diff --git a/CMOD/CMakeLists.txt b/CMOD/CMakeLists.txt index 2da5431c..4ff88c38 100644 --- a/CMOD/CMakeLists.txt +++ b/CMOD/CMakeLists.txt @@ -10,6 +10,22 @@ file(GLOB_RECURSE CMOD_SRC "${PROJECT_SOURCE_DIR}/src/*.h" ) +list(REMOVE_ITEM CMOD_SRC + "${PROJECT_SOURCE_DIR}/src/ModifierUsage.cpp" + "${PROJECT_SOURCE_DIR}/src/ModifierUsage.hpp" +) + +add_library(ModifierUsage STATIC + src/ModifierUsage.cpp + src/ModifierUsage.hpp +) +add_library(DISSCO::ModifierUsage ALIAS ModifierUsage) + +target_include_directories(ModifierUsage PUBLIC + "${PROJECT_SOURCE_DIR}/src" +) +target_compile_features(ModifierUsage PUBLIC cxx_std_20) + add_executable(CMOD ${CMOD_SRC}) # CMOD is consumed by LASSIE as a standalone subprocess (QProcess::start), # not linked into it. No ENABLE_EXPORTS / WINDOWS_EXPORT_ALL_SYMBOLS is @@ -22,6 +38,7 @@ target_link_libraries(CMOD PUBLIC PUGIXML) # add_subdirectory(${CMAKE_SOURCE_DIR}/external-libs/muParser) target_link_libraries(CMOD PUBLIC MUPARSER) target_link_libraries(CMOD PUBLIC LASS) +target_link_libraries(CMOD PUBLIC DISSCO::ModifierUsage) message(STATUS "libsndfile, pugixml, muParser, and LASS includes linked with target!") # heavy libraries that won't change much from compile to compile in this project diff --git a/CMOD/src/Bottom.cpp b/CMOD/src/Bottom.cpp index cd00c5a7..b0473991 100644 --- a/CMOD/src/Bottom.cpp +++ b/CMOD/src/Bottom.cpp @@ -25,11 +25,73 @@ //----------------------------------------------------------------------------// #include "Bottom.h" +#include "ModifierUsage.hpp" #include "Random.h" #include "Output.h" +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include + static int test=0; +struct Bottom::ModifierUsageRuntime { + std::optional program; + std::vector modifierIds; +}; + +namespace { + +bool parseStrictDouble(const char* text, double& value) { + if (text == NULL || *text == '\0') { + return false; + } + + errno = 0; + char* end = NULL; + value = std::strtod(text, &end); + if (end == text || errno == ERANGE) { + return false; + } + while (*end != '\0' && std::isspace(static_cast(*end))) { + ++end; + } + return *end == '\0'; +} + +bool isUnavailable(const string& value) { + std::size_t first = 0; + while (first < value.size() + && std::isspace(static_cast(value[first]))) { + ++first; + } + + std::size_t last = value.size(); + while (last > first + && std::isspace(static_cast(value[last - 1]))) { + --last; + } + + if (first == last) { + return true; + } + if (last - first != 3) { + return false; + } + return std::tolower(static_cast(value[first])) == 'n' + && value[first + 1] == '/' + && std::tolower(static_cast(value[first + 2])) == 'a'; +} + +} // namespace + //----------------------------------------------------------------------------// @@ -61,7 +123,7 @@ Bottom::Bottom(pugi::xml_node _element, 5 6 f - + @@ -94,10 +156,12 @@ Bottom::Bottom(pugi::xml_node _element, filterElement = extraInfo.child("Filter"); } - /* ZIYUAN CHEN, July 2023 */ - modifierGroupElement = extraInfo.child("ModifierGroup"); modifiersElement = extraInfo.child("Modifiers"); + // Modifier Usage is now the only runtime path. Files without the marker are + // adapted below with explicit, deterministic best-effort defaults. + initializeModifierUsage(extraInfo.child("ModifierUsage")); + } @@ -1282,439 +1346,503 @@ Reverb* Bottom::computeReverberationAdvanced(pugi::xml_node percentElement, //-----------------------------------------------------------------------------/ -void Bottom::applyModifiers(Sound *s, int numPartials) { - map > modGroups; // ZIYUAN CHEN, July 2023 - map group names to the mods +void Bottom::initializeModifierUsage(pugi::xml_node modifierUsageElement) { + using namespace dissco::modifier_usage; + modifierUsageRuntime = std::make_unique(); + + vector adapterDiagnostics; + Config config; + const bool useLegacyDefaults = !modifierUsageElement; + + if (useLegacyDefaults) { + cerr << "WARNING: Bottom '" << name + << "' has no marker. Legacy is " + "ignored; using best-effort Modifier Usage with per-sound " + "sampling, default ON chance 1, and stable synthetic IDs for " + "modifiers without metadata." + << endl; + config.scope = SamplingScope::PerSound; + } else { + const string version = modifierUsageElement.attribute("version").value(); + if (version != "1") { + adapterDiagnostics.push_back( + "unsupported or missing ModifierUsage version '" + version + "'."); + } + + const string samplingScope = + modifierUsageElement.attribute("samplingScope").value(); + if (samplingScope == "per-sound") { + config.scope = SamplingScope::PerSound; + } else if (samplingScope == "per-bottom") { + config.scope = SamplingScope::PerBottom; + } else { + config.scope = static_cast(-1); + adapterDiagnostics.push_back( + "samplingScope must be 'per-sound' or 'per-bottom'."); + } + } pugi::xml_document mergedModifiersDoc; - pugi::xml_node modifiersIncludingAncestorsElement; + pugi::xml_node mergedModifiers = + mergedModifiersDoc.append_child("Modifiers"); if (modifiersElement) { - modifiersIncludingAncestorsElement = mergedModifiersDoc.append_copy(modifiersElement); - } else { - modifiersIncludingAncestorsElement = mergedModifiersDoc.append_child("Modifiers"); + for (auto modifier = GFEC(modifiersElement); modifier; + modifier = GNES(modifier)) { + mergedModifiers.append_copy(modifier); + } } if (ancestorModifiersElement) { - for (auto a = GFEC(ancestorModifiersElement); a; a = GNES(a)) { - modifiersIncludingAncestorsElement.append_copy(a); + for (auto modifier = GFEC(ancestorModifiersElement); modifier; + modifier = GNES(modifier)) { + mergedModifiers.append_copy(modifier); } } - //cout<<"Bottom-"< -// 7 -// 0 -// EnvLib11.0 -// EnvLib21.0 -// -// -// -// -// -// -// -// - - pugi::xml_node arg = GFEC(modifierElement); - string modType; - const int modTypeCode = (int)utilities->evaluate(XMLTC(arg), this); - switch (modTypeCode){ - case 0: modType = "TREMOLO"; break; - case 1: modType = "VIBRATO"; break; - case 2: modType = "GLISSANDO"; break; - // case 3: modType = "BEND"; break; - case 3: modType = "DETUNE"; break; - case 4: modType = "AMPTRANS"; break; - case 5: modType = "FREQTRANS"; break; - case 6: modType = "WAVE_TYPE"; break; - case 7: modType = "PHASE_MOD"; break; - default: - cerr << "WARNING: Ignoring unknown Bottom modifier type " - << modTypeCode << " in " << name << "." << endl; - modifierElement = GNES(modifierElement); - continue; + // Reserve every explicit ID before generating any fallback IDs. This keeps + // synthesis deterministic while avoiding a collision with a later modifier. + std::unordered_set reservedModifierIds; + for (auto modifierElement = mergedModifiers.child("Modifier"); + modifierElement; + modifierElement = modifierElement.next_sibling("Modifier")) { + const ModifierId explicitId = + modifierElement.child("Usage").attribute("id").value(); + if (!explicitId.empty()) { + reservedModifierIds.insert(explicitId); } - //cout<<"Mod Type: "<evaluate(XMLTC(arg), this)==0)?"SOUND":"PARTIAL"; - - arg = GNES(arg); - - Envelope* probEnv = NULL; - pugi::xml_node ampElement, spreadElement, directionElement, velocityElement, rateElement, widthElement, - partialResultStringElement; - string ampStr, spreadStr, directionStr, velocityStr, rateStr, widthStr, probStr, partialResultStr; + } - // Only evaluate the envelope if we apply by SOUND. Otherwise, may segfault on empty probability envelopes. - if (applyHow == "SOUND") { - probStr = XMLTC(arg); - if (!probStr.empty()) { - probEnv = (Envelope*)utilities->evaluateObject(probStr, this, eventEnv); + int modifierPosition = 0; + for (auto modifierElement = mergedModifiers.child("Modifier"); + modifierElement; + modifierElement = modifierElement.next_sibling("Modifier")) { + ++modifierPosition; + Entry entry; + pugi::xml_node usageElement = modifierElement.child("Usage"); + if (!usageElement) { + if (useLegacyDefaults) { + string syntheticId = + "__legacy_modifier_" + std::to_string(modifierPosition); + int collisionSuffix = 2; + while (reservedModifierIds.find(syntheticId) + != reservedModifierIds.end()) { + syntheticId = "__legacy_modifier_" + + std::to_string(modifierPosition) + "_" + + std::to_string(collisionSuffix++); + } + reservedModifierIds.insert(syntheticId); + entry.id = std::move(syntheticId); + entry.defaultOnChance = 1.0; + } else { + adapterDiagnostics.push_back( + "modifier #" + std::to_string(modifierPosition) + + " has no appended metadata."); + // Keep one invalid entry in the configured order so the shared + // compiler can also report the empty identity. + entry.defaultOnChance = std::numeric_limits::quiet_NaN(); } + modifierUsageRuntime->modifierIds.push_back(entry.id); + config.orderedModifiers.push_back(std::move(entry)); + continue; } - ampElement = GNES(arg); - rateElement = GNES(ampElement); - widthElement = GNES(rateElement); - spreadElement = GNES(GNES(GNES(ampElement))); - directionElement = GNES(spreadElement); - velocityElement = GNES(directionElement); - partialResultStringElement = GNES(GNES(velocityElement)); - - ampStr = XMLTC(ampElement); - spreadStr = XMLTC(spreadElement); - directionStr = XMLTC(directionElement); - velocityStr = XMLTC(velocityElement); - rateStr = XMLTC(rateElement); - widthStr = XMLTC(widthElement); - partialResultStr = XMLTC(partialResultStringElement); - - // ADDED BY TEJUS - // skip group name - // pugi::xml_node partialResultStringElement = GNES(GNES(widthElement)); - - // TEJUS 2/8 - // and apply modifiers one by one via their partial number. - // TODO: Validate the partial num to make sure that it does not go out of range. - - if (applyHow == "SOUND") { - Modifier newMod(modType, probEnv, applyHow); - - // TEST - if (ampStr!=""){ - Envelope* env = (Envelope*)utilities->evaluateObject(ampStr, this, eventEnv ); - newMod.addValueEnv(env); - delete env; - } - if (spreadStr != ""){ - newMod.addSpread(atof(spreadStr.c_str())); - } - if (directionStr != ""){ - newMod.addDirection(atof(directionStr.c_str())); - } - if (velocityStr != ""){ - newMod.addVelocity(atof(velocityStr.c_str())); -float vel; - } - if (rateStr!=""){ - Envelope* env = (Envelope*)utilities->evaluateObject(rateStr, this, eventEnv ); - newMod.addValueEnv(env); - delete env; - } - if (widthStr!=""){ - Envelope* env = (Envelope*)utilities->evaluateObject(widthStr, this, eventEnv ); - newMod.addValueEnv(env); - delete env; + entry.id = usageElement.attribute("id").value(); + if (useLegacyDefaults && entry.id.empty()) { + string syntheticId = + "__legacy_modifier_" + std::to_string(modifierPosition); + int collisionSuffix = 2; + while (reservedModifierIds.find(syntheticId) + != reservedModifierIds.end()) { + syntheticId = "__legacy_modifier_" + + std::to_string(modifierPosition) + "_" + + std::to_string(collisionSuffix++); } + reservedModifierIds.insert(syntheticId); + entry.id = std::move(syntheticId); + } + modifierUsageRuntime->modifierIds.push_back(entry.id); + if (!parseStrictDouble(usageElement.attribute("defaultOn").value(), + entry.defaultOnChance)) { + entry.defaultOnChance = std::numeric_limits::quiet_NaN(); + adapterDiagnostics.push_back( + "modifier '" + entry.id + "' has an invalid defaultOn value."); + } - /* ZIYUAN CHEN, July 2023 - Categorizing a modifier into groups */ - arg = GNES(velocityElement);//group name - std::stringstream ss(XMLTC(arg)); - std::string groupName; - while (std::getline(ss, groupName, ',')) { - /* strip leading and trailing whitespaces to be compatible with - Name1, Name2, Name3 in Select - RandomInt function */ - groupName.erase(0, groupName.find_first_not_of(' ')); - groupName.erase(groupName.find_last_not_of(' ') + 1); - modGroups[groupName].push_back(newMod); + pugi::xml_node exceptionsElement = usageElement.child("Exceptions"); + for (auto exceptionElement = exceptionsElement.child("Exception"); + exceptionElement; + exceptionElement = exceptionElement.next_sibling("Exception")) { + Rule rule; + if (!parseStrictDouble(exceptionElement.attribute("onChance").value(), + rule.onChance)) { + rule.onChance = std::numeric_limits::quiet_NaN(); + adapterDiagnostics.push_back( + "modifier '" + entry.id + + "' has an exception with an invalid onChance value."); } - delete probEnv; - } - else if (applyHow == "PARTIAL") { - pugi::xml_document partialResultDoc; - partialResultDoc.load_string(partialResultStr.c_str()); - pugi::xml_node root = partialResultDoc.document_element(); - pugi::xml_node thisElement = GFEC(root); //start of envelopes - thisElement = GNES(thisElement); //envelopes - - pugi::xml_node envelopeElement = GFEC(thisElement);//first envelope - for (int i = 0; i evaluateObject(probStr, this, eventEnv); - } - - - // Make a new modifier - Modifier newPartialMod(modType, probEnv, applyHow, i); - envelopeElement = GNES(envelopeElement); - ampStr = XMLTC(envelopeElement); - // return; - envelopeElement = GNES(envelopeElement); - widthStr = XMLTC(envelopeElement); - envelopeElement = GNES(envelopeElement); - rateStr = XMLTC(envelopeElement); - if (ampStr!="" && ampStr!="N/A"){ - Envelope* env = (Envelope*)utilities->evaluateObject(ampStr, this, eventEnv ); - newPartialMod.addValueEnv(env); - delete env; + for (auto whenElement = exceptionElement.child("When"); + whenElement; + whenElement = whenElement.next_sibling("When")) { + Predicate predicate; + predicate.modifierId = + whenElement.attribute("modifierId").value(); + const string state = whenElement.attribute("state").value(); + if (state == "on") { + predicate.requiredOn = true; + } else if (state == "off") { + predicate.requiredOn = false; + } else { + adapterDiagnostics.push_back( + "modifier '" + entry.id + + "' has a condition whose state is not 'on' or 'off'."); } - // PHASE_MOD consumes magnitude and rate only. PartialResultString - // retains the shared width placeholder, but it must not enter the PM - // envelope queue and displace the rate envelope. - if (modType != "PHASE_MOD" && widthStr!="" && widthStr!="N/A"){ - Envelope* env = (Envelope*)utilities->evaluateObject(widthStr, this, eventEnv ); - newPartialMod.addValueEnv(env); - delete env; - } - if (rateStr!="" && rateStr!="N/A"){ - Envelope* env = (Envelope*)utilities->evaluateObject(rateStr, this, eventEnv ); - newPartialMod.addValueEnv(env); - delete env; - } - //return; - - - // delete probEnv; - arg = GNES(velocityElement);//group name - std::stringstream ss(XMLTC(arg)); - std::string groupName; - while (std::getline(ss, groupName, ',')) { - groupName.erase(0, groupName.find_first_not_of(' ')); - groupName.erase(groupName.find_last_not_of(' ') + 1); - modGroups[groupName].push_back(newPartialMod); + if (predicate.modifierId.empty()) { + adapterDiagnostics.push_back( + "modifier '" + entry.id + + "' has a condition with no modifierId."); } - delete probEnv; - envelopeElement = GNES(envelopeElement); + rule.when.push_back(std::move(predicate)); } + entry.rules.push_back(std::move(rule)); } - - modifierElement = GNES(modifierElement); // go to the next MOD in the list - } // end of the main while loop - - /* ZIYUAN CHEN, July 2023 - - Here, we evaluate the target "Modifier Group" name to be applied. - The user may put a string (e.g., "Apple") or a function in this field. - The function is always a random selection between strings with the following syntax: - - Select - Apple,Boy,Cat - - - RandomInt - 0 - 2 - - - - Since utilities->evaluate() returns a double everywhere else (and correspondingly, - holds numbers instead of strings), a special mechanism is implemented here - to (1) evaluate as a "RandomInt" function and (2) manually extract the - desired element, instead of rewriting utilities->evaluate(). - */ - - string targetModGroupName = XMLTC(modifierGroupElement); - - if (targetModGroupName.find("") != string::npos) { // evaluate if it's function string - - pugi::xml_node modifierGroupListElement = GNES(GFEC(GFEC(modifierGroupElement))); // - pugi::xml_node modifierGroupIndexFunElement = GNES(modifierGroupListElement); // - int targetModGroupIndex = (int)utilities->evaluate(XMLTC(modifierGroupIndexFunElement), this); - - std::stringstream ss(XMLTC(modifierGroupListElement)); - while (std::getline(ss, targetModGroupName, ',') && targetModGroupIndex > 0) { - targetModGroupName.erase(0, targetModGroupName.find_first_not_of(' ')); - targetModGroupName.erase(targetModGroupName.find_last_not_of(' ') + 1); - targetModGroupIndex--; - } - + config.orderedModifiers.push_back(std::move(entry)); } - // ZIYUAN CHEN, July 2023 - apply the specified one (1) group of modifiers - if (modGroups.find(targetModGroupName) != modGroups.end()) { - vector modGroup = modGroups[targetModGroupName]; - for (unsigned i = 0; i < modGroup.size(); i++) { - // PHASE_MOD is new, so its probability can use the intended modifier - // mechanism without changing how existing project modifiers render. - // The other Bottom modifier types historically apply unconditionally in - // this path; preserve that behavior for old-project audio compatibility. - if (modGroup[i].getModName() != "PHASE_MOD" || - modGroup[i].willOccur(checkPoint)) { - modGroup[i].applyModifier(s); - } - } - } else { - cerr << "WARNING: Specified modifier group " << targetModGroupName << " not found!" << endl; + CompileOptions compileOptions; + compileOptions.overallUsageMode = OverallUsageMode::Skip; + CompileResult compiled = compile(std::move(config), compileOptions); + for (const string& diagnostic : adapterDiagnostics) { + cerr << "Bottom::ModifierUsage configuration error in " << name + << ": " << diagnostic << endl; + } + for (const Diagnostic& diagnostic : compiled.diagnostics) { + cerr << "Bottom::ModifierUsage configuration error in " << name + << ": " << diagnostic.message << endl; } - //delete modifiersIncludingAncestorsElement; + if (adapterDiagnostics.empty() && compiled.program.has_value()) { + modifierUsageRuntime->program.emplace( + std::move(*compiled.program)); + } } -//----------------------------------------------------------------------------// +//-----------------------------------------------------------------------------/ -vector Bottom::applyNoteModifiersOld() { - vector modNames; +void Bottom::applyModifierUsage(Sound *s, int numPartials) { + using dissco::modifier_usage::ModifierId; + using dissco::modifier_usage::Selection; - vector modNoDep; //mods without dependencies - map > modMutEx; // map mutex group names to the mods - cout << "Bottom::applyNoteModifiers begin" << endl; + if (!modifierUsageRuntime->program.has_value()) { + // Diagnostics were emitted once when this Bottom was constructed. + return; + } + + struct RuntimeModifier { + std::unique_ptr effect; + bool applyByPartial = false; + }; + std::unordered_map> modifiersById; + bool runtimeValid = true; + auto runtimeError = [this, &runtimeValid](const string& message) { + runtimeValid = false; + cerr << "Bottom::ModifierUsage runtime error in " << name + << ": " << message << endl; + }; pugi::xml_document mergedModifiersDoc; - pugi::xml_node modifiersIncludingAncestorsElement = - mergedModifiersDoc.append_copy(modifiersElement); + pugi::xml_node mergedModifiers = + mergedModifiersDoc.append_child("Modifiers"); + if (modifiersElement) { + for (auto modifier = GFEC(modifiersElement); modifier; + modifier = GNES(modifier)) { + mergedModifiers.append_copy(modifier); + } + } if (ancestorModifiersElement) { - for (auto a = GFEC(ancestorModifiersElement); a; a = GNES(a)) { - modifiersIncludingAncestorsElement.append_copy(a); + for (auto modifier = GFEC(ancestorModifiersElement); modifier; + modifier = GNES(modifier)) { + mergedModifiers.append_copy(modifier); } } - cout << "Bottom-"<* modList = modifiersFV->getListPtr(this); - list::iterator modIter = modList->begin(); -*/ - - pugi::xml_node modifierElement = GFEC(modifiersIncludingAncestorsElement); -/* - pugi::xml_node modifierElement = GFEC(modifiersElement); - - cout<<"modifierElement: "<> sever; -*/ - - while (modifierElement != NULL) { + std::size_t modifierPosition = 0; + for (auto modifierElement = mergedModifiers.child("Modifier"); + modifierElement; + modifierElement = modifierElement.next_sibling("Modifier")) { + if (modifierPosition >= modifierUsageRuntime->modifierIds.size()) { + runtimeError("the compiled modifier order does not match the XML."); + break; + } + const ModifierId& usageId = + modifierUsageRuntime->modifierIds[modifierPosition++]; + if (usageId.empty()) { + runtimeError("a modifier has no usable Modifier Usage ID."); + continue; + } - pugi::xml_node arg = GFEC(modifierElement); + const int modTypeCode = static_cast( + utilities->evaluate(XMLTC(modifierElement.child("Type")), this)); string modType; - switch ((int)utilities->evaluate(XMLTC(arg), this)){ + switch (modTypeCode) { case 0: modType = "TREMOLO"; break; case 1: modType = "VIBRATO"; break; case 2: modType = "GLISSANDO"; break; - // case 3: modType = "BEND"; break; case 3: modType = "DETUNE"; break; + case 4: modType = "AMPTRANS"; break; + case 5: modType = "FREQTRANS"; break; + case 6: modType = "WAVE_TYPE"; break; + case 7: modType = "PHASE_MOD"; break; + default: + runtimeError("modifier '" + usageId + + "' has an unknown Type value."); + continue; } - //cout<<"Mod Type: "<evaluate(XMLTC(arg), this) == 0)? "SOUND":"PARTIAL"; - - arg = GNES(arg); - Envelope* probEnv = - (Envelope*)utilities->evaluateObject(XMLTC(arg), this, eventEnv); - - pugi::xml_node ampElement = GNES(arg); - pugi::xml_node rateElement = GNES(ampElement); - - string ampStr = XMLTC(ampElement); - string rateStr = XMLTC(rateElement); - cout << "Bottom::applyNoteModifiers - rateStr: " << rateStr << endl; - - Modifier newMod(modType, probEnv, applyHow); -/* needs to be rewrite to remove filevalue - while (modIter != modList->end()) { - // create the modifier and add it to the proper group - list::iterator currMod = modIter->getListPtr(this)->begin(); - // 1st arg is string, 2nd is env - string modType = currMod->getString(this); - currMod++; - Envelope* probEnv = currMod->getEnvelope(this); - currMod++; + const int applyHowCode = static_cast( + utilities->evaluate(XMLTC(modifierElement.child("ApplyHow")), this)); + if (applyHowCode != 0 && applyHowCode != 1) { + runtimeError("modifier '" + usageId + + "' has an invalid ApplyHow value."); + continue; + } + const bool applyByPartial = applyHowCode == 1; + + const string ampStr = XMLTC(modifierElement.child("Amplitude")); + const string rateStr = XMLTC(modifierElement.child("Rate")); + const string widthStr = XMLTC(modifierElement.child("Width")); + const string spreadStr = XMLTC(modifierElement.child("DetuneSpread")); + const string directionStr = XMLTC(modifierElement.child("DetuneDirection")); + const string velocityStr = XMLTC(modifierElement.child("DetuneVelocity")); + + double detuneSpread = 0.0; + double detuneDirection = 0.0; + double detuneVelocity = 0.0; + if (!applyByPartial && modType == "DETUNE") { + if (isUnavailable(spreadStr) || isUnavailable(directionStr) + || isUnavailable(velocityStr)) { + runtimeError("DETUNE modifier '" + usageId + + "' requires spread, direction, and velocity."); + continue; + } + if (!parseStrictDouble(spreadStr.c_str(), detuneSpread) + || !std::isfinite(detuneSpread) + || detuneSpread < 0.0 || detuneSpread > 1.0) { + runtimeError("DETUNE modifier '" + usageId + + "' has an invalid spread; expected a finite value " + "between 0 and 1."); + continue; + } + if (!parseStrictDouble(directionStr.c_str(), detuneDirection) + || !std::isfinite(detuneDirection) + || detuneDirection == 0.0) { + runtimeError("DETUNE modifier '" + usageId + + "' has an invalid direction; expected a finite, " + "non-zero value."); + continue; + } + if (!parseStrictDouble(velocityStr.c_str(), detuneVelocity) + || !std::isfinite(detuneVelocity) + || detuneVelocity < -1.0 || detuneVelocity > 1.0) { + runtimeError("DETUNE modifier '" + usageId + + "' has an invalid velocity; expected a finite value " + "between -1 and 1."); + continue; + } + detuneDirection = detuneDirection < 0.0 ? -1.0 : 1.0; + } - Modifier newMod(modType, probEnv, "SOUND"); -*/ + auto addEnvelope = [this, &runtimeError, &usageId]( + Modifier& modifier, + const string& value, + const char* fieldName) { + if (isUnavailable(value)) { + return false; + } + Envelope* envelope = static_cast( + utilities->evaluateObject(value, this, eventEnv)); + if (envelope == NULL) { + runtimeError("modifier '" + usageId + "' has an invalid " + + fieldName + " envelope."); + return false; + } + modifier.addValueEnv(envelope); + delete envelope; + return true; + }; + + if (!applyByPartial) { + auto effect = std::make_unique(modType, nullptr, "SOUND"); + int envelopeCount = 0; + envelopeCount += addEnvelope(*effect, ampStr, "Amplitude") ? 1 : 0; + if (modType == "DETUNE") { + effect->addSpread(detuneSpread); + effect->addDirection(detuneDirection); + effect->addVelocity(detuneVelocity); + } + envelopeCount += addEnvelope(*effect, rateStr, "Rate") ? 1 : 0; + envelopeCount += addEnvelope(*effect, widthStr, "Width") ? 1 : 0; + + int requiredEnvelopeCount = 0; + if (modType == "TREMOLO" || modType == "VIBRATO" + || modType == "PHASE_MOD") { + requiredEnvelopeCount = 2; + } else if (modType == "GLISSANDO" || modType == "WAVE_TYPE") { + requiredEnvelopeCount = 1; + } else if (modType == "AMPTRANS" || modType == "FREQTRANS") { + requiredEnvelopeCount = 3; + } + if (envelopeCount < requiredEnvelopeCount) { + runtimeError("modifier '" + usageId + + "' is missing a required parameter envelope."); + continue; + } + modifiersById[usageId].push_back( + RuntimeModifier{std::move(effect), false}); + continue; + } -/* needs to be rewrite to remove filevalue - string mutExGroup = ""; + const string partialResultStr = + XMLTC(modifierElement.child("PartialResultString")); + pugi::xml_document partialResultDoc; + const pugi::xml_parse_result parseResult = + partialResultDoc.load_string(partialResultStr.c_str()); + const pugi::xml_node root = partialResultDoc.document_element(); + pugi::xml_node envelopeElement = + root.child("Envelopes").child("Envelope"); + if (!parseResult || !root || !envelopeElement) { + runtimeError("PARTIAL modifier '" + usageId + + "' has an invalid PartialResultString."); + continue; + } - while (mutExGroup == "" && currMod != modIter->getListPtr(this)->end()) { - FileValue mutExFV = currMod->getListPtr(this)->back(); - if (mutExFV.getReturnType() == FVAL_STRING) { - mutExGroup = mutExFV.getString(this); - } else { - cerr << "Bottom::applyModifiers error: invalid syntax for MUT_EX group!" << endl; - exit(1); + // A logical PARTIAL modifier may intentionally have no enabled rows (for + // example, every Probability slot is N/A). Keep the ID represented so it + // remains a valid no-op when selected. + vector& runtimeModifiers = modifiersById[usageId]; + for (int partialIndex = 0; partialIndex < numPartials; ++partialIndex) { + // Fewer configured rows than spectrum partials is intentional: the + // remaining partials simply do not receive this modifier. Once a row + // starts, however, the legacy wire format still requires all four slots. + if (!envelopeElement) { + break; + } + pugi::xml_node probabilityElement = envelopeElement; + pugi::xml_node amplitudeElement = + probabilityElement.next_sibling("Envelope"); + pugi::xml_node widthElement = + amplitudeElement.next_sibling("Envelope"); + pugi::xml_node rateElement = + widthElement.next_sibling("Envelope"); + if (!probabilityElement || !amplitudeElement + || !widthElement || !rateElement) { + runtimeError("PARTIAL modifier '" + usageId + + "' contains fewer than four envelopes per partial."); + break; } -*/ - arg = GNES(rateElement);//group name (MUT_EX) - string mutExGroup = XMLTC(arg); + const string probabilityStr = XMLTC(probabilityElement); + if (isUnavailable(probabilityStr)) { + envelopeElement = rateElement.next_sibling("Envelope"); + continue; + } - if (applyHow == "SOUND") { + Envelope* probabilityEnvelope = static_cast( + utilities->evaluateObject(probabilityStr, this, eventEnv)); + if (probabilityEnvelope == NULL) { + runtimeError("PARTIAL modifier '" + usageId + + "' has an invalid Probability envelope."); + } - if (ampStr!=""){ - Envelope* env = - (Envelope*)utilities->evaluateObject(ampStr, this, eventEnv ); - newMod.addValueEnv(env); - delete env; + auto effect = std::make_unique( + modType, probabilityEnvelope, "PARTIAL", partialIndex); + delete probabilityEnvelope; + + int envelopeCount = 0; + envelopeCount += addEnvelope( + *effect, XMLTC(amplitudeElement), "partial Amplitude") ? 1 : 0; + envelopeCount += addEnvelope( + *effect, XMLTC(rateElement), "partial Rate") ? 1 : 0; + if (modType != "PHASE_MOD") { + envelopeCount += addEnvelope( + *effect, XMLTC(widthElement), "partial Width") ? 1 : 0; } - if (rateStr!=""){ - Envelope* env = - (Envelope*)utilities->evaluateObject(rateStr, this, eventEnv ); - newMod.addValueEnv(env); - delete env; + int requiredEnvelopeCount = 0; + if (modType == "TREMOLO" || modType == "VIBRATO" + || modType == "PHASE_MOD") { + requiredEnvelopeCount = 2; + } else if (modType == "GLISSANDO" || modType == "DETUNE" + || modType == "WAVE_TYPE") { + requiredEnvelopeCount = 1; + } else if (modType == "AMPTRANS" || modType == "FREQTRANS") { + requiredEnvelopeCount = 3; + } + if (envelopeCount < requiredEnvelopeCount) { + runtimeError("PARTIAL modifier '" + usageId + + "' is missing a required parameter envelope."); + break; } - } - else if (applyHow == "PARTIAL") { - cout << "ERROR: Note does not use PARTIAL" << endl; - } - if (mutExGroup == "") { - // not MUT_EX - modNoDep.push_back(newMod); - } else { - // mutually exclusive - modMutEx[mutExGroup].push_back(newMod); + runtimeModifiers.push_back(RuntimeModifier{std::move(effect), true}); + envelopeElement = rateElement.next_sibling("Envelope"); } -/* arg = GNES(widthElement);//group name (MUT_EX) - string mutExGroup = XMLTC(arg); - - modIter++; // go to the next MOD in the list } -*/ - delete probEnv; - modifierElement = GNES(modifierElement); // go to the next MOD in the list - } // end of the main while loop - - // go through the non-exclusive mods - for (unsigned i = 0; i < modNoDep.size(); i++) { - if (modNoDep[i].willOccur(checkPoint)) { - modNames.push_back( modNoDep[i].getModName() ); + // Program compilation and runtime effect parsing must describe the same + // ordered logical modifiers. Check this before drawing or applying anything. + for (const ModifierId& modifierId : modifierUsageRuntime->modifierIds) { + const auto found = modifiersById.find(modifierId); + if (found == modifiersById.end()) { + runtimeError("compiled modifier '" + modifierId + + "' has no runtime effect."); } } + if (!runtimeValid) { + return; + } - // go through the exclusive mods - map >::iterator iter = modMutEx.begin(); - if (iter != modMutEx.end()) { - vector modGroup = (*iter).second; + Selection selection; + try { + selection = modifierUsageRuntime->program->select( + []() { return Random::Rand(); }); + } catch (const std::exception& error) { + runtimeError(error.what()); + return; + } - //go through the group, and apply 1 at most - bool appliedMod = false; - for (unsigned i = 0; i < modGroup.size() && !appliedMod; i++) { - if (modGroup[i].willOccur(checkPoint)) { - modNames.push_back( modGroup[i].getModName() ); - appliedMod = true; + // Selection IDs are already in Program order. A selected PARTIAL logical + // modifier still uses its existing per-partial Probability envelope as a + // second-stage gate. + for (const ModifierId& selectedId : selection.orderedOnIds) { + auto found = modifiersById.find(selectedId); + if (found == modifiersById.end()) { + runtimeError("selection returned unknown modifier '" + selectedId + "'."); + return; + } + for (RuntimeModifier& runtimeModifier : found->second) { + Modifier& effect = *runtimeModifier.effect; + if (runtimeModifier.applyByPartial) { + if (effect.willOccur(checkPoint)) { + effect.applyModifier(s); + } + } else { + effect.setCheckPoint(checkPoint); + effect.applyModifier(s); } } - iter++; } +} - //delete modifiersIncludingAncestorsElement; +//-----------------------------------------------------------------------------/ - return modNames; +void Bottom::applyModifiers(Sound *s, int numPartials) { + applyModifierUsage(s, numPartials); } +//----------------------------------------------------------------------------// + // int applyNoteStaffs(pugi::xml_node _playingMethods){ // int noteStaff; diff --git a/CMOD/src/Bottom.h b/CMOD/src/Bottom.h index 7bb29c94..62de0d70 100644 --- a/CMOD/src/Bottom.h +++ b/CMOD/src/Bottom.h @@ -43,6 +43,7 @@ Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. #include "Random.h" #include "Piece.h" #include "Patter.h" +#include #include "ProbabilityEnvelope.h" // consider moving this into LASS.h #include #include @@ -64,7 +65,7 @@ Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * @brief Concrete leaf @ref Event that produces Sounds and Notes. * * Bottom keeps the XML element handles for the per-event attribute blocks - * (frequency, loudness, modifiers, modifier group, ancestor modifiers) and + * (frequency, loudness, modifiers, and ancestor modifiers) and * recomputes them for every child it emits — the same Bottom can produce * different frequencies / loudnesses on successive children when the * driving expressions contain randomness. @@ -85,11 +86,16 @@ class Bottom : public Event { /** Fixed carrier phase offset, expressed in normalized cycles. */ pugi::xml_node phaseElement; pugi::xml_node modifiersElement; - /* ZIYUAN CHEN, July 2023 - The "Modifier Group" is only present - in Bottom events, so this element doesn't appear in Event.h */ - pugi::xml_node modifierGroupElement; pugi::xml_node ancestorModifiersElement; + /** + * Conditional Modifier Usage is deliberately opaque here. Its + * implementation owns the validated shared Program, the resolved IDs + * used by the XML adapter, and the PerBottom selection cache. + */ + struct ModifierUsageRuntime; + std::unique_ptr modifierUsageRuntime; + //Current partial during the processing of the event int currPartialNum; @@ -470,22 +476,16 @@ class Bottom : public Event { string applyHow, int numPartials); - /** - * Use of modifiers: tremolo, vibrato, transients. Makes 3 lists/maps - - * one for modifiers with no dependencies, one for modifiers grouped - * together, and one for modifiers with direct dependencies on other - * modifiers. It goes through each list (in the order mentioned) to - * find which modifiers to use and their respective values and applies - * each of them. - **/ + /** Select and apply modifiers using conditional Modifier Usage. */ void applyModifiers(Sound *s, int numPartials); + void initializeModifierUsage(pugi::xml_node modifierUsageElement); + void applyModifierUsage(Sound *s, int numPartials); /** - * Apply modifiers for a note. - **/ + * Apply modifiers for a note. + **/ // vector applyNoteModifiers(); vector applyNoteModifiers(pugi::xml_node _playingMethods); - vector applyNoteModifiersOld(); // multistaffs /** * Apply staff for a note. diff --git a/CMOD/src/Modifier.cpp b/CMOD/src/Modifier.cpp index 18b28471..b12740ac 100644 --- a/CMOD/src/Modifier.cpp +++ b/CMOD/src/Modifier.cpp @@ -193,6 +193,12 @@ bool Modifier::willOccur(double checkPoint) { //----------------------------------------------------------------------------// +void Modifier::setCheckPoint(double checkPoint) { + checkPt = checkPoint; +} + +//----------------------------------------------------------------------------// + void Modifier::applyModifier(Sound* snd) { if (applyHow == "SOUND") { applyModSound(snd); diff --git a/CMOD/src/Modifier.h b/CMOD/src/Modifier.h index d2d73e39..1b276200 100644 --- a/CMOD/src/Modifier.h +++ b/CMOD/src/Modifier.h @@ -139,6 +139,12 @@ class Modifier { **/ bool willOccur(double checkPoint); + /** + * Set the event-relative checkpoint used by parameter envelopes when + * occurrence has already been decided by Modifier Usage. + */ + void setCheckPoint(double checkPoint); + /** * Apply the modifier to a sound * \param snd pointer to the sound to add this modifier to diff --git a/CMOD/src/ModifierUsage.cpp b/CMOD/src/ModifierUsage.cpp new file mode 100644 index 00000000..1b941ba6 --- /dev/null +++ b/CMOD/src/ModifierUsage.cpp @@ -0,0 +1,401 @@ +#include "ModifierUsage.hpp" + +#include +#include +#include +#include +#include +#include + +namespace dissco::modifier_usage { +namespace { + +struct CompiledPredicate { + std::size_t modifierIndex = 0; + bool requiredOn = false; +}; + +struct CompiledRule { + std::vector when; + double onChance = 0.0; +}; + +struct CompiledEntry { + ModifierId id; + double defaultOnChance = 1.0; + std::vector rules; +}; + +bool validProbability(double probability) +{ + return std::isfinite(probability) + && probability >= 0.0 + && probability <= 1.0; +} + +void addDiagnostic(std::vector& diagnostics, + DiagnosticCode code, + const ModifierId& targetId, + std::string message) +{ + diagnostics.push_back(Diagnostic{code, targetId, std::move(message)}); +} + +double resolvedProbability(const std::vector& entries, + std::size_t entryIndex, + const std::vector& decisions) +{ + const CompiledEntry& entry = entries[entryIndex]; + for (const CompiledRule& rule : entry.rules) { + const bool matches = std::all_of( + rule.when.begin(), rule.when.end(), + [&decisions](const CompiledPredicate& predicate) { + return decisions[predicate.modifierIndex] == predicate.requiredOn; + }); + if (matches) + return rule.onChance; + } + return entry.defaultOnChance; +} + +bool rulesCanOverlap(const CompiledRule& lhs, const CompiledRule& rhs) +{ + for (const CompiledPredicate& leftPredicate : lhs.when) { + const auto right = std::find_if( + rhs.when.begin(), rhs.when.end(), + [&leftPredicate](const CompiledPredicate& candidate) { + return candidate.modifierIndex == leftPredicate.modifierIndex; + }); + if (right != rhs.when.end() + && right->requiredOn != leftPredicate.requiredOn) { + return false; + } + } + return true; +} + +void accumulateOverallUsage(const std::vector& entries, + std::size_t entryIndex, + long double historyProbability, + std::vector& decisions, + std::vector& totals) +{ + if (entryIndex == entries.size() || historyProbability == 0.0L) + return; + + const long double onChance = static_cast( + resolvedProbability(entries, entryIndex, decisions)); + const long double onHistoryProbability = historyProbability * onChance; + const long double offHistoryProbability = + historyProbability * (1.0L - onChance); + + totals[entryIndex] += onHistoryProbability; + + if (onHistoryProbability != 0.0L) { + decisions[entryIndex] = true; + accumulateOverallUsage(entries, entryIndex + 1, + onHistoryProbability, decisions, totals); + } + if (offHistoryProbability != 0.0L) { + decisions[entryIndex] = false; + accumulateOverallUsage(entries, entryIndex + 1, + offHistoryProbability, decisions, totals); + } +} + +std::vector calculateOverallUsage( + const std::vector& entries) +{ + std::vector decisions(entries.size(), false); + std::vector totals(entries.size(), 0.0L); + accumulateOverallUsage(entries, 0, 1.0L, decisions, totals); + + std::vector result; + result.reserve(entries.size()); + for (std::size_t i = 0; i < entries.size(); ++i) { + result.push_back( + OverallUsage{entries[i].id, static_cast(totals[i])}); + } + return result; +} + +} // namespace + +struct Program::Impl { + SamplingScope scope = SamplingScope::PerSound; + std::vector entries; + std::vector overall; + std::optional perBottomSelection; +}; + +Program::Program(std::unique_ptr impl) + : m_impl(std::move(impl)) +{ +} + +Program::Program(Program&&) noexcept = default; +Program& Program::operator=(Program&&) noexcept = default; +Program::~Program() = default; + +Selection Program::select(const UnitRandom& nextUnit) +{ + if (!m_impl) + throw std::logic_error("Cannot select with a moved-from ModifierUsage Program."); + + if (m_impl->scope == SamplingScope::PerBottom + && m_impl->perBottomSelection.has_value()) { + return *m_impl->perBottomSelection; + } + + if (m_impl->entries.empty()) { + Selection selection; + if (m_impl->scope == SamplingScope::PerBottom) + m_impl->perBottomSelection = selection; + return selection; + } + + if (!nextUnit) + throw std::invalid_argument("ModifierUsage requires a random callback."); + + const double randomValue = nextUnit(); + if (!std::isfinite(randomValue) + || randomValue < 0.0 + || randomValue >= 1.0) { + throw std::domain_error( + "ModifierUsage random callback must return a finite value in [0, 1)."); + } + + long double remainder = static_cast(randomValue); + std::vector decisions(m_impl->entries.size(), false); + Selection selection; + selection.orderedOnIds.reserve(m_impl->entries.size()); + + for (std::size_t i = 0; i < m_impl->entries.size(); ++i) { + const long double probability = static_cast( + resolvedProbability(m_impl->entries, i, decisions)); + + if (remainder < probability) { + decisions[i] = true; + selection.orderedOnIds.push_back(m_impl->entries[i].id); + if (probability != 0.0L) + remainder /= probability; + } else { + decisions[i] = false; + if (probability != 1.0L) + remainder = (remainder - probability) / (1.0L - probability); + } + + // Exact interval arithmetic keeps the remainder in [0, 1). Floating + // point division can round a value at the upper edge to exactly one. + if (remainder >= 1.0L) + remainder = std::nextafter(1.0L, 0.0L); + else if (remainder < 0.0L) + remainder = 0.0L; + } + + if (m_impl->scope == SamplingScope::PerBottom) + m_impl->perBottomSelection = selection; + + return selection; +} + +const std::vector& Program::overallUsage() const noexcept +{ + static const std::vector empty; + return m_impl ? m_impl->overall : empty; +} + +CompileResult compile(Config config, CompileOptions options) +{ + CompileResult result; + + if (config.scope != SamplingScope::PerSound + && config.scope != SamplingScope::PerBottom) { + addDiagnostic(result.diagnostics, + DiagnosticCode::InvalidSamplingScope, + {}, + "Sampling scope is not supported."); + } + + std::unordered_map indexById; + indexById.reserve(config.orderedModifiers.size()); + for (std::size_t i = 0; i < config.orderedModifiers.size(); ++i) { + const ModifierId& id = config.orderedModifiers[i].id; + if (id.empty()) { + addDiagnostic(result.diagnostics, + DiagnosticCode::EmptyId, + {}, + "Modifier IDs must not be empty."); + continue; + } + + const auto [unused, inserted] = indexById.emplace(id, i); + if (!inserted) { + addDiagnostic(result.diagnostics, + DiagnosticCode::DuplicateId, + id, + "Modifier ID '" + id + "' is duplicated."); + } + } + + std::vector compiledEntries; + compiledEntries.reserve(config.orderedModifiers.size()); + + for (std::size_t entryIndex = 0; + entryIndex < config.orderedModifiers.size(); + ++entryIndex) { + const Entry& sourceEntry = config.orderedModifiers[entryIndex]; + CompiledEntry compiledEntry; + compiledEntry.id = sourceEntry.id; + compiledEntry.defaultOnChance = sourceEntry.defaultOnChance; + + if (!validProbability(sourceEntry.defaultOnChance)) { + addDiagnostic( + result.diagnostics, + DiagnosticCode::InvalidProbability, + sourceEntry.id, + "Default ON chance for modifier '" + sourceEntry.id + + "' must be finite and between 0 and 1."); + } + + for (std::size_t ruleIndex = 0; + ruleIndex < sourceEntry.rules.size(); + ++ruleIndex) { + const Rule& sourceRule = sourceEntry.rules[ruleIndex]; + CompiledRule compiledRule; + compiledRule.onChance = sourceRule.onChance; + bool ruleIsValid = true; + + if (sourceRule.when.empty()) { + addDiagnostic( + result.diagnostics, + DiagnosticCode::EmptyRule, + sourceEntry.id, + "An exception for modifier '" + sourceEntry.id + + "' must depend on at least one earlier modifier."); + ruleIsValid = false; + } + + if (!validProbability(sourceRule.onChance)) { + addDiagnostic( + result.diagnostics, + DiagnosticCode::InvalidProbability, + sourceEntry.id, + "Exception ON chance for modifier '" + sourceEntry.id + + "' must be finite and between 0 and 1."); + ruleIsValid = false; + } + + std::unordered_map predicateByIndex; + predicateByIndex.reserve(sourceRule.when.size()); + + for (const Predicate& predicate : sourceRule.when) { + const auto referenced = indexById.find(predicate.modifierId); + if (referenced == indexById.end()) { + addDiagnostic( + result.diagnostics, + DiagnosticCode::MissingReference, + sourceEntry.id, + "Modifier '" + sourceEntry.id + "' references missing modifier '" + + predicate.modifierId + "'."); + ruleIsValid = false; + continue; + } + + const std::size_t referencedIndex = referenced->second; + if (referencedIndex == entryIndex) { + addDiagnostic( + result.diagnostics, + DiagnosticCode::SelfReference, + sourceEntry.id, + "Modifier '" + sourceEntry.id + "' cannot depend on itself."); + ruleIsValid = false; + } else if (referencedIndex > entryIndex) { + addDiagnostic( + result.diagnostics, + DiagnosticCode::ForwardReference, + sourceEntry.id, + "Modifier '" + sourceEntry.id + + "' can reference only earlier modifiers."); + ruleIsValid = false; + } + + const auto [existing, inserted] = + predicateByIndex.emplace(referencedIndex, predicate.requiredOn); + if (!inserted) { + if (existing->second == predicate.requiredOn) { + addDiagnostic( + result.diagnostics, + DiagnosticCode::DuplicatePredicate, + sourceEntry.id, + "An exception for modifier '" + sourceEntry.id + + "' repeats a condition."); + } else { + addDiagnostic( + result.diagnostics, + DiagnosticCode::ConflictingPredicate, + sourceEntry.id, + "An exception for modifier '" + sourceEntry.id + + "' requires the same modifier to be both ON and OFF."); + } + ruleIsValid = false; + } + } + + if (ruleIsValid) { + compiledRule.when.reserve(predicateByIndex.size()); + for (const auto& [modifierIndex, requiredOn] : predicateByIndex) { + compiledRule.when.push_back( + CompiledPredicate{modifierIndex, requiredOn}); + } + std::sort( + compiledRule.when.begin(), compiledRule.when.end(), + [](const CompiledPredicate& lhs, + const CompiledPredicate& rhs) { + return lhs.modifierIndex < rhs.modifierIndex; + }); + compiledEntry.rules.push_back(std::move(compiledRule)); + } + } + + for (std::size_t left = 0; left < compiledEntry.rules.size(); ++left) { + for (std::size_t right = left + 1; + right < compiledEntry.rules.size(); + ++right) { + const CompiledRule& lhs = compiledEntry.rules[left]; + const CompiledRule& rhs = compiledEntry.rules[right]; + if (lhs.when.size() == rhs.when.size() + && rulesCanOverlap(lhs, rhs)) { + addDiagnostic( + result.diagnostics, + DiagnosticCode::AmbiguousRules, + sourceEntry.id, + "Modifier '" + sourceEntry.id + + "' has equal-specificity exceptions that can both match."); + } + } + } + + // Rule declaration order is deliberately non-semantic. + std::stable_sort( + compiledEntry.rules.begin(), compiledEntry.rules.end(), + [](const CompiledRule& lhs, const CompiledRule& rhs) { + return lhs.when.size() > rhs.when.size(); + }); + + compiledEntries.push_back(std::move(compiledEntry)); + } + + if (!result.diagnostics.empty()) + return result; + + auto impl = std::make_unique(); + impl->scope = config.scope; + impl->entries = std::move(compiledEntries); + if (options.overallUsageMode == OverallUsageMode::Exact) + impl->overall = calculateOverallUsage(impl->entries); + result.program.emplace(Program(std::move(impl))); + return result; +} + +} // namespace dissco::modifier_usage diff --git a/CMOD/src/ModifierUsage.hpp b/CMOD/src/ModifierUsage.hpp new file mode 100644 index 00000000..076c2744 --- /dev/null +++ b/CMOD/src/ModifierUsage.hpp @@ -0,0 +1,144 @@ +#ifndef DISSCO_MODIFIER_USAGE_HPP +#define DISSCO_MODIFIER_USAGE_HPP + +#include +#include +#include +#include +#include + +namespace dissco::modifier_usage { + +using ModifierId = std::string; +using UnitRandom = std::function; + +enum class SamplingScope { + PerSound, + PerBottom +}; + +struct Predicate { + ModifierId modifierId; + bool requiredOn = false; +}; + +struct Rule { + std::vector when; + double onChance = 0.0; +}; + +struct Entry { + ModifierId id; + double defaultOnChance = 1.0; + std::vector rules; +}; + +struct Config { + SamplingScope scope = SamplingScope::PerSound; + std::vector orderedModifiers; +}; + +enum class OverallUsageMode { + Exact, + Skip +}; + +struct CompileOptions { + OverallUsageMode overallUsageMode = OverallUsageMode::Exact; +}; + +enum class DiagnosticCode { + InvalidSamplingScope, + EmptyId, + DuplicateId, + InvalidProbability, + EmptyRule, + DuplicatePredicate, + ConflictingPredicate, + MissingReference, + SelfReference, + ForwardReference, + AmbiguousRules +}; + +struct Diagnostic { + DiagnosticCode code; + ModifierId targetId; + std::string message; +}; + +struct OverallUsage { + ModifierId id; + double chance = 0.0; +}; + +struct Selection { + // IDs remain in the configured selection/application order. + std::vector orderedOnIds; + + bool operator==(const Selection&) const = default; +}; + +struct CompileResult; + +/** + * A validated modifier-usage program. + * + * The program is movable but deliberately not copyable because PerBottom + * sampling owns a cached selection for one runtime Bottom instance. + */ +class Program { +public: + Program(Program&&) noexcept; + Program& operator=(Program&&) noexcept; + Program(const Program&) = delete; + Program& operator=(const Program&) = delete; + ~Program(); + + /** + * Select modifiers with one uniform random value in [0, 1). + * + * Non-empty PerSound programs call nextUnit once per invocation. Non-empty + * PerBottom programs call it only for the first invocation and return that + * cached selection thereafter. Empty programs draw nothing. A missing + * callback or a non-finite/out-of-range result throws std::invalid_argument + * or std::domain_error respectively. + */ + Selection select(const UnitRandom& nextUnit); + + /** + * Exact marginal ON probability for every modifier, in configured order. + * Empty when compilation explicitly skipped the exponential preview. + */ + const std::vector& overallUsage() const noexcept; + +private: + struct Impl; + explicit Program(std::unique_ptr impl); + + std::unique_ptr m_impl; + + friend CompileResult compile(Config config, CompileOptions options); +}; + +struct CompileResult { + std::optional program; + std::vector diagnostics; +}; + +/** + * Validate and compile a modifier-usage configuration. + * + * Invalid configurations return diagnostics and no Program. Rule order has no + * semantic effect: the most-specific matching rule wins. Two rules of equal + * specificity that can match the same history are rejected as ambiguous. + * + * Exact overall-usage calculation is exponential in the number of modifiers + * in the worst case, as required for arbitrary dependencies on earlier + * decisions. Runtime callers should select OverallUsageMode::Skip. + */ +CompileResult compile(Config config, CompileOptions options = {}); + +} // namespace dissco::modifier_usage + +#endif // DISSCO_MODIFIER_USAGE_HPP diff --git a/LASS/src/Sound.cpp b/LASS/src/Sound.cpp index 987585ca..756fdacd 100644 --- a/LASS/src/Sound.cpp +++ b/LASS/src/Sound.cpp @@ -31,6 +31,8 @@ Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. #include "Score.h" #include "Loudness.h" +#include + //----------------------------------------------------------------------------// Sound::Sound() { @@ -45,6 +47,7 @@ Sound::Sound() filterObj = NULL; reverbObj = NULL; spatializer_ = new Spatializer(); + spa_modified_ = false; } //----------------------------------------------------------------------------// @@ -141,26 +144,31 @@ void Sound::setPartialParam(PartialDynamicParam p, m_value_type v) //----------------------------------------------------------------------------// void Sound::setDetune(double direction, double spread, double velocity){ - if ( !(direction != -1 || direction != 1 )){ - cerr << "ERROR: Sound: out of range for DETUNE_DIRECTION." << endl; + // Preserve the historical all-zero sentinel as an explicit no-op. + if (spread == 0.0 && direction == 0.0 && velocity == 0.0){ return; } - if (spread < 0 or spread > 1){ - cerr << "ERROR: Sound: out of range DETUNE_SPREAD.Should be a percent" << endl; + if (!std::isfinite(direction) || direction == 0.0){ + cerr << "ERROR: Sound: DETUNE_DIRECTION must be a finite, non-zero value." + << endl; return; } - if ( velocity < -1 or velocity > 1 ){ - cerr << "ERROR: Sound: out of range for DETUNE_VELOCITY" << endl; + if (!std::isfinite(spread) || spread < 0.0 || spread > 1.0){ + cerr << "ERROR: Sound: DETUNE_SPREAD must be between 0 and 1." << endl; return; } - if (spread == 0 and direction == 0 and velocity == 0){ + if (!std::isfinite(velocity) || velocity < -1.0 || velocity > 1.0){ + cerr << "ERROR: Sound: DETUNE_VELOCITY must be between -1 and 1." << endl; return; } - setParam(DETUNE_DIRECTION,direction); + // Direction is a sign, not a magnitude. Canonicalizing here keeps the + // renderer's two envelope branches compatible with legacy positive and + // negative values such as 0.5 and -0.5. + setParam(DETUNE_DIRECTION, direction < 0.0 ? -1.0 : 1.0); setParam(DETUNE_SPREAD,spread); setParam(DETUNE_VELOCITY, velocity); // setParam(DETUNE_FUNDAMENTAL, 1); @@ -419,7 +427,7 @@ cout << "Final spread= " << spread << endl; y[1] += 1.0; y[2] += 1.0; - if(getParam(DETUNE_DIRECTION) == -1.0) // divergence (detuning) + if(getParam(DETUNE_DIRECTION) < 0.0) // divergence (detuning) { //cout << " diverging (detuning)" << endl; detuning_env->addEntry(x[0], y[2]); @@ -431,7 +439,7 @@ cout << " x1=" << x[1] << " y1=" << y[1] << endl; cout << " x2=" << x[2] << " y0=" << y[0] << endl; //int sever; cin >> sever; */ - } else if(getParam(DETUNE_DIRECTION) == 1.0) { // convergence (tuning) + } else if(getParam(DETUNE_DIRECTION) > 0.0) { // convergence (tuning) //cout << " converging (tuning)" << endl; detuning_env->addEntry(x[0], y[0]); detuning_env->addEntry(x[1], y[1]); @@ -486,7 +494,7 @@ cout << "Final spread= " << spread << endl; y[1] += 1.0; y[2] += 1.0; - if(getParam(DETUNE_DIRECTION) == -1.0) // divergence (detuning) + if(getParam(DETUNE_DIRECTION) < 0.0) // divergence (detuning) { //cout << " divergence (detuning)" << endl; detuning_env->addEntry(x[0], y[2]); @@ -497,7 +505,7 @@ cout << " x0=" << x[0] << " y2=" << y[2] << endl; cout << " x1=" << x[1] << " y1=" << y[1] << endl; cout << " x2=" << x[2] << " y0=" << y[0] << endl; */ - } else if(getParam(DETUNE_DIRECTION) == 1.0) { // convergence (tuning) + } else if(getParam(DETUNE_DIRECTION) > 0.0) { // convergence (tuning) //cout << " convergence (tuning)" << endl; detuning_env->addEntry(x[0], y[0]); @@ -590,12 +598,24 @@ void Sound::xml_read(XmlReader::xmltag* soundtag, DISSCO_HASHMAP setParam(LOUDNESS, atof(value)); if((value = soundtag->findChildParamValue("loudness_rate","value")) != 0) setParam(LOUDNESS_RATE, atof(value)); - if((value = soundtag->findChildParamValue("detune_spread","value")) != 0) - setParam(DETUNE_SPREAD, atof(value)); - if((value = soundtag->findChildParamValue("detune_direction","value")) != 0) - setParam(DETUNE_DIRECTION, atof(value)); - if((value = soundtag->findChildParamValue("detune_velocity","value")) != 0) - setParam(DETUNE_VELOCITY, atof(value)); + double detuneSpread = getParam(DETUNE_SPREAD); + double detuneDirection = getParam(DETUNE_DIRECTION); + double detuneVelocity = getParam(DETUNE_VELOCITY); + bool hasDetuneParameters = false; + if((value = soundtag->findChildParamValue("detune_spread","value")) != 0) { + detuneSpread = atof(value); + hasDetuneParameters = true; + } + if((value = soundtag->findChildParamValue("detune_direction","value")) != 0) { + detuneDirection = atof(value); + hasDetuneParameters = true; + } + if((value = soundtag->findChildParamValue("detune_velocity","value")) != 0) { + detuneVelocity = atof(value); + hasDetuneParameters = true; + } + if(hasDetuneParameters) + setDetune(detuneDirection, detuneSpread, detuneVelocity); if((value = soundtag->findChildParamValue("detune_fundamental","value")) != 0) setParam(DETUNE_FUNDAMENTAL, atof(value)); diff --git a/LASS/src/Sound.h b/LASS/src/Sound.h index 96cb10b3..d766ffce 100644 --- a/LASS/src/Sound.h +++ b/LASS/src/Sound.h @@ -89,13 +89,14 @@ Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. /** * \var SoundStaticParam DETUNE_DIRECTION -* - -1.0 means do detuning (divergence) -* - +1.0 means do tuning (convergence) +* - Negative values mean detuning (divergence) +* - Positive values mean tuning (convergence) +* - Sound::setDetune() stores the direction canonically as -1.0 or +1.0 **/ /** * \var SoundStaticParam DETUNE_VELOCITY -* - Values over [0.0, 1.0] +* - Values over [-1.0, 1.0] * - For VELOCITY=0.5, the transition will be linear from start * to end. For any other value, the transition will be * exponentially interpolated. diff --git a/LASSIE/CMakeLists.txt b/LASSIE/CMakeLists.txt index 042e186d..15143fda 100644 --- a/LASSIE/CMakeLists.txt +++ b/LASSIE/CMakeLists.txt @@ -19,8 +19,11 @@ qt_add_executable(LASSIE src/widgets/EnvLibDrawingArea.cpp src/widgets/EnvLibDrawingArea.hpp src/core/EnvelopeLibraryEntry.cpp src/core/EnvelopeLibraryEntry.hpp src/core/ProjectXmlWriter.cpp src/core/ProjectXmlWriter.hpp + src/core/ModifierUsageQtAdapter.cpp src/core/ModifierUsageQtAdapter.hpp src/dialogs/FunctionGenerator.cpp src/dialogs/FunctionGenerator.hpp src/ui/FunctionGenerator.ui src/dialogs/PartialModifierDialog.cpp src/dialogs/PartialModifierDialog.hpp + src/dialogs/ModifierDetailsDialog.cpp src/dialogs/ModifierDetailsDialog.hpp + src/dialogs/ModifierRulesDialog.cpp src/dialogs/ModifierRulesDialog.hpp src/dialogs/PartialModifierFormat.cpp src/dialogs/PartialModifierFormat.hpp src/dialogs/functions/FunctionWidget.cpp src/dialogs/functions/FunctionWidget.hpp src/dialogs/functions/FunctionRegistry.cpp src/dialogs/functions/FunctionRegistry.hpp @@ -82,14 +85,14 @@ target_link_libraries(LASSIE PRIVATE Qt6::Xml Qt6::Network LASS + DISSCO::ModifierUsage ) -# Note: LASSIE invokes CMOD as a subprocess (see CMOD_BINARY below and the -# QProcess::start call in MainWindow::runProject). No symbols are shared, so -# CMOD is intentionally NOT in the link list. Linking against an executable -# target requires an import library on Windows, which CMOD doesn't produce -# (no dllexport / WINDOWS_EXPORT_ALL_SYMBOLS), and the dependency was never -# load-bearing anyway. LASS, which LASSIE was previously pulling in -# transitively through CMOD's PUBLIC interface, is now linked directly. +# Note: LASSIE invokes the CMOD executable as a subprocess (see CMOD_BINARY +# below and the QProcess::start call in MainWindow::runProject), so the +# executable itself is intentionally NOT in the link list. Both executables +# instead share the Qt-free DISSCO::ModifierUsage library defined by CMOD. +# LASS, which LASSIE was previously pulling in transitively through CMOD's +# PUBLIC interface, is now linked directly. # LASSIE does its own XML handling with Qt's QtXml (QDomDocument), so it # needs no separate XML library. diff --git a/LASSIE/src/core/ModifierUsageQtAdapter.cpp b/LASSIE/src/core/ModifierUsageQtAdapter.cpp new file mode 100644 index 00000000..f988bfb3 --- /dev/null +++ b/LASSIE/src/core/ModifierUsageQtAdapter.cpp @@ -0,0 +1,195 @@ +#include "ModifierUsageQtAdapter.hpp" + +#include "../dialogs/PartialModifierFormat.hpp" + +#include + +#include + +#include +#include +#include + +namespace { + +double parseProbability(const QString& text) +{ + bool valid = false; + const double probability = text.trimmed().toDouble(&valid); + return valid ? probability + : std::numeric_limits::quiet_NaN(); +} + +std::string utf8(const QString& text) +{ + return text.toUtf8().toStdString(); +} + +bool hasConfiguredValue(const QString& value) +{ + const QString normalized = value.trimmed(); + return !normalized.isEmpty() + && normalized.compare(QStringLiteral("N/A"), Qt::CaseInsensitive) != 0; +} + +void validateEffectParameters(const Modifier& modifier, + int oneBasedPosition, + QStringList& diagnostics) +{ + const auto require = [&](const QString& value, const QString& field) { + if (!hasConfiguredValue(value)) { + diagnostics.append( + QObject::tr("Modifier %1 is missing %2.") + .arg(oneBasedPosition) + .arg(field)); + } + }; + const auto requireRange = [&](const QString& value, + const QString& field, + double minimum, + double maximum) { + if (!hasConfiguredValue(value)) { + require(value, field); + return; + } + bool valid = false; + const double number = value.trimmed().toDouble(&valid); + if (!valid || !std::isfinite(number) + || number < minimum || number > maximum) { + diagnostics.append( + QObject::tr("Modifier %1: %2 must be between %3 and %4.") + .arg(oneBasedPosition) + .arg(field) + .arg(minimum) + .arg(maximum)); + } + }; + const auto requireDirection = [&](const QString& value) { + const QString field = QObject::tr("Detune Direction"); + if (!hasConfiguredValue(value)) { + require(value, field); + return; + } + bool valid = false; + const double direction = value.trimmed().toDouble(&valid); + if (!valid || !std::isfinite(direction) || direction == 0.0) { + diagnostics.append( + QObject::tr("Modifier %1: Detune Direction must be a " + "finite, non-zero number.") + .arg(oneBasedPosition)); + } + }; + + if (modifier.applyhow_flag) { + const QString error = PartialModifierFormat::validationError( + static_cast(modifier.type), + modifier.partialresult_string); + if (!error.isEmpty()) { + diagnostics.append( + QObject::tr("Modifier %1: %2") + .arg(oneBasedPosition) + .arg(error)); + } + return; + } + + switch (modifier.type) { + case 0: // Tremolo + case 1: // Vibrato + case 7: // Phase Modulation + require(modifier.amplitude, QObject::tr("Magnitude")); + require(modifier.rate, QObject::tr("Rate")); + break; + case 2: // Glissando + case 6: // Wave Type + require(modifier.amplitude, QObject::tr("Magnitude")); + break; + case 3: // Detune + requireRange(modifier.detune_spread, + QObject::tr("Detune Spread"), 0.0, 1.0); + requireDirection(modifier.detune_direction); + requireRange(modifier.detune_velocity, + QObject::tr("Detune Velocity"), -1.0, 1.0); + break; + case 4: // Amplitude Transient + case 5: // Frequency Transient + require(modifier.amplitude, QObject::tr("Magnitude")); + require(modifier.rate, QObject::tr("Rate")); + require(modifier.width, QObject::tr("Width")); + break; + default: + diagnostics.append( + QObject::tr("Modifier %1 has an unknown type.") + .arg(oneBasedPosition)); + break; + } +} + +} // namespace + +ModifierUsageAnalysis analyzeModifierUsage( + const QList& modifiers, + ModifierSamplingScope scope) +{ + using namespace dissco::modifier_usage; + + ModifierUsageAnalysis analysis; + Config config; + config.scope = scope == ModifierSamplingScope::PerBottom + ? SamplingScope::PerBottom + : SamplingScope::PerSound; + config.orderedModifiers.reserve( + static_cast(modifiers.size())); + + for (int modifierIndex = 0; + modifierIndex < modifiers.size(); + ++modifierIndex) { + const Modifier& modifier = modifiers[modifierIndex]; + validateEffectParameters( + modifier, modifierIndex + 1, analysis.diagnostics); + Entry entry; + entry.id = utf8(modifier.instance_id); + entry.defaultOnChance = + parseProbability(modifier.default_on_chance); + entry.rules.reserve( + static_cast(modifier.rules.size())); + + for (const ModifierChanceRule& sourceRule : modifier.rules) { + Rule rule; + rule.onChance = parseProbability(sourceRule.on_chance); + rule.when.reserve( + static_cast(sourceRule.conditions.size())); + for (const ModifierCondition& condition + : sourceRule.conditions) { + rule.when.push_back(Predicate{ + utf8(condition.modifier_id), + condition.required_on + }); + } + entry.rules.push_back(std::move(rule)); + } + config.orderedModifiers.push_back(std::move(entry)); + } + + analysis.overall_usage_available = + modifiers.size() <= modifierUsageExactPreviewLimit; + CompileOptions options; + options.overallUsageMode = analysis.overall_usage_available + ? OverallUsageMode::Exact + : OverallUsageMode::Skip; + CompileResult compiled = compile(std::move(config), options); + for (const Diagnostic& diagnostic : compiled.diagnostics) + analysis.diagnostics.append( + QString::fromStdString(diagnostic.message)); + + if (!compiled.program) + return analysis; + + const std::vector& overall = + compiled.program->overallUsage(); + analysis.overall_on_chances.reserve( + static_cast(overall.size())); + for (const OverallUsage& usage : overall) + analysis.overall_on_chances.append(usage.chance); + return analysis; +} diff --git a/LASSIE/src/core/ModifierUsageQtAdapter.hpp b/LASSIE/src/core/ModifierUsageQtAdapter.hpp new file mode 100644 index 00000000..e06b1b6e --- /dev/null +++ b/LASSIE/src/core/ModifierUsageQtAdapter.hpp @@ -0,0 +1,32 @@ +#ifndef MODIFIERUSAGEQTADAPTER_HPP +#define MODIFIERUSAGEQTADAPTER_HPP + +#include "event_struct.hpp" + +#include +#include + +inline constexpr int modifierUsageExactPreviewLimit = 12; + +struct ModifierUsageAnalysis { + QVector overall_on_chances; + QStringList diagnostics; + bool overall_usage_available = true; + + bool isValid() const { return diagnostics.isEmpty(); } +}; + +/** + * Validates a LASSIE modifier list and, for small lists, calculates each exact + * marginal ON rate. Exact preview is deliberately bounded because arbitrary + * conditional rules require exponential enumeration; synthesis never does. + * + * Runtime sampling stays in the shared, Qt-free ModifierUsage module. This + * adapter is deliberately narrow so the editor preview cannot acquire its own + * subtly different probability semantics. + */ +ModifierUsageAnalysis analyzeModifierUsage( + const QList& modifiers, + ModifierSamplingScope scope); + +#endif // MODIFIERUSAGEQTADAPTER_HPP diff --git a/LASSIE/src/core/ProjectXmlWriter.cpp b/LASSIE/src/core/ProjectXmlWriter.cpp index d04bc5dd..a74fe729 100644 --- a/LASSIE/src/core/ProjectXmlWriter.cpp +++ b/LASSIE/src/core/ProjectXmlWriter.cpp @@ -1,6 +1,9 @@ #include "ProjectXmlWriter.hpp" #include +#include +#include +#include #include namespace { @@ -31,32 +34,30 @@ void writeDomNode(QXmlStreamWriter& writer, const QDomNode& node) QString activeModifierField(const Modifier& modifier, int fieldIndex) { - // Columns: probability, amplitude, rate, width, spread, direction, - // velocity. This preserves LASSIE's existing per-type save policy. - static constexpr bool fields[8][7] = { - /* TREMOLO */ { true, true, true, false, false, false, false }, - /* VIBRATO */ { true, true, true, false, false, false, false }, - /* GLISSANDO */ { true, true, false, false, false, false, false }, - /* DETUNE */ { true, false, false, false, true, true, true }, - /* AMPTRANS */ { true, true, true, true, false, false, false }, - /* FREQTRANS */ { true, true, true, true, false, false, false }, - /* WAVE_TYPE */ { false, true, false, false, false, false, false }, - /* PHASE_MOD */ { true, true, true, false, false, false, false }, + // Columns: amplitude, rate, width, spread, direction, velocity. + static constexpr bool fields[8][6] = { + /* TREMOLO */ { true, true, false, false, false, false }, + /* VIBRATO */ { true, true, false, false, false, false }, + /* GLISSANDO */ { true, false, false, false, false, false }, + /* DETUNE */ { false, false, false, true, true, true }, + /* AMPTRANS */ { true, true, true, false, false, false }, + /* FREQTRANS */ { true, true, true, false, false, false }, + /* WAVE_TYPE */ { true, false, false, false, false, false }, + /* PHASE_MOD */ { true, true, false, false, false, false }, }; - if (modifier.type >= 8 || fieldIndex < 0 || fieldIndex >= 7 + if (modifier.type >= 8 || fieldIndex < 0 || fieldIndex >= 6 || !fields[modifier.type][fieldIndex]) { return {}; } switch (fieldIndex) { - case 0: return modifier.probability; - case 1: return modifier.amplitude; - case 2: return modifier.rate; - case 3: return modifier.width; - case 4: return modifier.detune_spread; - case 5: return modifier.detune_direction; - case 6: return modifier.detune_velocity; + case 0: return modifier.amplitude; + case 1: return modifier.rate; + case 2: return modifier.width; + case 3: return modifier.detune_spread; + case 4: return modifier.detune_direction; + case 5: return modifier.detune_velocity; default: return {}; } } @@ -69,6 +70,39 @@ void writeElement(QXmlStreamWriter& writer, const QString& name, writer.writeEndElement(); } +QString samplingScopeName(ModifierSamplingScope scope) +{ + return scope == ModifierSamplingScope::PerBottom + ? QStringLiteral("per-bottom") + : QStringLiteral("per-sound"); +} + +void writeModifierUsage(QXmlStreamWriter& writer, const Modifier& modifier) +{ + writer.writeStartElement(QStringLiteral("Usage")); + writer.writeAttribute(QStringLiteral("id"), modifier.instance_id); + writer.writeAttribute(QStringLiteral("defaultOn"), + modifier.default_on_chance); + + writer.writeStartElement(QStringLiteral("Exceptions")); + for (const ModifierChanceRule& rule : modifier.rules) { + writer.writeStartElement(QStringLiteral("Exception")); + writer.writeAttribute(QStringLiteral("onChance"), rule.on_chance); + for (const ModifierCondition& condition : rule.conditions) { + writer.writeEmptyElement(QStringLiteral("When")); + writer.writeAttribute(QStringLiteral("modifierId"), + condition.modifier_id); + writer.writeAttribute(QStringLiteral("state"), + condition.required_on + ? QStringLiteral("on") + : QStringLiteral("off")); + } + writer.writeEndElement(); + } + writer.writeEndElement(); + writer.writeEndElement(); +} + } // namespace void ProjectXmlWriter::writeInlineXml(QXmlStreamWriter& writer, @@ -92,16 +126,15 @@ void ProjectXmlWriter::writeModifier(QXmlStreamWriter& writer, writeElement(writer, QStringLiteral("Type"), QString::number(modifier.type)); writeElement(writer, QStringLiteral("ApplyHow"), modifier.applyhow_flag ? QStringLiteral("1") : QStringLiteral("0")); - writeElement(writer, QStringLiteral("Probability"), activeModifierField(modifier, 0)); - writeElement(writer, QStringLiteral("Amplitude"), activeModifierField(modifier, 1)); - writeElement(writer, QStringLiteral("Rate"), activeModifierField(modifier, 2)); - writeElement(writer, QStringLiteral("Width"), activeModifierField(modifier, 3)); - writeElement(writer, QStringLiteral("DetuneSpread"), activeModifierField(modifier, 4)); - writeElement(writer, QStringLiteral("DetuneDirection"), activeModifierField(modifier, 5)); - writeElement(writer, QStringLiteral("DetuneVelocity"), activeModifierField(modifier, 6)); - writeElement(writer, QStringLiteral("GroupName"), modifier.group_name); + writeElement(writer, QStringLiteral("Amplitude"), activeModifierField(modifier, 0)); + writeElement(writer, QStringLiteral("Rate"), activeModifierField(modifier, 1)); + writeElement(writer, QStringLiteral("Width"), activeModifierField(modifier, 2)); + writeElement(writer, QStringLiteral("DetuneSpread"), activeModifierField(modifier, 3)); + writeElement(writer, QStringLiteral("DetuneDirection"), activeModifierField(modifier, 4)); + writeElement(writer, QStringLiteral("DetuneVelocity"), activeModifierField(modifier, 5)); writeElement(writer, QStringLiteral("PartialResultString"), modifier.applyhow_flag ? modifier.partialresult_string : QString{}); + writeModifierUsage(writer, modifier); writer.writeEndElement(); } @@ -125,7 +158,11 @@ void ProjectXmlWriter::writeBottomExtraInfo(QXmlStreamWriter& writer, writeElement(writer, QStringLiteral("Spatialization"), extraInfo.spa); writeElement(writer, QStringLiteral("Reverb"), extraInfo.reverb); writeElement(writer, QStringLiteral("Filter"), extraInfo.filter); - writeElement(writer, QStringLiteral("ModifierGroup"), extraInfo.modifier_group); + writer.writeEmptyElement(QStringLiteral("ModifierUsage")); + writer.writeAttribute(QStringLiteral("version"), QStringLiteral("1")); + writer.writeAttribute(QStringLiteral("samplingScope"), + samplingScopeName( + extraInfo.modifier_sampling_scope)); writer.writeStartElement(QStringLiteral("Modifiers")); for (const Modifier& modifier : extraInfo.modifiers) @@ -133,3 +170,39 @@ void ProjectXmlWriter::writeBottomExtraInfo(QXmlStreamWriter& writer, writer.writeEndElement(); writer.writeEndElement(); } + +bool ProjectXmlWriter::updateProjectSeed(const QString& filePath, + const QString& seed) +{ + QFile input(filePath); + if (!input.open(QIODevice::ReadOnly | QIODevice::Text)) + return false; + + QDomDocument document; + if (!document.setContent(&input)) + return false; + input.close(); + + QDomElement seedElement = document.documentElement() + .firstChildElement(QStringLiteral("ProjectConfiguration")) + .firstChildElement(QStringLiteral("Seed")); + if (seedElement.isNull()) + return false; + + while (!seedElement.firstChild().isNull()) + seedElement.removeChild(seedElement.firstChild()); + seedElement.appendChild(document.createTextNode(seed)); + + QSaveFile output(filePath); + if (!output.open(QIODevice::WriteOnly | QIODevice::Text)) + return false; + + QTextStream stream(&output); + stream << document.toString(); + stream.flush(); + if (stream.status() != QTextStream::Ok) { + output.cancelWriting(); + return false; + } + return output.commit(); +} diff --git a/LASSIE/src/core/ProjectXmlWriter.hpp b/LASSIE/src/core/ProjectXmlWriter.hpp index 8854b0fa..d99af6cc 100644 --- a/LASSIE/src/core/ProjectXmlWriter.hpp +++ b/LASSIE/src/core/ProjectXmlWriter.hpp @@ -20,6 +20,10 @@ void writeModifier(QXmlStreamWriter& writer, const Modifier& modifier); // phase and any modifiers. void writeBottomExtraInfo(QXmlStreamWriter& writer, const ExtraInfo& extraInfo); +// Update a project's seed without risking truncation when its XML cannot be +// parsed or the replacement file cannot be written completely. +bool updateProjectSeed(const QString& filePath, const QString& seed); + } // namespace ProjectXmlWriter #endif // PROJECTXMLWRITER_HPP diff --git a/LASSIE/src/core/event_struct.hpp b/LASSIE/src/core/event_struct.hpp index bd9e27b5..fb960040 100644 --- a/LASSIE/src/core/event_struct.hpp +++ b/LASSIE/src/core/event_struct.hpp @@ -12,6 +12,9 @@ #include #include +#include + +#include typedef enum { none, @@ -189,18 +192,60 @@ struct FreqInfo { QString entry_2; }; +enum class ModifierSamplingScope { + PerSound, + PerBottom +}; + +namespace ModifierUsageImportPolicy { + +inline bool hasCompleteMetadata(const QString& stableId, + bool hasDefaultOnAttribute, + const QString& defaultOn) +{ + bool isNumber = false; + const double chance = defaultOn.trimmed().toDouble(&isNumber); + return !stableId.trimmed().isEmpty() + && hasDefaultOnAttribute + && isNumber + && std::isfinite(chance) + && chance >= 0.0 + && chance <= 1.0; +} + +} // namespace ModifierUsageImportPolicy + +struct ModifierCondition { + QString modifier_id; + bool required_on = true; +}; + +struct ModifierChanceRule { + QList conditions; + QString on_chance; +}; + typedef struct Modifier Modifier; struct Modifier { + // Stable identity for conditional rules. Copies intentionally retain it; + // newly constructed modifiers receive a new identity. + QString instance_id = + QUuid::createUuid().toString(QUuid::WithoutBraces); + QString default_on_chance = QStringLiteral("1"); + QList rules; + // True only while importing a Modifier whose metadata was absent + // or incomplete. + // The writer always emits Usage and deliberately never serializes this. + bool usage_metadata_needs_review = false; + unsigned type = 0; bool applyhow_flag = false; // false == SOUND, true == PARTIAL - QString probability; QString amplitude; QString rate; QString width; QString detune_spread; QString detune_direction; QString detune_velocity; - QString group_name; QString partialresult_string; }; @@ -213,8 +258,12 @@ struct ExtraInfo { QString spa; QString reverb; QString filter; - QString modifier_group; QList modifiers; + ModifierSamplingScope modifier_sampling_scope = + ModifierSamplingScope::PerSound; + // Transient import warning. It is true only while an older file without a + // marker is open and is deliberately never serialized. + bool modifier_usage_needs_review = false; }; /* HEvents are Top, High, Mid, or Low events */ diff --git a/LASSIE/src/core/project_struct.cpp b/LASSIE/src/core/project_struct.cpp index 9bfe8788..e7f7324d 100644 --- a/LASSIE/src/core/project_struct.cpp +++ b/LASSIE/src/core/project_struct.cpp @@ -4,6 +4,7 @@ in the associated window (currently, the project view). */ #include "project_struct.hpp" #include "event_struct.hpp" +#include "ProjectXmlWriter.hpp" #include "../../LASS/src/LASS.h" #include "EnvelopeLibraryEntry.hpp" @@ -111,21 +112,147 @@ namespace QtParser { return layer; } + inline bool parseModifierState(const QString& source, bool& requiredOn) { + const QString state = source.trimmed().toLower(); + if (state == QStringLiteral("on") + || state == QStringLiteral("true") + || state == QStringLiteral("1")) { + requiredOn = true; + return true; + } + if (state == QStringLiteral("off") + || state == QStringLiteral("false") + || state == QStringLiteral("0")) { + requiredOn = false; + return true; + } + return false; + } + + inline ModifierChanceRule parseModifierException(QXmlStreamReader& r) { + // r at ; attributes are read before consuming its children. + ModifierChanceRule rule; + rule.on_chance = + r.attributes().value(QStringLiteral("onChance")).toString(); + + while (r.readNextStartElement()) { + if (r.name() != QStringView(u"When")) { + r.skipCurrentElement(); + continue; + } + + const auto attributes = r.attributes(); + ModifierCondition condition; + condition.modifier_id = + attributes.value(QStringLiteral("modifierId")).toString().trimmed(); + + bool validState = false; + bool requiredOn = true; + validState = parseModifierState( + attributes.value(QStringLiteral("state")).toString(), + requiredOn); + condition.required_on = requiredOn; + + // Malformed predicates are ignored rather than being silently + // interpreted as an OFF dependency. + if (!condition.modifier_id.isEmpty() && validState) + rule.conditions.append(condition); + r.skipCurrentElement(); + } + return rule; + } + + inline bool parseModifierUsage(QXmlStreamReader& r, Modifier& modifier) { + // r at . + const auto attributes = r.attributes(); + const QString id = + attributes.value(QStringLiteral("id")).toString().trimmed(); + const bool hasDefaultOn = + attributes.hasAttribute(QStringLiteral("defaultOn")); + const QString defaultOn = + attributes.value(QStringLiteral("defaultOn")).toString(); + if (!id.isEmpty()) + modifier.instance_id = id; + if (hasDefaultOn) + modifier.default_on_chance = defaultOn; + + modifier.rules.clear(); + while (r.readNextStartElement()) { + if (r.name() != QStringView(u"Exceptions")) { + r.skipCurrentElement(); + continue; + } + + while (r.readNextStartElement()) { + if (r.name() == QStringView(u"Exception")) + modifier.rules.append(parseModifierException(r)); + else + r.skipCurrentElement(); + } + } + + return ModifierUsageImportPolicy::hasCompleteMetadata( + id, hasDefaultOn, defaultOn); + } + inline Modifier parseModifier(QXmlStreamReader& r) { Modifier modifier; - modifier.type = nextChildInner(r).toUInt(); - // On disk: 0 == SOUND, 1 == PARTIAL. In memory, true == PARTIAL. - modifier.applyhow_flag = (nextChildInner(r).trimmed() != "0"); - modifier.probability = nextChildInner(r); - modifier.amplitude = nextChildInner(r); - modifier.rate = nextChildInner(r); - modifier.width = nextChildInner(r); - modifier.detune_spread = nextChildInner(r); - modifier.detune_direction = nextChildInner(r); - modifier.detune_velocity = nextChildInner(r); - modifier.group_name = nextChildInner(r); - modifier.partialresult_string = nextChildInner(r); - consumeRest(r); + bool hasCompleteUsageMetadata = false; + while (r.readNextStartElement()) { + const QString tag = r.name().toString(); + + if (tag == QStringLiteral("Type")) { + bool valid = false; + const unsigned type = readInner(r).trimmed().toUInt(&valid); + if (valid) + modifier.type = type; + } else if (tag == QStringLiteral("ApplyHow")) { + const QString value = readInner(r).trimmed(); + bool valid = false; + const int applyHow = value.toInt(&valid); + if (valid) { + modifier.applyhow_flag = (applyHow != 0); + } else if (value.compare(QStringLiteral("PARTIAL"), + Qt::CaseInsensitive) == 0) { + modifier.applyhow_flag = true; + } else if (value.compare(QStringLiteral("SOUND"), + Qt::CaseInsensitive) == 0) { + modifier.applyhow_flag = false; + } + } else if (tag == QStringLiteral("Probability")) { + // Consumed only for one-way import of pre-Modifier-Usage files. + r.skipCurrentElement(); + } else if (tag == QStringLiteral("Amplitude")) { + modifier.amplitude = readInner(r); + } else if (tag == QStringLiteral("Rate")) { + modifier.rate = readInner(r); + } else if (tag == QStringLiteral("Width")) { + modifier.width = readInner(r); + } else if (tag == QStringLiteral("DetuneSpread")) { + modifier.detune_spread = readInner(r); + } else if (tag == QStringLiteral("DetuneDirection")) { + modifier.detune_direction = readInner(r); + } else if (tag == QStringLiteral("DetuneVelocity")) { + modifier.detune_velocity = readInner(r); + } else if (tag == QStringLiteral("GroupName")) { + // Legacy group membership has no equivalent in the new model. + r.skipCurrentElement(); + } else if (tag == QStringLiteral("PartialResultString")) { + modifier.partialresult_string = readInner(r); + } else if (tag == QStringLiteral("Usage")) { + hasCompleteUsageMetadata = parseModifierUsage(r, modifier); + } else { + // A future field must not shift the interpretation of any + // legacy sibling. + r.skipCurrentElement(); + } + } + + if (modifier.instance_id.trimmed().isEmpty()) { + modifier.instance_id = + QUuid::createUuid().toString(QUuid::WithoutBraces); + } + modifier.usage_metadata_needs_review = !hasCompleteUsageMetadata; return modifier; } @@ -166,8 +293,12 @@ namespace QtParser { } inline void parseModifiers(QXmlStreamReader& r, QList& out) { - while (r.readNextStartElement()) - out.append(parseModifier(r)); + while (r.readNextStartElement()) { + if (r.name() == QStringView(u"Modifier")) + out.append(parseModifier(r)); + else + r.skipCurrentElement(); + } } /// @brief Parse the shared "HEvent core" children of ``: from `` @@ -206,6 +337,8 @@ namespace QtParser { // projects written before existed keep all following fields // aligned. Unknown future fields are ignored safely as well. info.phase = QStringLiteral("0"); + bool modifierUsageMarkerSupported = false; + info.modifier_sampling_scope = ModifierSamplingScope::PerSound; while (r.readNextStartElement()) { const QString tag = r.name().toString(); @@ -227,13 +360,35 @@ namespace QtParser { } else if (tag == QStringLiteral("Filter")) { info.filter = readInner(r); } else if (tag == QStringLiteral("ModifierGroup")) { - info.modifier_group = readInner(r); + // Consume the old selector without keeping a second model. + r.skipCurrentElement(); + } else if (tag == QStringLiteral("ModifierUsage")) { + const QString version = r.attributes() + .value(QStringLiteral("version")) + .toString() + .trimmed(); + const QString scope = r.attributes() + .value(QStringLiteral("samplingScope")) + .toString() + .trimmed() + .toLower(); + const bool scopeSupported = + scope == QStringLiteral("per-sound") + || scope == QStringLiteral("per-bottom"); + modifierUsageMarkerSupported = + version == QStringLiteral("1") && scopeSupported; + info.modifier_sampling_scope = + scope == QStringLiteral("per-bottom") + ? ModifierSamplingScope::PerBottom + : ModifierSamplingScope::PerSound; + r.skipCurrentElement(); } else if (tag == QStringLiteral("Modifiers")) { parseModifiers(r, info.modifiers); } else { r.skipCurrentElement(); } } + info.modifier_usage_needs_review = !modifierUsageMarkerSupported; } inline void parseBottomEventChildren(QXmlStreamReader& r, BottomEvent& bev) { @@ -744,31 +899,8 @@ void ProjectManager::addEvent(Eventtype newEvent, QString eventName) { } void ProjectManager::writeSeedEntry(const QString& seed) const { - QString filepath = curr_project_->fileinfo.absoluteFilePath(); - - QFile file(filepath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) - return; - QDomDocument doc; - doc.setContent(&file); - file.close(); - - QDomElement seedEl = doc.documentElement() - .firstChildElement("ProjectConfiguration") - .firstChildElement("Seed"); - - if (!seedEl.isNull()) { - QDomNode text = seedEl.firstChild(); - if (!text.isNull()) - seedEl.removeChild(text); - seedEl.appendChild(doc.createTextNode(seed)); - } - - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) - return; - QTextStream out(&file); - out << doc.toString(); - file.close(); + ProjectXmlWriter::updateProjectSeed( + curr_project_->fileinfo.absoluteFilePath(), seed); } void ProjectManager::markModified() { diff --git a/LASSIE/src/dialogs/ModifierDetailsDialog.cpp b/LASSIE/src/dialogs/ModifierDetailsDialog.cpp new file mode 100644 index 00000000..a68f8581 --- /dev/null +++ b/LASSIE/src/dialogs/ModifierDetailsDialog.cpp @@ -0,0 +1,507 @@ +#include "ModifierDetailsDialog.hpp" + +#include "FunctionGenerator.hpp" +#include "PartialModifierDialog.hpp" +#include "../inst.hpp" +#include "../widgets/ModifierUiPolicy.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using enum FunctionReturnType; + +ModifierDetailsDialog::ModifierDetailsDialog(const Modifier& modifier, + Eventtype eventType, + unsigned eventIndex, + QWidget* parent) + : QDialog(parent), + m_modifier(modifier), + m_eventType(eventType), + m_eventIndex(eventIndex) +{ + setWindowTitle(tr("%1 Parameters") + .arg(ModifierUiPolicy::displayName(static_cast(m_modifier.type)))); + setModal(true); + resize(680, 420); + + auto* root = new QVBoxLayout(this); + + auto* explanation = new QLabel( + tr("Default ON chance and conditional exceptions are edited in the " + "main Modifier Usage list. This window controls what the selected " + "modifier does."), + this); + explanation->setWordWrap(true); + root->addWidget(explanation); + + auto* effectGroup = new QGroupBox(tr("Effect parameters"), this); + auto* effectLayout = new QGridLayout(effectGroup); + + auto* applyLabel = new QLabel(tr("Apply to:"), effectGroup); + m_applyCombo = new QComboBox(effectGroup); + m_applyCombo->addItems({tr("SOUND"), tr("PARTIAL")}); + m_applyCombo->setCurrentIndex(m_modifier.applyhow_flag ? 1 : 0); + applyLabel->setBuddy(m_applyCombo); + effectLayout->addWidget(applyLabel, 0, 0); + effectLayout->addWidget(m_applyCombo, 0, 1, 1, 2); + + addFieldRow(effectLayout, 1, Magnitude, tr("Magnitude Envelope:")); + addFieldRow(effectLayout, 2, Rate, tr("Rate Envelope:")); + addFieldRow(effectLayout, 3, Width, tr("Width Envelope:")); + addFieldRow(effectLayout, 4, Spread, tr("Detune Spread:")); + addFieldRow(effectLayout, 5, Direction, tr("Detune Direction:")); + addFieldRow(effectLayout, 6, Velocity, tr("Detune Velocity:")); + addFieldRow(effectLayout, 7, PartialResult, tr("Partial Parameters:")); + root->addWidget(effectGroup); + + auto* buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, + this, &ModifierDetailsDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, + this, &ModifierDetailsDialog::reject); + root->addWidget(buttons); + + connect(m_applyCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, [this](int) { updateVisibleFields(); }); + updateVisibleFields(); +} + +void ModifierDetailsDialog::addFieldRow(QGridLayout* layout, int row, + Field field, + const QString& labelText) +{ + auto* label = new QLabel(labelText, this); + auto* edit = new QLineEdit(valueFor(field), this); + if (field == PartialResult) + edit->setReadOnly(true); + label->setBuddy(edit); + auto* button = new QPushButton( + field == PartialResult ? tr("Customize...") : tr("Insert Function"), this); + if (field == Spread || field == Direction || field == Velocity) { + edit->setPlaceholderText( + field == Direction + ? tr("Negative = detune; positive = tune") + : tr("Numeric value")); + auto* validator = new QDoubleValidator(edit); + validator->setNotation(QDoubleValidator::StandardNotation); + validator->setLocale(QLocale::c()); + if (field == Spread) { + validator->setBottom(0.0); + validator->setTop(1.0); + } else if (field == Velocity) { + validator->setBottom(-1.0); + validator->setTop(1.0); + } + edit->setValidator(validator); + button->setVisible(false); + } + + layout->addWidget(label, row, 0); + layout->addWidget(edit, row, 1); + layout->addWidget(button, row, 2); + + m_fields.append({field, label, edit, button}); + connect(button, &QPushButton::clicked, this, + [this, field]() { editField(field); }); +} + +void ModifierDetailsDialog::updateVisibleFields() +{ + const int type = static_cast(m_modifier.type); + const bool applyByPartial = (m_applyCombo->currentIndex() == 1); + + for (const FieldWidgets& widgets : m_fields) { + const bool visible = ModifierUiPolicy::fieldVisible( + type, static_cast(widgets.field), applyByPartial); + const bool enabled = ModifierUiPolicy::fieldEnabled( + type, static_cast(widgets.field), applyByPartial); + const bool supportsFunction = + widgets.field != Spread + && widgets.field != Direction + && widgets.field != Velocity; + widgets.label->setVisible(visible); + widgets.edit->setVisible(visible); + widgets.functionButton->setVisible(visible && supportsFunction); + widgets.label->setEnabled(enabled); + widgets.edit->setEnabled(enabled); + widgets.functionButton->setEnabled(enabled); + if (visible && !enabled) { + const QString explanation = tr( + "This is a SOUND parameter. Switch Apply to SOUND to edit it."); + widgets.label->setToolTip(explanation); + widgets.edit->setToolTip(explanation); + widgets.functionButton->setToolTip(explanation); + widgets.label->setAccessibleDescription(explanation); + widgets.edit->setAccessibleDescription(explanation); + widgets.functionButton->setAccessibleDescription(explanation); + } else { + widgets.label->setToolTip(QString()); + widgets.edit->setToolTip(QString()); + widgets.functionButton->setToolTip(QString()); + widgets.label->setAccessibleDescription(QString()); + widgets.edit->setAccessibleDescription(QString()); + widgets.functionButton->setAccessibleDescription(QString()); + } + + if (type == 7 && widgets.field == Magnitude) + widgets.label->setText(tr("Magnitude Envelope (cycle depth):")); + else if (widgets.field == Magnitude) + widgets.label->setText(tr("Magnitude Envelope:")); + + if (type == 7 && widgets.field == Rate) + widgets.label->setText(tr("Rate Envelope (Hz):")); + else if (widgets.field == Rate) + widgets.label->setText(tr("Rate Envelope:")); + } +} + +void ModifierDetailsDialog::editField(Field field) +{ + FieldWidgets* widgets = nullptr; + for (FieldWidgets& candidate : m_fields) { + if (candidate.field == field) { + widgets = &candidate; + break; + } + } + if (!widgets) + return; + + if (field == PartialResult) { + PartialModifierDialog dialog( + this, static_cast(m_modifier.type), + partialRowConstraint(), + widgets->edit->text()); + if (dialog.exec() == QDialog::Accepted) + widgets->edit->setText(dialog.resultString()); + return; + } + + FunctionGenerator dialog(this, functionReturnENV, widgets->edit->text()); + if (dialog.exec() == QDialog::Accepted + && !dialog.getResultString().isEmpty()) { + widgets->edit->setText(dialog.getResultString()); + } +} + +QString ModifierDetailsDialog::valueFor(Field field) const +{ + switch (field) { + case Magnitude: return m_modifier.amplitude; + case Rate: return m_modifier.rate; + case Width: return m_modifier.width; + case Spread: return m_modifier.detune_spread; + case Direction: return m_modifier.detune_direction; + case Velocity: return m_modifier.detune_velocity; + case PartialResult: return m_modifier.partialresult_string; + } + return {}; +} + +void ModifierDetailsDialog::setValue(Field field, const QString& value) +{ + switch (field) { + case Magnitude: m_modifier.amplitude = value; break; + case Rate: m_modifier.rate = value; break; + case Width: m_modifier.width = value; break; + case Spread: m_modifier.detune_spread = value; break; + case Direction: m_modifier.detune_direction = value; break; + case Velocity: m_modifier.detune_velocity = value; break; + case PartialResult: m_modifier.partialresult_string = value; break; + } +} + +ModifierUiPolicy::PartialRowConstraint +ModifierDetailsDialog::partialRowConstraint() const +{ + ModifierUiPolicy::PartialRowConstraint constraint; + ProjectManager* projectManager = Inst::get_project_manager(); + if (!projectManager || !projectManager->get_curr_project()) { + constraint.maximumRows = 1; + constraint.explanation = tr( + "No open project is available; one placeholder partial row is shown."); + return constraint; + } + + const QList& spectra = projectManager->spectrumevents(); + QList candidates; + QStringList resolutionIssues; + QSet seenNames; + bool bottomScope = false; + bool runtimeDependentPath = false; + + enum class PackageTypeKind { Spectrum, Other, RuntimeDependent }; + const auto classifyPackageType = [](const QString& value) { + const QString type = value.trimmed(); + bool validInteger = false; + const int integerType = type.toInt(&validInteger); + if (validInteger) { + return integerType == static_cast(sound) + ? PackageTypeKind::Spectrum : PackageTypeKind::Other; + } + + bool validNumber = false; + const double numericType = type.toDouble(&validNumber); + if (validNumber && std::isfinite(numericType) + && std::floor(numericType) == numericType) { + return numericType == static_cast(sound) + ? PackageTypeKind::Spectrum : PackageTypeKind::Other; + } + + if (type == QStringLiteral("Spectrum")) + return PackageTypeKind::Spectrum; + return PackageTypeKind::RuntimeDependent; + }; + + const auto addSpectrumCandidates = [&](const QString& name) { + if (name.isEmpty()) { + resolutionIssues.append(tr( + "A Spectrum package has an empty event name.")); + runtimeDependentPath = true; + return; + } + if (seenNames.contains(name)) + return; + seenNames.insert(name); + + QList matches; + for (const SpectrumEvent& spectrum : spectra) { + if (spectrum.name == name) + matches.append(&spectrum); + } + if (matches.isEmpty()) { + resolutionIssues.append(tr( + "Referenced Spectrum \"%1\" does not exist.").arg(name)); + runtimeDependentPath = true; + return; + } + if (matches.size() > 1) { + resolutionIssues.append(tr( + "Spectrum name \"%1\" is duplicated, so the reference is " + "ambiguous.").arg(name)); + runtimeDependentPath = true; + } + for (const SpectrumEvent* match : matches) { + if (!candidates.contains(match)) + candidates.append(match); + } + }; + + if (m_eventType == bottom) { + if (m_eventIndex + >= static_cast(projectManager->bottomevents().size())) { + constraint.maximumRows = 0; + constraint.explanation = tr( + "The current Bottom event is no longer available. Reopen its " + "modifier before configuring partial rows."); + return constraint; + } + bottomScope = true; + const HEvent& bottomEvent = + projectManager->bottomevents()[static_cast(m_eventIndex)].event; + for (const Layer& layer : bottomEvent.event_layers) { + for (const Package& package : layer.discrete_packages) { + const PackageTypeKind kind = + classifyPackageType(package.event_type); + if (kind == PackageTypeKind::Other) + continue; + if (kind == PackageTypeKind::RuntimeDependent) { + runtimeDependentPath = true; + resolutionIssues.append(tr( + "The child type for package \"%1\" is evaluated at " + "runtime, so its Spectrum status cannot be guaranteed.") + .arg(package.event_name)); + } + // EventName is a literal key in CMOD. Do not trim or otherwise + // normalize it here, or the editor could validate a reference + // that runtime lookup will reject. + addSpectrumCandidates(package.event_name); + } + } + } else { + for (const SpectrumEvent& spectrum : spectra) { + if (seenNames.contains(spectrum.name)) { + runtimeDependentPath = true; + resolutionIssues.append(tr( + "Spectrum name \"%1\" is duplicated in the project.") + .arg(spectrum.name)); + } + seenNames.insert(spectrum.name); + candidates.append(&spectrum); + } + } + + if (candidates.isEmpty()) { + constraint.maximumRows = 0; + constraint.explanation = bottomScope + ? tr("This Bottom has no statically resolved Spectrum candidate. " + "PARTIAL settings do not affect note-only children; a runtime " + "child expression cannot be capped in the editor.") + : tr("No reachable Spectrum can be resolved for this modifier. " + "One placeholder row is shown without a Spectrum-derived limit."); + if (!resolutionIssues.isEmpty()) + constraint.explanation += tr("\nReview: %1") + .arg(resolutionIssues.join(QStringLiteral(" "))); + return constraint; + } + + bool exactMaximum = !runtimeDependentPath; + int maximum = 1; + for (const SpectrumEvent* spectrum : candidates) { + const ModifierUiPolicy::SpectrumPartialCount count = + ModifierUiPolicy::spectrumPartialCount(*spectrum); + maximum = std::max(maximum, count.count); + exactMaximum = exactMaximum && count.exact; + + if (!count.generated && count.exact) { + int configuredPartials = 0; + for (const QString& partial : spectrum->spectrum.partials) { + if (!partial.trimmed().isEmpty()) + ++configuredPartials; + } + if (configuredPartials != count.count) { + resolutionIssues.append(tr( + "Spectrum \"%1\" declares %2 partials but contains %3 " + "configured partial envelopes.") + .arg(spectrum->name) + .arg(count.count) + .arg(configuredPartials)); + } + if (!spectrum->generate_spectrum.trimmed().isEmpty()) { + resolutionIssues.append(tr( + "Spectrum \"%1\" has GenerateSpectrum text but no function " + "element; CMOD will use its explicit partial list.") + .arg(spectrum->name)); + } + } else if (!count.exact) { + resolutionIssues.append(tr( + "Spectrum \"%1\" determines NumberOfPartials at runtime.") + .arg(spectrum->name)); + } + } + + constraint.suggestedRows = maximum; + constraint.maximumRows = exactMaximum ? maximum : 0; + if (!exactMaximum) { + constraint.explanation = bottomScope + ? tr("At least one Spectrum referenced by this Bottom has a runtime-" + "determined or inconsistent partial count. %1 rows are suggested; " + "CMOD uses the actual count at runtime.").arg(maximum) + : tr("At least one project Spectrum has a runtime-determined or " + "inconsistent partial count. %1 rows are suggested; inherited " + "modifiers use each sound's actual count at runtime.").arg(maximum); + } else if (bottomScope && candidates.size() == 1) { + constraint.explanation = tr( + "This Bottom references Spectrum \"%1\", which declares up to %2 " + "partials. You can configure at most %2 rows; CMOD may use fewer " + "at high base frequencies.") + .arg(candidates.front()->name) + .arg(maximum); + } else if (bottomScope) { + constraint.explanation = tr( + "This Bottom can choose %1 Spectra; the largest has %2 partials. " + "You can configure at most %2 rows; smaller spectra ignore later rows.") + .arg(candidates.size()) + .arg(maximum); + } else { + constraint.explanation = tr( + "For inherited modifiers, the project-wide safe maximum covers %1 " + "Spectra; the largest declares up to %2 partials. You can configure " + "at most %2 rows.") + .arg(candidates.size()) + .arg(maximum); + } + if (!resolutionIssues.isEmpty()) + constraint.explanation += tr("\nReview: %1") + .arg(resolutionIssues.join(QStringLiteral(" "))); + return constraint; +} + +void ModifierDetailsDialog::accept() +{ + const bool applyByPartial = (m_applyCombo->currentIndex() == 1); + for (const FieldWidgets& widgets : m_fields) { + if (!ModifierUiPolicy::fieldEnabled( + static_cast(m_modifier.type), + static_cast(widgets.field), applyByPartial)) { + continue; + } + + const QString value = widgets.edit->text().trimmed(); + if (widgets.field == PartialResult) { + const QString error = PartialModifierFormat::validationError( + static_cast(m_modifier.type), value); + if (!error.isEmpty()) { + QMessageBox::warning( + this, tr("Invalid partial parameters"), error); + widgets.edit->setFocus(); + return; + } + continue; + } + if (value.isEmpty() + || value.compare( + QStringLiteral("N/A"), Qt::CaseInsensitive) == 0) { + QMessageBox::warning( + this, tr("Missing modifier parameter"), + tr("Enter or generate every parameter shown for this " + "modifier before saving.")); + widgets.edit->setFocus(); + return; + } + if ((widgets.field == Spread + || widgets.field == Direction + || widgets.field == Velocity) + && !widgets.edit->hasAcceptableInput()) { + QMessageBox::warning( + this, tr("Invalid modifier parameter"), + tr("Enter a valid number for every Detune parameter.")); + widgets.edit->setFocus(); + return; + } + if (widgets.field == Direction) { + bool validDirection = false; + const double direction = value.toDouble(&validDirection); + if (!validDirection || !std::isfinite(direction) + || direction == 0.0) { + QMessageBox::warning( + this, tr("Invalid Detune direction"), + tr("Enter a negative value to detune or a positive " + "value to tune. Direction cannot be zero.")); + widgets.edit->setFocus(); + return; + } + } + } + + m_modifier.applyhow_flag = applyByPartial; + for (const FieldWidgets& widgets : m_fields) { + if (widgets.field == Direction + && ModifierUiPolicy::fieldEnabled( + static_cast(m_modifier.type), + static_cast(widgets.field), applyByPartial)) { + const double direction = widgets.edit->text().toDouble(); + setValue(widgets.field, + direction < 0.0 ? QStringLiteral("-1") + : QStringLiteral("1")); + } else { + setValue(widgets.field, widgets.edit->text()); + } + } + QDialog::accept(); +} diff --git a/LASSIE/src/dialogs/ModifierDetailsDialog.hpp b/LASSIE/src/dialogs/ModifierDetailsDialog.hpp new file mode 100644 index 00000000..a197d9cc --- /dev/null +++ b/LASSIE/src/dialogs/ModifierDetailsDialog.hpp @@ -0,0 +1,70 @@ +#ifndef MODIFIERDETAILSDIALOG_HPP +#define MODIFIERDETAILSDIALOG_HPP + +#include +#include + +#include "../core/event_struct.hpp" + +class QComboBox; +class QLabel; +class QLineEdit; +class QPushButton; + +namespace ModifierUiPolicy { +struct PartialRowConstraint; +} + +/** + * Edits the synthesis parameters of one Modifier as an atomic draft. + * + * The compact Modifier row owns activation probability and conditional rules. + * This dialog intentionally owns only effect parameters. + */ +class ModifierDetailsDialog : public QDialog +{ +public: + explicit ModifierDetailsDialog(const Modifier& modifier, + Eventtype eventType, + unsigned eventIndex, + QWidget* parent = nullptr); + + Modifier resultModifier() const { return m_modifier; } + +protected: + void accept() override; + +private: + enum Field { + Magnitude = 0, + Rate, + Width, + Spread, + Direction, + Velocity, + PartialResult + }; + + struct FieldWidgets { + Field field; + QLabel* label = nullptr; + QLineEdit* edit = nullptr; + QPushButton* functionButton = nullptr; + }; + + void addFieldRow(class QGridLayout* layout, int row, Field field, + const QString& labelText); + void updateVisibleFields(); + void editField(Field field); + QString valueFor(Field field) const; + void setValue(Field field, const QString& value); + ModifierUiPolicy::PartialRowConstraint partialRowConstraint() const; + + Modifier m_modifier; + Eventtype m_eventType = bottom; + unsigned m_eventIndex = 0; + QComboBox* m_applyCombo = nullptr; + QVector m_fields; +}; + +#endif // MODIFIERDETAILSDIALOG_HPP diff --git a/LASSIE/src/dialogs/ModifierRulesDialog.cpp b/LASSIE/src/dialogs/ModifierRulesDialog.cpp new file mode 100644 index 00000000..11a8a790 --- /dev/null +++ b/LASSIE/src/dialogs/ModifierRulesDialog.cpp @@ -0,0 +1,406 @@ +#include "ModifierRulesDialog.hpp" + +#include "../widgets/ModifierUiPolicy.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QString modifierLabel(const Modifier& modifier, int oneBasedPosition) +{ + return QObject::tr("%1 (%2)") + .arg(ModifierUiPolicy::displayName(static_cast(modifier.type))) + .arg(oneBasedPosition); +} + +QString normalizedChance(double percent) +{ + return QString::number(percent / 100.0, 'g', 15); +} + +class RuleEditDialog : public QDialog +{ +public: + RuleEditDialog(const QList& earlierModifiers, + const ModifierChanceRule* existing, + QWidget* parent) + : QDialog(parent), + m_earlierModifiers(earlierModifiers) + { + setWindowTitle(existing + ? tr("Edit conditional exception") + : tr("Add conditional exception")); + setModal(true); + + auto* root = new QVBoxLayout(this); + auto* explanation = new QLabel( + tr("Choose only the earlier states that matter; Any ignores that " + "modifier. When the selected context occurs, the target uses the " + "ON chance below instead of its default."), + this); + explanation->setWordWrap(true); + root->addWidget(explanation); + + auto* form = new QFormLayout; + for (int index = 0; index < earlierModifiers.size(); ++index) { + const Modifier& modifier = earlierModifiers[index]; + auto* state = new QComboBox(this); + state->addItem(tr("Any")); + state->addItem(tr("ON"), true); + state->addItem(tr("OFF"), false); + + if (existing) { + for (const ModifierCondition& condition : existing->conditions) { + if (condition.modifier_id == modifier.instance_id) { + state->setCurrentIndex(condition.required_on ? 1 : 2); + break; + } + } + } + + form->addRow(modifierLabel(modifier, index + 1) + QStringLiteral(":"), + state); + m_stateCombos.append(state); + } + + m_chanceSpin = new QDoubleSpinBox(this); + m_chanceSpin->setRange(0.0, 100.0); + m_chanceSpin->setDecimals(1); + m_chanceSpin->setSingleStep(1.0); + m_chanceSpin->setSuffix(QStringLiteral("%")); + m_chanceSpin->setValue(existing + ? existing->on_chance.toDouble() * 100.0 + : 50.0); + form->addRow(tr("Use ON chance:"), m_chanceSpin); + root->addLayout(form); + + auto* buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, + this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, + this, &QDialog::reject); + root->addWidget(buttons); + } + + ModifierChanceRule resultRule() const + { + ModifierChanceRule rule; + rule.on_chance = normalizedChance(m_chanceSpin->value()); + for (int index = 0; index < m_earlierModifiers.size(); ++index) { + if (m_stateCombos[index]->currentIndex() == 0) + continue; + ModifierCondition condition; + condition.modifier_id = + m_earlierModifiers[index].instance_id; + condition.required_on = + m_stateCombos[index]->currentData().toBool(); + rule.conditions.append(condition); + } + return rule; + } + +protected: + void accept() override + { + for (QComboBox* state : m_stateCombos) { + if (state->currentIndex() != 0) { + QDialog::accept(); + return; + } + } + QMessageBox::warning( + this, tr("Condition required"), + tr("Choose ON or OFF for at least one earlier modifier.")); + } + +private: + QList m_earlierModifiers; + QList m_stateCombos; + QDoubleSpinBox* m_chanceSpin = nullptr; +}; + +} // namespace + +ModifierRulesDialog::ModifierRulesDialog( + const Modifier& target, + const QList& earlierModifiers, + QWidget* parent) + : QDialog(parent), + m_earlierModifiers(earlierModifiers), + m_rules(target.rules) +{ + setWindowTitle(tr("%1 conditional exceptions") + .arg(ModifierUiPolicy::displayName(static_cast(target.type)))); + setModal(true); + resize(680, 360); + + auto* root = new QVBoxLayout(this); + m_explanation = new QLabel(this); + m_explanation->setWordWrap(true); + root->addWidget(m_explanation); + + m_table = new QTableWidget(this); + m_table->setColumnCount(2); + m_table->setHorizontalHeaderLabels( + {tr("Earlier modifier context"), tr("Use ON chance")}); + m_table->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::Stretch); + m_table->horizontalHeader()->setSectionResizeMode( + 1, QHeaderView::ResizeToContents); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setSelectionMode(QAbstractItemView::SingleSelection); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + root->addWidget(m_table); + + auto* actionRow = new QHBoxLayout; + m_addButton = new QPushButton(tr("+ Add exception"), this); + m_editButton = new QPushButton(tr("Edit"), this); + m_removeButton = new QPushButton(tr("Remove"), this); + actionRow->addWidget(m_addButton); + actionRow->addWidget(m_editButton); + actionRow->addWidget(m_removeButton); + actionRow->addStretch(); + root->addLayout(actionRow); + + auto* buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, + this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, + this, &QDialog::reject); + root->addWidget(buttons); + + connect(m_addButton, &QPushButton::clicked, + this, [this]() { addRule(); }); + connect(m_editButton, &QPushButton::clicked, + this, [this]() { editSelectedRule(); }); + connect(m_removeButton, &QPushButton::clicked, + this, [this]() { removeSelectedRule(); }); + connect(m_table, &QTableWidget::cellDoubleClicked, + this, [this](int, int) { editSelectedRule(); }); + connect(m_table, &QTableWidget::itemSelectionChanged, this, [this]() { + const bool selected = selectedRuleIndex() >= 0; + m_editButton->setEnabled(selected); + m_removeButton->setEnabled(selected); + }); + + if (m_earlierModifiers.isEmpty()) { + m_explanation->setText( + tr("This modifier is evaluated first, so it has no earlier " + "modifier context and always uses its Default ON chance.")); + m_addButton->setEnabled(false); + } else { + m_explanation->setText( + tr("Default ON chance is the fallback. Add only contexts that " + "should use a different chance.")); + } + + rebuildTable(); +} + +void ModifierRulesDialog::accept() +{ + for (int index = 0; index < m_rules.size(); ++index) { + if (m_rules[index].conditions.isEmpty()) { + QMessageBox::warning( + this, tr("Condition required"), + tr("Every exception must depend on at least one earlier " + "modifier.")); + return; + } + if (hasDuplicateContext(m_rules[index], index) + || hasAmbiguousContext(m_rules[index], index)) { + QMessageBox::warning( + this, tr("Overlapping exceptions"), + tr("Two exceptions can match the same earlier state with " + "equal specificity. Edit or remove one before saving.")); + return; + } + } + QDialog::accept(); +} + +void ModifierRulesDialog::rebuildTable() +{ + m_table->setRowCount(m_rules.size()); + for (int row = 0; row < m_rules.size(); ++row) { + m_table->setItem( + row, 0, new QTableWidgetItem(conditionSummary(m_rules[row]))); + bool valid = false; + const double chance = m_rules[row].on_chance.toDouble(&valid); + const QString chanceText = valid + ? QStringLiteral("%1%").arg(chance * 100.0, 0, 'f', 1) + : tr("Invalid"); + m_table->setItem(row, 1, new QTableWidgetItem(chanceText)); + } + + const bool selected = selectedRuleIndex() >= 0; + m_editButton->setEnabled(selected); + m_removeButton->setEnabled(selected); +} + +void ModifierRulesDialog::addRule() +{ + RuleEditDialog dialog(m_earlierModifiers, nullptr, this); + if (dialog.exec() != QDialog::Accepted) + return; + + const ModifierChanceRule rule = dialog.resultRule(); + if (hasDuplicateContext(rule)) { + QMessageBox::warning( + this, tr("Duplicate context"), + tr("An exception already exists for this earlier modifier context.")); + return; + } + if (hasAmbiguousContext(rule)) { + QMessageBox::warning( + this, tr("Overlapping context"), + tr("This exception can match at the same time as another " + "exception with the same number of conditions. Add another " + "ON/OFF condition so only one of them can match.")); + return; + } + m_rules.append(rule); + rebuildTable(); + m_table->selectRow(m_rules.size() - 1); +} + +void ModifierRulesDialog::editSelectedRule() +{ + const int row = selectedRuleIndex(); + if (row < 0) + return; + + RuleEditDialog dialog(m_earlierModifiers, &m_rules[row], this); + if (dialog.exec() != QDialog::Accepted) + return; + + const ModifierChanceRule rule = dialog.resultRule(); + if (hasDuplicateContext(rule, row)) { + QMessageBox::warning( + this, tr("Duplicate context"), + tr("An exception already exists for this earlier modifier context.")); + return; + } + if (hasAmbiguousContext(rule, row)) { + QMessageBox::warning( + this, tr("Overlapping context"), + tr("This exception can match at the same time as another " + "exception with the same number of conditions. Add another " + "ON/OFF condition so only one of them can match.")); + return; + } + m_rules[row] = rule; + rebuildTable(); + m_table->selectRow(row); +} + +void ModifierRulesDialog::removeSelectedRule() +{ + const int row = selectedRuleIndex(); + if (row < 0) + return; + m_rules.removeAt(row); + rebuildTable(); +} + +QString ModifierRulesDialog::conditionSummary( + const ModifierChanceRule& rule) const +{ + QStringList parts; + for (const ModifierCondition& condition : rule.conditions) { + QString label = condition.modifier_id; + for (int index = 0; index < m_earlierModifiers.size(); ++index) { + if (m_earlierModifiers[index].instance_id + == condition.modifier_id) { + label = modifierLabel(m_earlierModifiers[index], index + 1); + break; + } + } + parts.append(tr("%1 is %2") + .arg(label, condition.required_on ? tr("ON") : tr("OFF"))); + } + return parts.isEmpty() ? tr("Invalid or empty context") + : parts.join(QStringLiteral(" / ")); +} + +QString ModifierRulesDialog::contextKey( + const ModifierChanceRule& rule) const +{ + QStringList parts; + for (const Modifier& modifier : m_earlierModifiers) { + QString state = QStringLiteral("?"); + for (const ModifierCondition& condition : rule.conditions) { + if (condition.modifier_id == modifier.instance_id) { + state = condition.required_on + ? QStringLiteral("1") : QStringLiteral("0"); + break; + } + } + parts.append(modifier.instance_id + QStringLiteral("=") + state); + } + return parts.join(QStringLiteral("|")); +} + +bool ModifierRulesDialog::hasDuplicateContext( + const ModifierChanceRule& candidate, + int ignoredIndex) const +{ + const QString key = contextKey(candidate); + for (int index = 0; index < m_rules.size(); ++index) { + if (index != ignoredIndex && contextKey(m_rules[index]) == key) + return true; + } + return false; +} + +bool ModifierRulesDialog::hasAmbiguousContext( + const ModifierChanceRule& candidate, + int ignoredIndex) const +{ + for (int index = 0; index < m_rules.size(); ++index) { + if (index == ignoredIndex + || m_rules[index].conditions.size() + != candidate.conditions.size()) { + continue; + } + + bool canOverlap = true; + for (const ModifierCondition& candidateCondition + : candidate.conditions) { + for (const ModifierCondition& existingCondition + : m_rules[index].conditions) { + if (candidateCondition.modifier_id + == existingCondition.modifier_id + && candidateCondition.required_on + != existingCondition.required_on) { + canOverlap = false; + break; + } + } + if (!canOverlap) + break; + } + if (canOverlap) + return true; + } + return false; +} + +int ModifierRulesDialog::selectedRuleIndex() const +{ + const auto selected = m_table->selectionModel()->selectedRows(); + return selected.size() == 1 ? selected.front().row() : -1; +} diff --git a/LASSIE/src/dialogs/ModifierRulesDialog.hpp b/LASSIE/src/dialogs/ModifierRulesDialog.hpp new file mode 100644 index 00000000..e4610da7 --- /dev/null +++ b/LASSIE/src/dialogs/ModifierRulesDialog.hpp @@ -0,0 +1,53 @@ +#ifndef MODIFIERRULESDIALOG_HPP +#define MODIFIERRULESDIALOG_HPP + +#include +#include + +#include "../core/event_struct.hpp" + +class QLabel; +class QPushButton; +class QTableWidget; + +/** + * Edits a target modifier's conditional exceptions as one atomic draft. + * + * Each rule mentions only the earlier states that matter. Equal-specificity + * overlapping contexts are rejected so declaration order stays irrelevant. + */ +class ModifierRulesDialog : public QDialog +{ +public: + ModifierRulesDialog(const Modifier& target, + const QList& earlierModifiers, + QWidget* parent = nullptr); + + QList resultRules() const { return m_rules; } + +protected: + void accept() override; + +private: + void rebuildTable(); + void addRule(); + void editSelectedRule(); + void removeSelectedRule(); + QString conditionSummary(const ModifierChanceRule& rule) const; + QString contextKey(const ModifierChanceRule& rule) const; + bool hasDuplicateContext(const ModifierChanceRule& candidate, + int ignoredIndex = -1) const; + bool hasAmbiguousContext(const ModifierChanceRule& candidate, + int ignoredIndex = -1) const; + int selectedRuleIndex() const; + + QList m_earlierModifiers; + QList m_rules; + QTableWidget* m_table = nullptr; + QLabel* m_explanation = nullptr; + QPushButton* m_addButton = nullptr; + QPushButton* m_editButton = nullptr; + QPushButton* m_removeButton = nullptr; +}; + +#endif // MODIFIERRULESDIALOG_HPP diff --git a/LASSIE/src/dialogs/PartialModifierDialog.cpp b/LASSIE/src/dialogs/PartialModifierDialog.cpp index f18b0cc6..aea4b451 100644 --- a/LASSIE/src/dialogs/PartialModifierDialog.cpp +++ b/LASSIE/src/dialogs/PartialModifierDialog.cpp @@ -7,17 +7,72 @@ #include #include #include +#include #include #include #include +#include +#include #include #include +namespace { + +struct ParameterPresentation { + QString magnitudeLabel; + QString widthLabel; + QString rateLabel; + bool usesMagnitude = false; + bool usesWidth = false; + bool usesRate = false; +}; + +ParameterPresentation presentationFor(int modifierType) +{ + switch (modifierType) { + case 0: + return {QObject::tr("Magnitude (depth):"), QString(), + QObject::tr("Rate (Hz):"), true, false, true}; + case 1: + return {QObject::tr("Magnitude (frequency depth):"), QString(), + QObject::tr("Rate (Hz):"), true, false, true}; + case 2: + return {QObject::tr("Frequency change:"), QString(), QString(), + true, false, false}; + case 3: + return {QObject::tr("Detuning:"), QString(), QString(), + true, false, false}; + case 4: + case 5: + return {QObject::tr("Magnitude:"), QObject::tr("Width:"), + QObject::tr("Rate:"), true, true, true}; + case 6: + return {QObject::tr("Wave type:"), QString(), QString(), + true, false, false}; + case 7: + return {QObject::tr("Magnitude (cycle depth):"), QString(), + QObject::tr("Rate (Hz):"), true, false, true}; + default: + return {QObject::tr("Magnitude:"), QObject::tr("Width:"), + QObject::tr("Rate:"), true, true, true}; + } +} + +QString unusedLabel(const QString& parameter) +{ + return QObject::tr("%1 (not used by this modifier):").arg(parameter); +} + +} // namespace + PartialModifierDialog::PartialModifierDialog(QWidget* parent, - int spectrumPartialCount, + int modifierType, + const ModifierUiPolicy::PartialRowConstraint& constraint, const QString& originalString) - : QDialog(parent) + : QDialog(parent), + m_modifierType(modifierType), + m_constraint(constraint) { setWindowTitle(tr("Customize Partials")); setModal(true); @@ -25,8 +80,9 @@ PartialModifierDialog::PartialModifierDialog(QWidget* parent, auto* mainLayout = new QVBoxLayout(this); auto* explanation = new QLabel( - tr("Each partial is stored as probability, magnitude, width, and rate ENV values. " - "Use Insert Function to build an envelope; N/A leaves an unused value empty."), + tr("Each row controls one spectrum partial. Probability decides whether " + "the modifier is applied to that partial. Use Insert Function to build " + "each envelope; N/A means that value is not used."), this); explanation->setWordWrap(true); mainLayout->addWidget(explanation); @@ -38,28 +94,62 @@ PartialModifierDialog::PartialModifierDialog(QWidget* parent, QString parseWarning; QVector values = PartialModifierFormat::parse(originalString, &parseWarning); - const int requestedRows = std::max(1, spectrumPartialCount); - const int rowCount = std::max(requestedRows, static_cast(values.size())); + m_savedRowCount = values.size(); + const int requestedRows = std::max(1, constraint.suggestedRows); + m_suggestedPartialCount = requestedRows; + // Opening the editor must not silently add rows to an existing value. + // Start with the saved count (or one blank row) and let the explicit Add + // action grow it toward the Spectrum-derived suggestion/limit. + const int rowCount = std::max(1, static_cast(values.size())); values.resize(rowCount); + m_activeRowCount = rowCount; - if (parseWarning.isEmpty()) { - m_statusLabel->setText( - tr("Editing %1 partial(s), based on the largest Spectrum in this project.") - .arg(rowCount)); - } else { - m_statusLabel->setText(parseWarning + tr(" Cancel preserves the original value.")); + QStringList statusParts; + if (!constraint.explanation.isEmpty()) + statusParts.append(constraint.explanation); + if (!parseWarning.isEmpty()) { + statusParts.append(parseWarning + + tr(" Cancel preserves the original value.")); m_statusLabel->setStyleSheet(QStringLiteral("color: #b06000;")); } + m_statusContext = statusParts.join(QStringLiteral("\n")); + + auto* countLayout = new QHBoxLayout; + auto* countLabel = new QLabel(tr("Number of partial rows:"), this); + m_rowCountSpin = new QSpinBox(this); + m_rowCountSpin->setObjectName(QStringLiteral("partialRowCountSpin")); + m_rowCountSpin->setKeyboardTracking(false); + m_rowCountSpin->setReadOnly(true); + m_rowCountSpin->setRange( + 1, ModifierUiPolicy::editorRowMaximum(constraint, m_savedRowCount)); + m_rowCountSpin->setValue(rowCount); + countLabel->setBuddy(m_rowCountSpin); + m_addPartialButton = new QPushButton(tr("Add partial"), this); + m_addPartialButton->setObjectName(QStringLiteral("addPartialButton")); + m_removePartialButton = new QPushButton(tr("Remove last partial"), this); + m_removePartialButton->setObjectName(QStringLiteral("removePartialButton")); + countLayout->addWidget(countLabel); + countLayout->addWidget(m_rowCountSpin); + countLayout->addWidget(m_addPartialButton); + countLayout->addWidget(m_removePartialButton); + countLayout->addStretch(); + mainLayout->addLayout(countLayout); + + m_countWarningLabel = new QLabel(this); + m_countWarningLabel->setWordWrap(true); + m_countWarningLabel->setStyleSheet(QStringLiteral("color: #b06000;")); + m_countWarningLabel->setVisible(false); + mainLayout->addWidget(m_countWarningLabel); - auto* scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - auto* rowsWidget = new QWidget(scrollArea); + m_scrollArea = new QScrollArea(this); + m_scrollArea->setWidgetResizable(true); + auto* rowsWidget = new QWidget(m_scrollArea); m_rowsLayout = new QVBoxLayout(rowsWidget); + m_rowsLayout->addStretch(); for (int i = 0; i < rowCount; ++i) addPartialRow(i, values.at(i)); - m_rowsLayout->addStretch(); - scrollArea->setWidget(rowsWidget); - mainLayout->addWidget(scrollArea, 1); + m_scrollArea->setWidget(rowsWidget); + mainLayout->addWidget(m_scrollArea, 1); auto* previewLabel = new QLabel(tr("Generated Partial Result String:"), this); mainLayout->addWidget(previewLabel); @@ -71,10 +161,21 @@ PartialModifierDialog::PartialModifierDialog(QWidget* parent, auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); - connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::accepted, + this, &PartialModifierDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); mainLayout->addWidget(buttons); + connect(m_rowCountSpin, QOverload::of(&QSpinBox::valueChanged), + this, [this](int count) { setPartialRowCount(count); }); + connect(m_addPartialButton, &QPushButton::clicked, this, [this]() { + m_rowCountSpin->setValue(m_activeRowCount + 1); + }); + connect(m_removePartialButton, &QPushButton::clicked, this, [this]() { + m_rowCountSpin->setValue(m_activeRowCount - 1); + }); + + updateCountControls(); updatePreview(); } @@ -83,20 +184,32 @@ void PartialModifierDialog::addPartialRow( { auto* group = new QGroupBox(tr("Partial %1").arg(partialIndex + 1), this); auto* layout = new QVBoxLayout(group); + const ParameterPresentation presentation = presentationFor(m_modifierType); PartialRow row; + row.group = group; addEnvelopeEntry(layout, tr("Probability:"), values.probability, true, &row.probability); - addEnvelopeEntry(layout, tr("Magnitude (cycle depth):"), values.magnitude, - true, &row.magnitude); + addEnvelopeEntry( + layout, + presentation.usesMagnitude + ? presentation.magnitudeLabel : unusedLabel(tr("Magnitude")), + values.magnitude, presentation.usesMagnitude, &row.magnitude); - addEnvelopeEntry(layout, tr("Width (unused by Phase Modulation):"), - values.width, false, &row.width); + addEnvelopeEntry( + layout, + presentation.usesWidth + ? presentation.widthLabel : unusedLabel(tr("Width")), + values.width, presentation.usesWidth, &row.width); - addEnvelopeEntry(layout, tr("Rate (Hz):"), values.rate, true, &row.rate); + addEnvelopeEntry( + layout, + presentation.usesRate + ? presentation.rateLabel : unusedLabel(tr("Rate")), + values.rate, presentation.usesRate, &row.rate); m_rows.append(row); - m_rowsLayout->addWidget(group); + m_rowsLayout->insertWidget(m_rowsLayout->count() - 1, group); } void PartialModifierDialog::addEnvelopeEntry(QVBoxLayout* layout, @@ -109,12 +222,17 @@ void PartialModifierDialog::addEnvelopeEntry(QVBoxLayout* layout, auto* rowLabel = new QLabel(label, this); rowLabel->setMinimumWidth(225); auto* lineEdit = new QLineEdit( - PartialModifierFormat::normalizedValue(value, enabled), this); + // A field can be unused by the current modifier type yet become + // meaningful if that type is changed later. Keep its saved slot while + // disabling the control instead of silently replacing it with N/A. + PartialModifierFormat::normalizedValue(value, true), this); auto* button = new QPushButton(tr("Insert Function"), this); lineEdit->setEnabled(enabled); button->setEnabled(enabled); rowLabel->setEnabled(enabled); + rowLabel->setBuddy(lineEdit); + button->setAccessibleName(tr("Insert function for %1").arg(label)); lineEdit->setToolTip(enabled ? tr("An ENV-returning function, or N/A.") : tr("This value is not used by the selected modifier type.")); @@ -145,11 +263,42 @@ void PartialModifierDialog::openEnvelopeGenerator(QLineEdit* entry) } } +void PartialModifierDialog::setPartialRowCount(int count) +{ + const int previousCount = m_activeRowCount; + while (m_rows.size() < count) + addPartialRow(m_rows.size(), PartialModifierFormat::Values{}); + + if (count < previousCount) { + for (int index = count; index < previousCount; ++index) { + PartialRow& row = m_rows[index]; + row.probability->setText(QStringLiteral("N/A")); + row.magnitude->setText(QStringLiteral("N/A")); + row.width->setText(QStringLiteral("N/A")); + row.rate->setText(QStringLiteral("N/A")); + } + } + + m_activeRowCount = count; + for (int index = 0; index < m_rows.size(); ++index) + m_rows[index].group->setVisible(index < m_activeRowCount); + updateCountControls(); + updatePreview(); + + if (count > previousCount && count <= m_rows.size()) { + PartialRow& row = m_rows[count - 1]; + row.probability->setFocus(); + if (m_scrollArea) + m_scrollArea->ensureWidgetVisible(row.group); + } +} + QString PartialModifierDialog::resultString() const { QVector values; - values.reserve(m_rows.size()); - for (const PartialRow& row : m_rows) { + values.reserve(m_activeRowCount); + for (int index = 0; index < m_activeRowCount; ++index) { + const PartialRow& row = m_rows[index]; PartialModifierFormat::Values value; value.probability = row.probability->text(); value.magnitude = row.magnitude->text(); @@ -160,6 +309,68 @@ QString PartialModifierDialog::resultString() const return PartialModifierFormat::serialize(values); } +void PartialModifierDialog::accept() +{ + if (!ModifierUiPolicy::rowCountAllowed( + m_constraint, m_activeRowCount, m_savedRowCount)) { + QMessageBox::warning( + this, tr("Too many partial rows"), + tr("This configuration cannot grow beyond %1 rows. Reduce the row " + "count or cancel to preserve the original value.") + .arg(std::max(m_constraint.maximumRows, m_savedRowCount))); + return; + } + + const QString error = PartialModifierFormat::validationError( + m_modifierType, resultString()); + if (!error.isEmpty()) { + QMessageBox::warning(this, tr("Invalid partial parameters"), error); + return; + } + QDialog::accept(); +} + +void PartialModifierDialog::updateCountControls() +{ + if (!m_rowCountSpin) + return; + + const bool hasExactLimit = m_constraint.maximumRows > 0; + const bool aboveCurrentLimit = + hasExactLimit && m_activeRowCount > m_constraint.maximumRows; + const bool belowSuggestion = + m_activeRowCount < m_suggestedPartialCount; + + QString status = m_statusContext; + if (!status.isEmpty()) + status += QLatin1Char('\n'); + status += tr("Showing %1 partial row(s).").arg(m_activeRowCount); + m_statusLabel->setText(status); + + m_addPartialButton->setEnabled( + m_activeRowCount < m_rowCountSpin->maximum() + && (!hasExactLimit + || m_activeRowCount < m_constraint.maximumRows)); + m_removePartialButton->setEnabled(m_activeRowCount > 1); + + if (aboveCurrentLimit) { + m_countWarningLabel->setText(tr( + "This saved modifier has %1 rows, while the current Spectrum limit " + "is %2. The extra rows are preserved but CMOD ignores them for these " + "sounds; no additional rows can be added.") + .arg(m_activeRowCount) + .arg(m_constraint.maximumRows)); + m_countWarningLabel->setVisible(true); + } else if (belowSuggestion) { + m_countWarningLabel->setText(tr( + "Partials after row %1 will not receive this modifier.") + .arg(m_activeRowCount)); + m_countWarningLabel->setVisible(true); + } else { + m_countWarningLabel->setVisible(false); + } +} + void PartialModifierDialog::updatePreview() { if (m_preview) diff --git a/LASSIE/src/dialogs/PartialModifierDialog.hpp b/LASSIE/src/dialogs/PartialModifierDialog.hpp index 084fc2a3..1172c1e4 100644 --- a/LASSIE/src/dialogs/PartialModifierDialog.hpp +++ b/LASSIE/src/dialogs/PartialModifierDialog.hpp @@ -6,32 +6,41 @@ #include #include "PartialModifierFormat.hpp" +#include "../widgets/ModifierUiPolicy.hpp" class QLabel; class QLineEdit; class QPlainTextEdit; class QPushButton; +class QGroupBox; +class QSpinBox; +class QScrollArea; class QVBoxLayout; /** - * Structured editor for a Bottom PHASE_MOD modifier applied by PARTIAL. + * Structured editor for any Bottom modifier applied by PARTIAL. * * CMOD's legacy format stores four adjacent elements for every * partial, in probability/magnitude/width/rate order. This dialog deliberately - * keeps that wire format while presenting the values as normal ENV function - * entries instead of asking users to hand-author the XML wrapper. + * keeps that wire format while showing only the parameters consumed by the + * selected modifier type. */ class PartialModifierDialog : public QDialog { public: explicit PartialModifierDialog(QWidget* parent, - int spectrumPartialCount, + int modifierType, + const ModifierUiPolicy::PartialRowConstraint& constraint, const QString& originalString = QString()); QString resultString() const; +protected: + void accept() override; + private: struct PartialRow { + QGroupBox* group = nullptr; QLineEdit* probability = nullptr; QLineEdit* magnitude = nullptr; QLineEdit* width = nullptr; @@ -45,12 +54,25 @@ class PartialModifierDialog : public QDialog const QString& value, bool enabled, QLineEdit** entry); + void setPartialRowCount(int count); void openEnvelopeGenerator(QLineEdit* entry); + void updateCountControls(); void updatePreview(); + int m_modifierType = 0; + int m_activeRowCount = 0; + int m_suggestedPartialCount = 1; + int m_savedRowCount = 0; + ModifierUiPolicy::PartialRowConstraint m_constraint; QVector m_rows; QVBoxLayout* m_rowsLayout = nullptr; + QSpinBox* m_rowCountSpin = nullptr; + QPushButton* m_addPartialButton = nullptr; + QPushButton* m_removePartialButton = nullptr; + QScrollArea* m_scrollArea = nullptr; + QLabel* m_countWarningLabel = nullptr; QLabel* m_statusLabel = nullptr; + QString m_statusContext; QPlainTextEdit* m_preview = nullptr; }; diff --git a/LASSIE/src/dialogs/PartialModifierFormat.cpp b/LASSIE/src/dialogs/PartialModifierFormat.cpp index 7fd4bf82..12286cf0 100644 --- a/LASSIE/src/dialogs/PartialModifierFormat.cpp +++ b/LASSIE/src/dialogs/PartialModifierFormat.cpp @@ -39,6 +39,22 @@ QString translated(const char* source) return QCoreApplication::translate("PartialModifierDialog", source); } +bool hasConfiguredValue(const QString& value) +{ + const QString normalized = value.trimmed(); + return !normalized.isEmpty() + && normalized.compare(QStringLiteral("N/A"), Qt::CaseInsensitive) != 0; +} + +QString missingEffectError(int zeroBasedRow, const QString& effect) +{ + return QCoreApplication::translate( + "PartialModifierDialog", + "Partial %1 is enabled but has no %2 envelope.") + .arg(zeroBasedRow + 1) + .arg(effect); +} + } // namespace QString PartialModifierFormat::normalizedValue(const QString& value, bool enabled) @@ -46,7 +62,11 @@ QString PartialModifierFormat::normalizedValue(const QString& value, bool enable if (!enabled) return QStringLiteral("N/A"); const QString trimmed = value.trimmed(); - return trimmed.isEmpty() ? QStringLiteral("N/A") : trimmed; + if (trimmed.isEmpty() + || trimmed.compare(QStringLiteral("N/A"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("N/A"); + } + return trimmed; } QVector @@ -114,11 +134,65 @@ QString PartialModifierFormat::serialize(const QVector& partials) for (const Values& row : partials) { result += envelopeXml(normalizedValue(row.probability, true)); result += envelopeXml(normalizedValue(row.magnitude, true)); - // CMOD's legacy PARTIAL wire format always has four slots. PHASE_MOD - // does not consume width, so keep the placeholder explicit. - result += envelopeXml(QStringLiteral("N/A")); + // Keep all four legacy slots. The editor supplies N/A for a field the + // selected modifier type does not consume; transient modifiers retain + // their real Width envelope here. + result += envelopeXml(normalizedValue(row.width, true)); result += envelopeXml(normalizedValue(row.rate, true)); } result += QStringLiteral(""); return result; } + +QString PartialModifierFormat::validationError(int modifierType, + const QString& source) +{ + QString warning; + const QVector values = parse(source, &warning); + if (!warning.isEmpty()) + return warning; + if (values.isEmpty()) + return translated("Configure at least one partial row."); + + for (int row = 0; row < values.size(); ++row) { + const Values& value = values[row]; + if (!hasConfiguredValue(value.probability)) + continue; + + const auto require = [&](const QString& effectValue, + const char* effectName) -> QString { + return hasConfiguredValue(effectValue) + ? QString() + : missingEffectError(row, translated(effectName)); + }; + + QString error; + switch (modifierType) { + case 0: // Tremolo + case 1: // Vibrato + case 7: // Phase Modulation + error = require(value.magnitude, "Magnitude"); + if (error.isEmpty()) + error = require(value.rate, "Rate"); + break; + case 2: // Glissando + case 3: // Detune + case 6: // Wave Type + error = require(value.magnitude, "Magnitude"); + break; + case 4: // Amplitude Transient + case 5: // Frequency Transient + error = require(value.magnitude, "Magnitude"); + if (error.isEmpty()) + error = require(value.width, "Width"); + if (error.isEmpty()) + error = require(value.rate, "Rate"); + break; + default: + return translated("The selected modifier type is not supported."); + } + if (!error.isEmpty()) + return error; + } + return {}; +} diff --git a/LASSIE/src/dialogs/PartialModifierFormat.hpp b/LASSIE/src/dialogs/PartialModifierFormat.hpp index d8e5a798..f9ac68dc 100644 --- a/LASSIE/src/dialogs/PartialModifierFormat.hpp +++ b/LASSIE/src/dialogs/PartialModifierFormat.hpp @@ -15,6 +15,7 @@ struct Values { QVector parse(const QString& source, QString* warning = nullptr); QString serialize(const QVector& partials); +QString validationError(int modifierType, const QString& source); QString normalizedValue(const QString& value, bool enabled); diff --git a/LASSIE/src/dialogs/functions/impl/SelectFunction.hpp b/LASSIE/src/dialogs/functions/impl/SelectFunction.hpp index 0792f95c..eab5a8cf 100644 --- a/LASSIE/src/dialogs/functions/impl/SelectFunction.hpp +++ b/LASSIE/src/dialogs/functions/impl/SelectFunction.hpp @@ -41,7 +41,6 @@ class SelectFunction : public FunctionWidget { FunctionReturnType::functionReturnREV, FunctionReturnType::functionReturnPAT, FunctionReturnType::functionReturnFIL, - FunctionReturnType::functionReturnMGP, FunctionReturnType::functionReturnMakeListFun, FunctionReturnType::functionReturnPartialNum, FunctionReturnType::functionReturnNumOfChildren, diff --git a/LASSIE/src/lassie.hpp b/LASSIE/src/lassie.hpp index 48f0531f..97615670 100644 --- a/LASSIE/src/lassie.hpp +++ b/LASSIE/src/lassie.hpp @@ -29,7 +29,6 @@ enum class FunctionReturnType { functionReturnPAT, funcitonReturnMEA, functionReturnFIL, // added for filter object - functionReturnMGP, // ZIYUAN CHEN, July 2023 - added for "Modifier Group" functionReturnSPE, //added for generating spectrum from distance functionReturnIntList, functionReturnFloatList, diff --git a/LASSIE/src/ui/Attributes.ui b/LASSIE/src/ui/Attributes.ui index ef1ccf45..4f74028e 100644 --- a/LASSIE/src/ui/Attributes.ui +++ b/LASSIE/src/ui/Attributes.ui @@ -908,24 +908,50 @@ - - - - - Modifier Group(Optional): - - - - - - - - - Insert Function - - - - + + + Modifier selection + + + + + + + + Sampling scope: + + + + + + + Choose whether each sound gets its own modifier selection or every sound in this Bottom event shares one selection. + + + + Per sound + + + + + Once per Bottom event + + + + + + + + + + Modifiers are evaluated in list order. Add an exception only when an earlier result changes a modifier's chance. + + + true + + + + diff --git a/LASSIE/src/ui/Modifiers.ui b/LASSIE/src/ui/Modifiers.ui index 12fd8f66..e2868154 100644 --- a/LASSIE/src/ui/Modifiers.ui +++ b/LASSIE/src/ui/Modifiers.ui @@ -2,219 +2,91 @@ Modifiers - - - - - - - - - TREMOLO - VIBRATO - GLISSANDO - DETUNE - PHASE_MOD - AMPTRANS - FREQTRANS - WAVE_TYPE - - - - - - Remove This Modifier - - - - - - - - - - SOUND - PARTIAL - - - - - - Group Name: - - - - - - - - - - - - - - Probability Envelope: - - - - - - - - - - Insert Function - - - - - - - - - - - Magnitude Envelope: - - - - - - - - - - Insert Function - - - - - - - - - - - Rate Value Envelope: - - - - - - - - - - Insert Function - - - - - - - - - - - Width Envelope: - - - - - - - - - - Insert Function - - - - - - - - - - - Detune Spread: - - true - - - - - - - - - Fn. - - - - - - - Detune Direction: - - true - - - - - - - - - Fn. - - - - - - - Detune Velocity: - - true - - - - - - - - - Fn. - - - - - - - - - - - Partial Result String: - - - - - - - - - - Insert Function - - - - - - + + QFrame::StyledPanel + + + 8 + 6 + 8 + 6 + 6 + + + 240 + 1. + Qt::AlignCenter - - + + + + 3016777215 + Move earlier in the selection order + + + + + + 3016777215 + Move later in the selection order + + + + + + 1700 + TREMOLO + VIBRATO + GLISSANDO + DETUNE + PHASE_MOD + AMPTRANS + FREQTRANS + WAVE_TYPE + + + + + Qt::Horizontal + 1020 + + + + + Fallback ON chance used when no conditional exception matches. This is not necessarily the final overall usage. + Default ON: + + + + + Fallback ON chance used when no conditional exception matches. The exact overall usage is shown below the list. + 900 + 1 + 100.000000000000000 + 1.000000000000000 + % + + + + + Add exceptions for meaningful states of modifiers evaluated earlier. + 1100 + No exceptions + + + + + Edit Apply To, Magnitude, Rate, Width, Detune, partial values, and legacy fields + Parameters… + + + + + Remove + + + + + diff --git a/LASSIE/src/widgets/EventAttributesViewController.cpp b/LASSIE/src/widgets/EventAttributesViewController.cpp index ce31f410..b4a0d7c3 100644 --- a/LASSIE/src/widgets/EventAttributesViewController.cpp +++ b/LASSIE/src/widgets/EventAttributesViewController.cpp @@ -4,18 +4,22 @@ #include "../inst.hpp" #include "../utilities.hpp" #include "../core/event_struct.hpp" +#include "../core/ModifierUsageQtAdapter.hpp" #include "../dialogs/FunctionGenerator.hpp" #include "ProjectViewController.hpp" #include "../widgets/LayerBox.hpp" #include "../widgets/Partials.hpp" #include "../widgets/Modifiers.hpp" +#include "../widgets/ModifierUiPolicy.hpp" #include "NoteModifierSelection.hpp" #include "../ui/ui_Modifiers.h" #include #include #include +#include #include +#include #include #include @@ -102,8 +106,7 @@ EventAttributesViewController::EventAttributesViewController(ProjectView* projec this, &EventAttributesViewController::BSReverbButtonClicked); connect(ui->BSFilterButton, &QPushButton::clicked, this, &EventAttributesViewController::BSFilterButtonClicked); -*/ connect(ui->BSModifierGroupButton, &QPushButton::clicked, - this, &EventAttributesViewController::BSModifierGroupButtonClicked); +*/ ui->BSWellTemperedPage->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); ui->BSFundamentalPage->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); @@ -133,6 +136,10 @@ EventAttributesViewController::EventAttributesViewController(ProjectView* projec this, &EventAttributesViewController::addModifierButtonClicked); connect(ui->addPartialButton, &QPushButton::clicked, this, &EventAttributesViewController::addPartialButtonClicked); + connect(ui->modifierSamplingScopeCombo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &EventAttributesViewController::modifierSamplingScopeChanged); // --- tempo controls --- ui->tempoValuePage->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); @@ -381,7 +388,10 @@ void EventAttributesViewController::saveCurrentShownEventData() { extra_info.spa = ui->spaEntry->text(); extra_info.reverb = ui->revEntry->text(); extra_info.filter = ui->filEntry->text(); - extra_info.modifier_group = ui->modifierGroupEntry->text(); + extra_info.modifier_sampling_scope = + ui->modifierSamplingScopeCombo->currentIndex() == 1 + ? ModifierSamplingScope::PerBottom + : ModifierSamplingScope::PerSound; } @@ -585,13 +595,14 @@ void EventAttributesViewController::showCurrentEventData() { ui->frequencyContainer->setVisible(true); ui->loudnessContainer->setVisible(true); ui->phaseContainer->setVisible(true); - ui->modGroupContainer->setVisible(true); } else { ui->frequencyContainer->setVisible(false); ui->loudnessContainer->setVisible(false); ui->phaseContainer->setVisible(false); - ui->modGroupContainer->setVisible(false); } + // Modifier Usage visibility is applied after the event data and + // rows have been loaded; sampling scope remains Bottom-only. + ui->modifierUsageControls->setVisible(false); fixStackedWidgetLayout(ui->standardPage); break; case sound: @@ -662,20 +673,17 @@ void EventAttributesViewController::showCurrentEventData() { ui->spaEntry->setText(extra_info.spa); ui->revEntry->setText(extra_info.reverb); ui->filEntry->setText(extra_info.filter); - ui->modifierGroupEntry->setText(extra_info.modifier_group); - - // clear existing Modifiers widgets - for (Modifiers* mod : m_modifiers) { - ui->modifiersLayout->removeWidget(mod); - mod->deleteLater(); + { + const QSignalBlocker scopeBlocker( + ui->modifierSamplingScopeCombo); + ui->modifierSamplingScopeCombo->setCurrentIndex( + extra_info.modifier_sampling_scope + == ModifierSamplingScope::PerBottom + ? 1 + : 0); } - m_modifiers.clear(); - // rebuild buttom Modifiers - for (int i = 0; i < extra_info.modifiers.size(); ++i) { - addModifiersUI(i); - m_modifiers[i]->setModifierData(extra_info.modifiers[i]); - } + rebuildModifierRows(); event = bottom_event.event; }else if(type == top){ @@ -762,19 +770,7 @@ void EventAttributesViewController::showCurrentEventData() { // clear and rebuild hevent modifier widgets if (type != bottom) { - // clear existing Modifiers widgets - for (Modifiers* mod : m_modifiers) { - ui->modifiersLayout->removeWidget(mod); - mod->deleteLater(); - } - m_modifiers.clear(); - - // rebuild Modifiers - for (int i = 0; i < event.modifiers.size(); ++i) { - addModifiersUI(i); - m_modifiers[i]->setModifierData(event.modifiers[i]); - } - + rebuildModifierRows(); } // environment if (type != bottom) { @@ -783,6 +779,8 @@ void EventAttributesViewController::showCurrentEventData() { ui->filEntry->setText(event.filter); } + updateModifierUsageUi(); + // Rebuild LayerBoxes for the newly shown event for (int i = 0; i < event.event_layers.size(); ++i) { addLayerBoxUI(i); @@ -795,6 +793,7 @@ void EventAttributesViewController::showCurrentEventData() { ui->spectrumNumPartialEntry->setText(event.num_partials); ui->spectrumNumPartialEntry->setEnabled(false); //gray out number of partials ui->spectrumDeviationEntry->setText(event.deviation); + ui->spectrumGenEntry->setText(event.generate_spectrum); ui->spectrumGenEntry->setEnabled(false); // Clear all existing Partials widgets so the panel reflects the new event @@ -1049,11 +1048,6 @@ void EventAttributesViewController::BSFilterButtonClicked() { insertFunctionString(spectrumGenerateFunButton); } */ -void EventAttributesViewController::BSModifierGroupButtonClicked() { - // if (m_currentlyShownEvent->getEventExtraInfo()->getChildTypeFlag() != 0) return; - insertFunctionString(BSModGroupFunButton); -} - void EventAttributesViewController::BSWellTemperedButtonClicked() { insertFunctionString(BSWellTemperedFunButton); } @@ -1151,10 +1145,6 @@ void EventAttributesViewController::insertFunctionString(FunctionButton button) target = ui->phaseEntry; gen = new FunctionGenerator(nullptr, functionReturnFloat, target->text()); break; - case BSModGroupFunButton: - target = ui->modifierGroupEntry; - gen = new FunctionGenerator(nullptr, functionReturnSPE, target->text()); - break; case BSWellTemperedFunButton: target = ui->wellTemperedEntry; gen = new FunctionGenerator(nullptr, functionReturnFloat, target->text()); @@ -1254,15 +1244,14 @@ void EventAttributesViewController::addPartialsUI(int partialIndex) { m_partials[i]->setPartialIndex(i); } fixStackedWidgetLayout(ui->soundPage); - ui->spectrumNumPartialEntry->setText(QString::number(partials2.size())); + ui->spectrumNumPartialEntry->setText( + ModifierUiPolicy::partialCountAfterExplicitListChange( + ui->spectrumNumPartialEntry->text(), partials2.size())); }); m_partials.append(par); ui->partialsLayout->addWidget(par); fixStackedWidgetLayout(ui->soundPage); - ProjectManager *pm = Inst::get_project_manager(); - Spectrum* sevent = &pm->spectrumevents()[m_curreventindex].spectrum; - ui->spectrumNumPartialEntry->setText(QString::number(sevent->partials.size())); } @@ -1275,61 +1264,390 @@ void EventAttributesViewController::addPartialButtonClicked() { sevent->partials.append(""); addPartialsUI(sevent->partials.size()-1); + ui->spectrumNumPartialEntry->setText( + ModifierUiPolicy::partialCountAfterExplicitListChange( + ui->spectrumNumPartialEntry->text(), sevent->partials.size())); } -void EventAttributesViewController::addModifiersUI(int modifierIndex) { - Modifiers* mod = new Modifiers(m_curreventtype, m_curreventindex, modifierIndex, this); - connect(mod, &Modifiers::deleteRequested, this, [this](Modifiers* m) { - ProjectManager* pm2 = Inst::get_project_manager(); +QList* EventAttributesViewController::currentModifierList() +{ + if (!m_hasCurrentEvent) + return nullptr; + + ProjectManager* projectManager = Inst::get_project_manager(); + if (m_curreventtype == top) + return &projectManager->topevent().modifiers; + if (m_curreventtype == high + && m_curreventindex >= 0 + && m_curreventindex < projectManager->highevents().size()) { + return &projectManager->highevents()[m_curreventindex].modifiers; + } + if (m_curreventtype == mid + && m_curreventindex >= 0 + && m_curreventindex < projectManager->midevents().size()) { + return &projectManager->midevents()[m_curreventindex].modifiers; + } + if (m_curreventtype == low + && m_curreventindex >= 0 + && m_curreventindex < projectManager->lowevents().size()) { + return &projectManager->lowevents()[m_curreventindex].modifiers; + } + if (m_curreventtype == bottom + && m_curreventindex >= 0 + && m_curreventindex < projectManager->bottomevents().size()) { + return &projectManager->bottomevents()[m_curreventindex] + .extra_info.modifiers; + } + return nullptr; +} - QList* modList = nullptr; - if (m_curreventtype != bottom) { - if (m_curreventtype == top) modList = &pm2->topevent().modifiers; - else if (m_curreventtype == high) modList = &pm2->highevents()[m_curreventindex].modifiers; - else if (m_curreventtype == mid) modList = &pm2->midevents()[m_curreventindex].modifiers; - else if (m_curreventtype == low) modList = &pm2->lowevents()[m_curreventindex].modifiers; - } else { - modList = &pm2->bottomevents()[m_curreventindex].extra_info.modifiers; - } +ExtraInfo* EventAttributesViewController::currentBottomExtraInfo() +{ + if (!m_hasCurrentEvent || m_curreventtype != bottom) + return nullptr; + + ProjectManager* projectManager = Inst::get_project_manager(); + if (m_curreventindex < 0 + || m_curreventindex >= projectManager->bottomevents().size()) { + return nullptr; + } + return &projectManager->bottomevents()[m_curreventindex].extra_info; +} - int idx = m_modifiers.indexOf(m); - if (modList && idx >= 0 && idx < modList->size()) { - modList->removeAt(idx); +void EventAttributesViewController::rebuildModifierRows() +{ + for (Modifiers* modifier : m_modifiers) { + ui->modifiersLayout->removeWidget(modifier); + modifier->deleteLater(); + } + m_modifiers.clear(); + + QList* modifiers = currentModifierList(); + if (modifiers) { + for (int index = 0; index < modifiers->size(); ++index) + addModifiersUI(index); + } + + updateModifierUsageUi(); + fixStackedWidgetLayout(ui->standardPage); +} + +void EventAttributesViewController::updateModifierUsageSummary() +{ + QList* modifiers = currentModifierList(); + const int modifierCount = modifiers ? modifiers->size() : 0; + int ruleCount = 0; + if (modifiers) { + for (const Modifier& modifier : *modifiers) + ruleCount += modifier.rules.size(); + } + + const ExtraInfo* extraInfo = currentBottomExtraInfo(); + bool hasMissingUsageMetadata = false; + if (modifiers) { + for (const Modifier& modifier : *modifiers) { + hasMissingUsageMetadata = hasMissingUsageMetadata + || modifier.usage_metadata_needs_review; } + } + QString importNotice; + if (extraInfo && extraInfo->modifier_usage_needs_review) { + importNotice = + tr("Compatibility import needs review: this Bottom had no " + "supported Modifier Usage marker. Effect parameters and any " + "Usage data were kept, but legacy group selection cannot be " + "converted exactly. Review this list before synthesis.\n"); + } else if (hasMissingUsageMetadata) { + importNotice = + tr("Compatibility import needs review: one or more modifiers had " + "missing or invalid Usage metadata. Review their stable IDs " + "and default chances before synthesis.\n"); + } + const auto showSummary = [this, &importNotice](const QString& text) { + ui->modifierUsageSummaryLabel->setText(importNotice + text); + }; - m_modifiers.removeOne(m); - ui->modifiersLayout->removeWidget(m); - m->deleteLater(); - for (int i = 0; i < m_modifiers.size(); ++i) { - m_modifiers[i]->setModifierIndex(i); + const QString scopeDescription = !extraInfo + ? tr("sampling scope is chosen on each Bottom event") + : extraInfo->modifier_sampling_scope + == ModifierSamplingScope::PerBottom + ? tr("one selection is shared by the whole Bottom event") + : tr("each generated sound receives its own selection"); + + if (!modifiers || modifiers->isEmpty()) { + showSummary( + tr("No modifiers yet. Add one below; %1.") + .arg(scopeDescription)); + return; + } + + const ModifierSamplingScope scope = extraInfo + ? extraInfo->modifier_sampling_scope + : ModifierSamplingScope::PerSound; + const ModifierUsageAnalysis analysis = + analyzeModifierUsage(*modifiers, scope); + if (!analysis.isValid()) { + showSummary( + tr("Configuration needs attention: %1") + .arg(analysis.diagnostics.constFirst())); + return; + } + + QStringList usages; + bool hasPartialModifier = false; + for (int index = 0; index < modifiers->size(); ++index) { + hasPartialModifier = + hasPartialModifier || modifiers->at(index).applyhow_flag; + if (!analysis.overall_usage_available + || index >= analysis.overall_on_chances.size()) { + continue; } + usages.append( + tr("%1. %2: %3%") + .arg(index + 1) + .arg(ModifierUiPolicy::displayName( + static_cast(modifiers->at(index).type))) + .arg(analysis.overall_on_chances[index] * 100.0, + 0, 'f', 1)); + } + + const QString overallDescription = analysis.overall_usage_available + ? usages.join(QStringLiteral(" | ")) + : tr("not calculated above %1 modifiers; CMOD direct sampling " + "remains linear") + .arg(modifierUsageExactPreviewLimit); + + QString summary = + tr("Default ON is the fallback when no exception matches.\n" + "%1 modifier(s), %2 exception(s). Selection follows the numbered " + "order; %3.\n" + "Estimated overall selection: %4") + .arg(modifierCount) + .arg(ruleCount) + .arg(scopeDescription) + .arg(overallDescription); + if (hasPartialModifier) { + summary += tr("\nA PARTIAL modifier that is selected still uses its " + "per-partial Probability Envelope."); + } + showSummary(summary); +} + +void EventAttributesViewController::updateModifierUsageUi() +{ + ui->modifierUsageControls->setVisible( + ModifierUiPolicy::usageSummaryVisible(m_curreventtype)); + const bool showSamplingScope = + ModifierUiPolicy::samplingScopeVisible(m_curreventtype); + ui->modifierSamplingScopeLabel->setVisible(showSamplingScope); + ui->modifierSamplingScopeCombo->setVisible(showSamplingScope); + + updateModifierUsageSummary(); + if (ui->stackedWidget->currentWidget() == ui->standardPage) fixStackedWidgetLayout(ui->standardPage); +} + +void EventAttributesViewController::modifierSamplingScopeChanged(int index) +{ + ExtraInfo* extraInfo = currentBottomExtraInfo(); + if (!extraInfo) + return; + + extraInfo->modifier_sampling_scope = + index == 1 ? ModifierSamplingScope::PerBottom + : ModifierSamplingScope::PerSound; + updateModifierUsageSummary(); + MUtilities::modified(); +} + +bool EventAttributesViewController::modifierOrderIsValid( + const QList& modifiers, QString* explanation) const +{ + QHash positions; + for (int index = 0; index < modifiers.size(); ++index) { + const QString id = modifiers[index].instance_id.trimmed(); + if (id.isEmpty()) { + if (explanation) { + *explanation = tr("Modifier %1 has no stable ID.") + .arg(index + 1); + } + return false; + } + if (positions.contains(id)) { + if (explanation) { + *explanation = + tr("Modifiers %1 and %2 have the same stable ID.") + .arg(positions.value(id) + 1) + .arg(index + 1); + } + return false; + } + positions.insert(id, index); + } + + for (int modifierIndex = 0; + modifierIndex < modifiers.size(); + ++modifierIndex) { + const Modifier& modifier = modifiers[modifierIndex]; + for (int ruleIndex = 0; ruleIndex < modifier.rules.size(); ++ruleIndex) { + for (const ModifierCondition& condition : + modifier.rules[ruleIndex].conditions) { + const auto dependency = positions.constFind( + condition.modifier_id.trimmed()); + if (dependency == positions.cend()) { + if (explanation) { + *explanation = + tr("Exception %1 of modifier %2 refers to a " + "modifier that no longer exists.") + .arg(ruleIndex + 1) + .arg(modifierIndex + 1); + } + return false; + } + if (dependency.value() >= modifierIndex) { + if (explanation) { + *explanation = + tr("Exception %1 of modifier %2 would refer to " + "itself or to a later modifier. Conditions may " + "refer only to earlier modifiers.") + .arg(ruleIndex + 1) + .arg(modifierIndex + 1); + } + return false; + } + } + } + } + return true; +} + +void EventAttributesViewController::deleteModifierRow(Modifiers* row) +{ + QList* modifiers = currentModifierList(); + const int index = m_modifiers.indexOf(row); + if (!modifiers || index < 0 || index >= modifiers->size()) + return; + + const QString removedId = modifiers->at(index).instance_id.trimmed(); + int affectedRuleCount = 0; + for (int modifierIndex = 0; + modifierIndex < modifiers->size(); + ++modifierIndex) { + if (modifierIndex == index) + continue; + for (const ModifierChanceRule& rule : + modifiers->at(modifierIndex).rules) { + bool referencesRemovedModifier = false; + for (const ModifierCondition& condition : rule.conditions) { + if (condition.modifier_id.trimmed() == removedId) { + referencesRemovedModifier = true; + break; + } + } + if (referencesRemovedModifier) + ++affectedRuleCount; + } + } + + if (affectedRuleCount > 0) { + const QMessageBox::StandardButton response = QMessageBox::warning( + this, + tr("Delete referenced modifier?"), + tr("%1 conditional exception(s) refer to this modifier. Deleting " + "it will also remove each entire affected exception. Continue?") + .arg(affectedRuleCount), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (response != QMessageBox::Yes) + return; + + for (int modifierIndex = 0; + modifierIndex < modifiers->size(); + ++modifierIndex) { + if (modifierIndex == index) + continue; + QList& rules = + (*modifiers)[modifierIndex].rules; + for (int ruleIndex = rules.size() - 1; + ruleIndex >= 0; + --ruleIndex) { + bool referencesRemovedModifier = false; + for (const ModifierCondition& condition : + rules[ruleIndex].conditions) { + if (condition.modifier_id.trimmed() == removedId) { + referencesRemovedModifier = true; + break; + } + } + if (referencesRemovedModifier) + rules.removeAt(ruleIndex); + } + } + } + + modifiers->removeAt(index); + rebuildModifierRows(); + MUtilities::modified(); +} + +void EventAttributesViewController::moveModifierRow(Modifiers* row, int offset) +{ + QList* modifiers = currentModifierList(); + const int sourceIndex = m_modifiers.indexOf(row); + const int destinationIndex = sourceIndex + offset; + if (!modifiers + || sourceIndex < 0 + || sourceIndex >= modifiers->size() + || destinationIndex < 0 + || destinationIndex >= modifiers->size()) { + return; + } + + QList candidate = *modifiers; + candidate.move(sourceIndex, destinationIndex); + QString explanation; + if (!modifierOrderIsValid(candidate, &explanation)) { + QMessageBox::warning( + this, + tr("Cannot reorder modifiers"), + explanation + QStringLiteral("\n\n") + + tr("Move or remove the dependent exception first.")); + return; + } + + modifiers->move(sourceIndex, destinationIndex); + rebuildModifierRows(); + MUtilities::modified(); +} + +void EventAttributesViewController::addModifiersUI(int modifierIndex) { + Modifiers* mod = new Modifiers(m_curreventtype, m_curreventindex, modifierIndex, this); + connect(mod, &Modifiers::deleteRequested, + this, &EventAttributesViewController::deleteModifierRow); + connect(mod, &Modifiers::moveUpRequested, this, + [this](Modifiers* row) { moveModifierRow(row, -1); }); + connect(mod, &Modifiers::moveDownRequested, this, + [this](Modifiers* row) { moveModifierRow(row, 1); }); + connect(mod, &Modifiers::dataChanged, this, [this]() { + updateModifierUsageSummary(); + MUtilities::modified(); }); m_modifiers.append(mod); ui->modifiersLayout->addWidget(mod); - fixStackedWidgetLayout(ui->standardPage); - } void EventAttributesViewController::addModifierButtonClicked() { qDebug("add new modifier button clicked"); - ProjectManager *pm = Inst::get_project_manager(); + QList* modifiers = currentModifierList(); + if (!modifiers) + return; - if (m_curreventtype != bottom) { - HEvent* hevent = nullptr; - if (m_curreventtype == top) hevent = &pm->topevent(); - else if (m_curreventtype == high) hevent = &pm->highevents()[m_curreventindex]; - else if (m_curreventtype == mid) hevent = &pm->midevents()[m_curreventindex]; - else if (m_curreventtype == low) hevent = &pm->lowevents()[m_curreventindex]; - hevent->modifiers.append(Modifier()); - addModifiersUI(hevent->modifiers.size() - 1); - } else { - ExtraInfo* bevent = &pm->bottomevents()[m_curreventindex].extra_info; - bevent->modifiers.append(Modifier()); - addModifiersUI(bevent->modifiers.size() - 1); - } + modifiers->append(Modifier()); + addModifiersUI(modifiers->size() - 1); + updateModifierUsageUi(); + MUtilities::modified(); } void EventAttributesViewController::tempoAsNoteValueButtonClicked() { diff --git a/LASSIE/src/widgets/EventAttributesViewController.hpp b/LASSIE/src/widgets/EventAttributesViewController.hpp index 0c51fd17..2554f8c7 100644 --- a/LASSIE/src/widgets/EventAttributesViewController.hpp +++ b/LASSIE/src/widgets/EventAttributesViewController.hpp @@ -43,7 +43,6 @@ typedef enum { attributesFilBuilderFunButton, BSLoudnessFunButton, BSPhaseFunButton, - BSModGroupFunButton, BSWellTemperedFunButton, BSFunFreq1FunButton, BSFunFreq2FunButton, @@ -137,8 +136,6 @@ private slots: // void BSSpatializationButtonClicked(); // void BSReverbButtonClicked(); // void BSFilterButtonClicked(); - void BSModifierGroupButtonClicked(); - void wellTemperedRadioButtonClicked(); void fundamentalRadioButtonClicked(); void continuumRadioButtonClicked(); @@ -151,6 +148,7 @@ private slots: void addNewLayerButtonClicked(); void addModifierButtonClicked(); void addPartialButtonClicked(); + void modifierSamplingScopeChanged(int index); // // tempo controls void tempoAsNoteValueButtonClicked(); @@ -186,6 +184,15 @@ private slots: void addLayerBoxUI(int layerIndex); void addPartialsUI(int partialIndex); void addModifiersUI(int modifierIndex); + QList* currentModifierList(); + ExtraInfo* currentBottomExtraInfo(); + void rebuildModifierRows(); + void updateModifierUsageUi(); + void updateModifierUsageSummary(); + void deleteModifierRow(Modifiers* row); + void moveModifierRow(Modifiers* row, int offset); + bool modifierOrderIsValid(const QList& modifiers, + QString* explanation = nullptr) const; void insertFunctionString(FunctionButton fn); void fixStackedWidgetLayout(QWidget* currPage); }; diff --git a/LASSIE/src/widgets/ModifierUiPolicy.hpp b/LASSIE/src/widgets/ModifierUiPolicy.hpp index 8590c27e..53b6ac4f 100644 --- a/LASSIE/src/widgets/ModifierUiPolicy.hpp +++ b/LASSIE/src/widgets/ModifierUiPolicy.hpp @@ -1,32 +1,192 @@ #ifndef MODIFIERUIPOLICY_HPP #define MODIFIERUIPOLICY_HPP +#include "../core/event_struct.hpp" + +#include +#include + +#include +#include +#include + namespace ModifierUiPolicy { -inline constexpr int fieldCount = 8; +inline constexpr int fieldCount = 7; +inline constexpr int generatedSpectrumPartialCount = 20; + +struct PartialRowConstraint { + int suggestedRows = 1; + // Zero means that the spectrum size is evaluated only at CMOD runtime. + int maximumRows = 0; + QString explanation; +}; + +struct SpectrumPartialCount { + int count = 1; + bool exact = false; + bool generated = false; +}; + +inline bool staticIntegerValue(const QString& source, int* result = nullptr) +{ + const QString value = source.trimmed(); + bool validInteger = false; + const int integer = value.toInt(&validInteger); + if (validInteger) { + if (result) + *result = integer; + return true; + } + + bool validNumber = false; + const double number = value.toDouble(&validNumber); + if (!validNumber + || !std::isfinite(number) + || std::floor(number) != number + || number < std::numeric_limits::min() + || number > std::numeric_limits::max()) { + return false; + } + if (result) + *result = static_cast(number); + return true; +} + +inline QString partialCountAfterExplicitListChange( + const QString& configuredCount, int explicitRowCount) +{ + return staticIntegerValue(configuredCount) + ? QString::number(explicitRowCount) + : configuredCount; +} + +inline bool usageSummaryVisible(Eventtype eventType) +{ + switch (eventType) { + case top: + case high: + case mid: + case low: + case bottom: + return true; + default: + return false; + } +} + +inline bool samplingScopeVisible(Eventtype eventType) +{ + return eventType == bottom; +} + +inline QString displayName(int modifierType) +{ + switch (modifierType) { + case 0: return QStringLiteral("Tremolo"); + case 1: return QStringLiteral("Vibrato"); + case 2: return QStringLiteral("Glissando"); + case 3: return QStringLiteral("Detune"); + case 4: return QStringLiteral("Amplitude Transient"); + case 5: return QStringLiteral("Frequency Transient"); + case 6: return QStringLiteral("Wave Type"); + case 7: return QStringLiteral("Phase Modulation"); + default: return QStringLiteral("Unknown Modifier"); + } +} + +inline SpectrumPartialCount spectrumPartialCount(const SpectrumEvent& spectrum) +{ + QXmlStreamReader generatedReader( + QStringLiteral("") + spectrum.generate_spectrum + + QStringLiteral("")); + bool hasGeneratedElement = false; + bool sawWrapperElement = false; + while (!generatedReader.atEnd()) { + generatedReader.readNext(); + if (!generatedReader.isStartElement()) + continue; + if (!sawWrapperElement) { + sawWrapperElement = true; + continue; + } + hasGeneratedElement = true; + } + if (!generatedReader.hasError() && hasGeneratedElement) + return {generatedSpectrumPartialCount, true, true}; + + const int listedCount = std::max( + 1, static_cast(spectrum.spectrum.partials.size())); + + int declaredCount = 0; + if (staticIntegerValue(spectrum.num_partials, &declaredCount) + && declaredCount > 0) { + return {declaredCount, true, false}; + } + + // A function-valued NumberOfPartials is evaluated by CMOD. The explicit + // list is still the best editor suggestion, but it is not a safe limit. + return {listedCount, false, false}; +} + +inline int editorRowMaximum(const PartialRowConstraint& constraint, + int savedRows) +{ + const int preservedRows = std::max(1, savedRows); + if (constraint.maximumRows > 0) + return std::max(constraint.maximumRows, preservedRows); + // Runtime-valued NumberOfPartials has no truthful finite UI cap. The row + // count control is read-only and grows one row per Add action, so using + // QSpinBox's full integer range does not trigger a bulk allocation. + return std::numeric_limits::max(); +} + +inline bool rowCountAllowed(const PartialRowConstraint& constraint, + int rows, + int grandfatheredRows = 0) +{ + const int effectiveMaximum = std::max( + constraint.maximumRows, grandfatheredRows); + return rows >= 1 + && (constraint.maximumRows <= 0 || rows <= effectiveMaximum); +} -// Fields: probability, magnitude, rate, width, spread, direction, velocity, +// Fields: magnitude, rate, width, spread, direction, velocity, // partial-result string. -inline bool fieldEnabled(int modifierType, int field, bool applyByPartial) +inline bool soundFieldApplies(int modifierType, int field) { static constexpr bool fields[8][7] = { - /* TREMOLO */ { true, true, true, false, false, false, false }, - /* VIBRATO */ { true, true, true, false, false, false, false }, - /* GLISSANDO */ { true, true, false, false, false, false, false }, - /* DETUNE */ { true, false, false, false, true, true, true }, - /* AMPTRANS */ { true, true, true, true, false, false, false }, - /* FREQTRANS */ { true, true, true, true, false, false, false }, - /* WAVE_TYPE */ { true, true, true, false, false, false, false }, - /* PHASE_MOD */ { true, true, true, false, false, false, false }, + /* TREMOLO */ { true, true, false, false, false, false, false }, + /* VIBRATO */ { true, true, false, false, false, false, false }, + /* GLISSANDO */ { true, false, false, false, false, false, false }, + /* DETUNE */ { false, false, false, true, true, true, false }, + /* AMPTRANS */ { true, true, true, false, false, false, false }, + /* FREQTRANS */ { true, true, true, false, false, false, false }, + /* WAVE_TYPE */ { true, false, false, false, false, false, false }, + /* PHASE_MOD */ { true, true, false, false, false, false, false }, }; if (modifierType < 0 || modifierType >= 8 || field < 0 || field >= fieldCount) return false; - // In PARTIAL mode CMOD reads these values exclusively from - // PartialResultString. Leaving the top-level PM controls enabled would - // make edits appear effective even though CMOD ignores them. - if (modifierType == 7 && applyByPartial) - return field == 7; - return field == 7 ? applyByPartial : fields[modifierType][field]; + return field < 6 && fields[modifierType][field]; +} + +inline bool fieldVisible(int modifierType, int field, bool applyByPartial) +{ + if (modifierType < 0 || modifierType >= 8 || field < 0 || field >= fieldCount) + return false; + if (field == 6) + return applyByPartial; + return soundFieldApplies(modifierType, field); +} + +inline bool fieldEnabled(int modifierType, int field, bool applyByPartial) +{ + if (!fieldVisible(modifierType, field, applyByPartial)) + return false; + // In PARTIAL mode CMOD reads effect parameters exclusively from + // PartialResultString. SOUND fields remain visible for orientation but + // are disabled so they cannot appear to affect the per-partial result. + return applyByPartial ? field == 6 : field != 6; } } // namespace ModifierUiPolicy diff --git a/LASSIE/src/widgets/Modifiers.cpp b/LASSIE/src/widgets/Modifiers.cpp index 09503b9f..3cb6efeb 100644 --- a/LASSIE/src/widgets/Modifiers.cpp +++ b/LASSIE/src/widgets/Modifiers.cpp @@ -1,52 +1,33 @@ #include "Modifiers.hpp" -#include "../ui/ui_Modifiers.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include "../ui/ui_Attributes.h" +#include "../dialogs/ModifierDetailsDialog.hpp" +#include "../dialogs/ModifierRulesDialog.hpp" #include "../inst.hpp" -#include "../dialogs/FunctionGenerator.hpp" -#include "../dialogs/PartialModifierDialog.hpp" +#include "../ui/ui_Modifiers.h" #include "ModifierUiPolicy.hpp" -using enum FunctionReturnType; +#include +#include +#include +#include +#include namespace { -constexpr int phaseModType = 7; -// The combo box is ordered for display, while these values must retain the -// stable modifier IDs serialized in project files and consumed by CMOD. -constexpr int modifierTypesByDisplayOrder[] = { 0, 1, 2, 3, 7, 4, 5, 6 }; +// Display order differs from the stable integer codes serialized for CMOD. +constexpr int modifierTypesByDisplayOrder[] = {0, 1, 2, 3, 7, 4, 5, 6}; constexpr int modifierTypeCount = sizeof(modifierTypesByDisplayOrder) / sizeof(modifierTypesByDisplayOrder[0]); -int currentModifierType(const QComboBox* combo) +QString normalizedChance(double percent) { - bool valid = false; - const int type = combo->currentData().toInt(&valid); - return valid ? type : -1; + return QString::number(percent / 100.0, 'g', 15); } -void saveCurrentModifierType(const QComboBox* combo, Modifier& modifier) -{ - const int type = currentModifierType(combo); - if (type >= 0) - modifier.type = static_cast(type); -} -} +} // namespace -Modifiers::Modifiers(Eventtype eventType, unsigned eventIndex, int modifierIndex, QWidget *parent) +Modifiers::Modifiers(Eventtype eventType, unsigned eventIndex, + int modifierIndex, QWidget* parent) : QFrame(parent), ui(new Ui::Modifiers), m_eventType(eventType), @@ -54,375 +35,159 @@ Modifiers::Modifiers(Eventtype eventType, unsigned eventIndex, int modifierIndex m_modifierIndex(modifierIndex) { ui->setupUi(this); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + for (int index = 0; index < modifierTypeCount; ++index) ui->modifierType->setItemData(index, modifierTypesByDisplayOrder[index]); - this->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - this->setMinimumHeight(480); + Modifier& modifier = backendModifier(); + if (modifier.instance_id.trimmed().isEmpty()) { + modifier.instance_id = + QUuid::createUuid().toString(QUuid::WithoutBraces); + } - setupUi(); - ui->modifierSpreadLabel->adjustSize(); - ui->modifierDirLabel->adjustSize(); - ui->modifierVelLabel->adjustSize(); connect(ui->modifierRemoveButton, &QPushButton::clicked, - this, &Modifiers::modRemoveButtonClicked); + this, [this]() { emit deleteRequested(this); }); + connect(ui->moveUpButton, &QPushButton::clicked, + this, [this]() { emit moveUpRequested(this); }); + connect(ui->moveDownButton, &QPushButton::clicked, + this, [this]() { emit moveDownRequested(this); }); + connect(ui->parametersButton, &QPushButton::clicked, + this, [this]() { openParameters(); }); + connect(ui->rulesButton, &QPushButton::clicked, + this, [this]() { openRules(); }); + + connect(ui->modifierType, + QOverload::of(&QComboBox::currentIndexChanged), + this, [this](int) { + const int type = currentModifierType(); + if (type >= 0) + backendModifier().type = static_cast(type); + updateRow(); + emit dataChanged(); + }); + connect(ui->defaultChanceSpin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, [this](double value) { + backendModifier().default_on_chance = + normalizedChance(value); + backendModifier().usage_metadata_needs_review = false; + emit dataChanged(); + }); - // Populate UI from backend; setModifierData calls updateModState at the end - Modifier& modData = getBackendLayer(); - setModifierData(modData); + updateRow(); } -Modifier& Modifiers::getBackendLayer() { - ProjectManager* pm = Inst::get_project_manager(); - - if (m_eventType != bottom) { - HEvent* hevent = nullptr; - if (m_eventType == top) - hevent = &pm->topevent(); - else if (m_eventType == high) - hevent = &pm->highevents()[m_eventIndex]; - else if (m_eventType == mid) - hevent = &pm->midevents()[m_eventIndex]; - else if (m_eventType == low) - hevent = &pm->lowevents()[m_eventIndex]; - - return hevent->modifiers[m_modifierIndex]; - } else { - ExtraInfo* bevent = &pm->bottomevents()[m_eventIndex].extra_info; - return bevent->modifiers[m_modifierIndex]; - } +QList& Modifiers::backendModifierList() +{ + ProjectManager* projectManager = Inst::get_project_manager(); + if (m_eventType == top) + return projectManager->topevent().modifiers; + if (m_eventType == high) + return projectManager->highevents()[m_eventIndex].modifiers; + if (m_eventType == mid) + return projectManager->midevents()[m_eventIndex].modifiers; + if (m_eventType == low) + return projectManager->lowevents()[m_eventIndex].modifiers; + return projectManager->bottomevents()[m_eventIndex].extra_info.modifiers; } -void Modifiers::modRemoveButtonClicked() { - emit deleteRequested(this); +Modifier& Modifiers::backendModifier() +{ + return backendModifierList()[m_modifierIndex]; } -void Modifiers::setupUi() { - // connecting buttons - connect(ui->modifierProbFunButton, &QPushButton::clicked, - this, [this]() { - modFunctionButtonClicked(modProbabilityChanged); - }); - connect(ui->modifierMagFunButton, &QPushButton::clicked, - this, [this]() { - modFunctionButtonClicked(modMagnitudeChanged); - }); - connect(ui->modifierRateFunButton, &QPushButton::clicked, - this, [this]() { - modFunctionButtonClicked(modRateChanged); - }); - connect(ui->modifierWidthFunButton, &QPushButton::clicked, - this, [this]() { - modFunctionButtonClicked(modWidthChanged); - }); - connect(ui->modifierResFunButton, &QPushButton::clicked, - this, [this]() { - modFunctionButtonClicked(modPartialChanged); - }); - connect(ui->modifierSpreadFunButton, &QPushButton::clicked, - this, [this]() { - modFunctionButtonClicked(modSpreadChanged); - }); - connect(ui->modifierDirFunButton, &QPushButton::clicked, - this, [this]() { - modFunctionButtonClicked(modDirChanged); - }); - connect(ui->modifierVelFunButton, &QPushButton::clicked, - this, [this]() { - modFunctionButtonClicked(modVelChanged); - }); - - // connecting line edits - connect(ui->modifierNameEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modNameChanged); - }); - connect(ui->modifierProbEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modProbabilityChanged); - }); - connect(ui->modifierMagEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modMagnitudeChanged); - }); - connect(ui->modifierRateEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modRateChanged); - }); - connect(ui->modifierWidthEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modWidthChanged); - }); - connect(ui->modifierResEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modPartialChanged); - }); - connect(ui->modifierSpreadEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modSpreadChanged); - }); - connect(ui->modifierDirEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modDirChanged); - }); - connect(ui->modifierVelEdit, &QLineEdit::textChanged, - this, [this]() { - modTextChanged(modVelChanged); - }); - - // connecting combobox - connect(ui->modifierType, QOverload::of(&QComboBox::currentIndexChanged), - this, [this](int) { - saveCurrentModifierType(ui->modifierType, getBackendLayer()); - updateModState(); - }); - connect(ui->modifierApply, QOverload::of(&QComboBox::currentIndexChanged), - this, [this](int index) { - getBackendLayer().applyhow_flag = index; - updateModState(); - }); +int Modifiers::currentModifierType() const +{ + bool valid = false; + const int type = ui->modifierType->currentData().toInt(&valid); + return valid ? type : -1; } -void Modifiers::updateModState() { - const int typeIndex = currentModifierType(ui->modifierType); - - const bool isPhaseMod = (typeIndex == phaseModType); - ui->modifierMagLabel->setText(isPhaseMod - ? tr("Magnitude Envelope (cycle depth):") - : tr("Magnitude Envelope:")); - ui->modifierRateLabel->setText(isPhaseMod - ? tr("Rate Envelope (Hz):") - : tr("Rate Value Envelope:")); - - // GLISSANDO (2) is always SOUND — disable the apply combo - // In 2.1.0, GLISSANDO has enabled apply combo - /*if (typeIndex == 2) { - ui->modifierApply->blockSignals(true); - ui->modifierApply->setCurrentIndex(0); - ui->modifierApply->blockSignals(false); - ui->modifierApply->setEnabled(false); - } else { - ui->modifierApply->setEnabled(true); - }*/ - - bool isPartial = (ui->modifierApply->currentIndex() == 1); - - // Enabled flags per type: { prob, mag, rate, width, spread, dir, vel, res } - // res (partial result string) depends on apply mode, not type — set below - // prob mag rate width spread dir vel - struct Row { QLabel* label; QLineEdit* edit; QPushButton* btn; }; - const Row rows[8] = { - { ui->modifierProbLabel, ui->modifierProbEdit, ui->modifierProbFunButton }, - { ui->modifierMagLabel, ui->modifierMagEdit, ui->modifierMagFunButton }, - { ui->modifierRateLabel, ui->modifierRateEdit, ui->modifierRateFunButton }, - { ui->modifierWidthLabel, ui->modifierWidthEdit, ui->modifierWidthFunButton }, - { ui->modifierSpreadLabel, ui->modifierSpreadEdit, ui->modifierSpreadFunButton }, - { ui->modifierDirLabel, ui->modifierDirEdit, ui->modifierDirFunButton }, - { ui->modifierVelLabel, ui->modifierVelEdit, ui->modifierVelFunButton }, - { ui->modifierResLabel, ui->modifierResEdit, ui->modifierResFunButton }, - }; - - if (typeIndex < 0 || typeIndex >= modifierTypeCount) { - ui->modifierApply->setEnabled(false); - for (const Row& row : rows) { - row.label->setEnabled(false); - row.edit->setEnabled(false); - row.btn->setEnabled(false); - } - return; - } - ui->modifierApply->setEnabled(true); - - for (int i = 0; i < 8; i++) { - const bool enabled = ModifierUiPolicy::fieldEnabled(typeIndex, i, isPartial); - rows[i].label->setEnabled(enabled); - rows[i].edit->setEnabled(enabled); - rows[i].btn->setEnabled(enabled); - } +void Modifiers::setModifierIndex(int modifierIndex) +{ + m_modifierIndex = modifierIndex; + updateRow(); } -void Modifiers::modTextChanged(ModChanged type) { - // Get a reference to your specific backend modifier object - Modifier& mod = getBackendLayer(); - - switch (type) { - case modNameChanged: - mod.group_name = ui->modifierNameEdit->text(); - break; - case modProbabilityChanged: - mod.probability = ui->modifierProbEdit->text(); - break; - case modMagnitudeChanged: - mod.amplitude = ui->modifierMagEdit->text(); - break; - case modRateChanged: - mod.rate = ui->modifierRateEdit->text(); - break; - case modWidthChanged: - mod.width = ui->modifierWidthEdit->text(); - break; - case modPartialChanged: - mod.partialresult_string = ui->modifierResEdit->text(); - break; - case modSpreadChanged: - mod.detune_spread = ui->modifierSpreadEdit->text(); - break; - case modDirChanged: - mod.detune_direction = ui->modifierDirEdit->text(); - break; - case modVelChanged: - mod.detune_velocity = ui->modifierVelEdit->text(); - break; - default: - break; - } +void Modifiers::saveModifierToBackend() +{ + const int type = currentModifierType(); + if (type >= 0) + backendModifier().type = static_cast(type); + backendModifier().default_on_chance = + normalizedChance(ui->defaultChanceSpin->value()); } -void Modifiers::modFunctionButtonClicked(ModChanged type) { - QLineEdit* target = nullptr; - FunctionGenerator* gen = nullptr; - - switch (type) { - case modProbabilityChanged: - target = ui->modifierProbEdit; - break; - case modMagnitudeChanged: - target = ui->modifierMagEdit; - break; - case modRateChanged: - target = ui->modifierRateEdit; - break; - case modWidthChanged: - target = ui->modifierWidthEdit; - break; - case modPartialChanged: - target = ui->modifierResEdit; - break; - case modSpreadChanged: - target = ui->modifierSpreadEdit; - break; - case modDirChanged: - target = ui->modifierDirEdit; - break; - case modVelChanged: - target = ui->modifierVelEdit; - break; - case modNameChanged: - break; - default: - break; - } - - if (!target) return; - - // The structured editor is deliberately PHASE_MOD-only. Other modifier - // types retain their legacy FunctionGenerator path and wire semantics. - if (type == modPartialChanged - && currentModifierType(ui->modifierType) == phaseModType) { - ProjectManager* pm = Inst::get_project_manager(); - int spectrumPartialCount = 0; - constexpr int generatedSpectrumPartialCount = 20; - if (pm->get_curr_project()) { - for (const SpectrumEvent& spectrum : pm->spectrumevents()) { - spectrumPartialCount = std::max( - spectrumPartialCount, - static_cast(spectrum.spectrum.partials.size())); - - bool isInteger = false; - const int declaredCount = spectrum.num_partials.toInt(&isInteger); - if (isInteger) - spectrumPartialCount = std::max(spectrumPartialCount, declaredCount); +void Modifiers::updateRow() +{ + const Modifier& modifier = backendModifier(); - // CMOD's generated-spectrum path currently creates 20 partials. - if (!spectrum.generate_spectrum.trimmed().isEmpty()) - spectrumPartialCount = std::max(spectrumPartialCount, - generatedSpectrumPartialCount); - } - } + ui->orderLabel->setText(QStringLiteral("%1.").arg(m_modifierIndex + 1)); + ui->moveUpButton->setEnabled(m_modifierIndex > 0); + ui->moveDownButton->setEnabled( + m_modifierIndex + 1 < backendModifierList().size()); - PartialModifierDialog dialog(this, - std::max(1, spectrumPartialCount), - target->text()); - if (dialog.exec() == QDialog::Accepted) - target->setText(dialog.resultString()); - return; + { + const QSignalBlocker blocker(ui->modifierType); + ui->modifierType->setCurrentIndex( + ui->modifierType->findData(static_cast(modifier.type))); } - gen = new FunctionGenerator(nullptr, functionReturnENV, target->text()); - if (gen) { - if (gen->exec() == QDialog::Accepted && !gen->getResultString().isEmpty()) - target->setText(gen->getResultString()); - delete gen; + bool validChance = false; + const double chance = modifier.default_on_chance.toDouble(&validChance); + { + const QSignalBlocker blocker(ui->defaultChanceSpin); + ui->defaultChanceSpin->setValue( + validChance ? chance * 100.0 : 100.0); } -} - -void Modifiers::saveModifierToBackend() { - saveCurrentModifierType(ui->modifierType, getBackendLayer()); - getBackendLayer().applyhow_flag = ui->modifierApply->currentIndex(); - modTextChanged(modNameChanged); - modTextChanged(modProbabilityChanged); - modTextChanged(modMagnitudeChanged); - modTextChanged(modRateChanged); - modTextChanged(modWidthChanged); - modTextChanged(modPartialChanged); - modTextChanged(modSpreadChanged); - modTextChanged(modDirChanged); - modTextChanged(modVelChanged); + const int ruleCount = modifier.rules.size(); + ui->rulesButton->setText( + ruleCount == 0 + ? tr("No exceptions") + : tr("%1 exception%2") + .arg(ruleCount) + .arg(ruleCount == 1 ? QString() : QStringLiteral("s"))); + + ui->parametersButton->setText(tr("Parameters...")); + ui->parametersButton->setToolTip( + tr("Edit Apply To, Magnitude, Rate, Width, Detune, and partial " + "values for %1.") + .arg(ModifierUiPolicy::displayName( + static_cast(modifier.type)))); } -void Modifiers::setModifierData(Modifier& modData) { - ui->modifierType->blockSignals(true); - ui->modifierType->setCurrentIndex( - ui->modifierType->findData(static_cast(modData.type))); - ui->modifierType->blockSignals(false); - - ui->modifierApply->blockSignals(true); - ui->modifierApply->setCurrentIndex(modData.applyhow_flag); - ui->modifierApply->blockSignals(false); - - ui->modifierProbEdit->blockSignals(true); - ui->modifierProbEdit->setText(modData.probability); - ui->modifierProbEdit->blockSignals(false); - - ui->modifierMagEdit->blockSignals(true); - ui->modifierMagEdit->setText(modData.amplitude); - ui->modifierMagEdit->blockSignals(false); - - ui->modifierRateEdit->blockSignals(true); - ui->modifierRateEdit->setText(modData.rate); - ui->modifierRateEdit->blockSignals(false); - - ui->modifierWidthEdit->blockSignals(true); - ui->modifierWidthEdit->setText(modData.width); - ui->modifierWidthEdit->blockSignals(false); - - ui->modifierSpreadEdit->blockSignals(true); - ui->modifierSpreadEdit->setText(modData.detune_spread); - ui->modifierSpreadEdit->blockSignals(false); - - ui->modifierDirEdit->blockSignals(true); - ui->modifierDirEdit->setText(modData.detune_direction); - ui->modifierDirEdit->blockSignals(false); +void Modifiers::openParameters() +{ + ModifierDetailsDialog dialog( + backendModifier(), m_eventType, m_eventIndex, this); + if (dialog.exec() != QDialog::Accepted) + return; - ui->modifierVelEdit->blockSignals(true); - ui->modifierVelEdit->setText(modData.detune_velocity); - ui->modifierVelEdit->blockSignals(false); + backendModifier() = dialog.resultModifier(); + updateRow(); + emit dataChanged(); +} - ui->modifierNameEdit->blockSignals(true); - ui->modifierNameEdit->setText(modData.group_name); - ui->modifierNameEdit->blockSignals(false); +void Modifiers::openRules() +{ + QList earlier; + const QList& modifiers = backendModifierList(); + for (int index = 0; index < m_modifierIndex; ++index) + earlier.append(modifiers[index]); - ui->modifierResEdit->blockSignals(true); - ui->modifierResEdit->setText(modData.partialresult_string); - ui->modifierResEdit->blockSignals(false); + ModifierRulesDialog dialog(backendModifier(), earlier, this); + if (dialog.exec() != QDialog::Accepted) + return; - updateModState(); + backendModifier().rules = dialog.resultRules(); + backendModifier().usage_metadata_needs_review = false; + updateRow(); + emit dataChanged(); } Modifiers::~Modifiers() { delete ui; } - - diff --git a/LASSIE/src/widgets/Modifiers.hpp b/LASSIE/src/widgets/Modifiers.hpp index 6fc2d5b2..b45916c0 100644 --- a/LASSIE/src/widgets/Modifiers.hpp +++ b/LASSIE/src/widgets/Modifiers.hpp @@ -1,65 +1,50 @@ #ifndef MODIFIERS_HPP #define MODIFIERS_HPP -#include -#include #include #include "../core/event_struct.hpp" -typedef enum { - modProbabilityChanged, - modMagnitudeChanged, - modRateChanged, - modWidthChanged, - modPartialChanged, - modSpreadChanged, - modDirChanged, - modVelChanged, - modNameChanged -} ModChanged; - - -class EventAttributesViewController; - namespace Ui { class Modifiers; } +/** + * Compact ordered row for one configured Modifier instance. + * + * Activation settings stay visible in the list. Detailed synthesis fields and + * conditional exceptions are edited atomically in dialogs. + */ class Modifiers : public QFrame { Q_OBJECT public: - /*Constructor to create the modifier*/ - Modifiers(Eventtype eventType, unsigned eventIndex, int modifierIndex, QWidget *parent = nullptr); - /*Destructor to delete the UI*/ + Modifiers(Eventtype eventType, unsigned eventIndex, int modifierIndex, + QWidget* parent = nullptr); ~Modifiers() override; - void setModifierIndex(int modifierIndex) { m_modifierIndex = modifierIndex; } + + void setModifierIndex(int modifierIndex); void saveModifierToBackend(); - void setModifierData(Modifier& modData); - - Ui::Modifiers *ui; signals: void deleteRequested(Modifiers* self); - -private slots: - void modFunctionButtonClicked(ModChanged type); - void modRemoveButtonClicked(); - void modTextChanged(ModChanged type); + void moveUpRequested(Modifiers* self); + void moveDownRequested(Modifiers* self); + void dataChanged(); private: - void setupUi(); - void updateModState(); - - Modifier& getBackendLayer(); - + Modifier& backendModifier(); + QList& backendModifierList(); + int currentModifierType() const; + void updateRow(); + void openParameters(); + void openRules(); + + Ui::Modifiers* ui; Eventtype m_eventType; - unsigned m_eventIndex; - int m_modifierIndex; - - + unsigned m_eventIndex; + int m_modifierIndex; }; -#endif // MODIFIERS_HPP \ No newline at end of file +#endif // MODIFIERS_HPP diff --git a/LASSIE/src/widgets/ProjectViewController.cpp b/LASSIE/src/widgets/ProjectViewController.cpp index 360efa07..f4ec3349 100644 --- a/LASSIE/src/widgets/ProjectViewController.cpp +++ b/LASSIE/src/widgets/ProjectViewController.cpp @@ -4,13 +4,16 @@ * Copyright (c) 2025, DISSCO authors */ #include +#include #include #include #include +#include #include #include #include #include +#include #include #include @@ -58,6 +61,27 @@ namespace PVCHelper { nameItem->setData(name, Qt::UserRole + 2); return {typeItem, nameItem}; } + + void renewModifierIds(QList& modifiers) { + QHash replacementByOldId; + for (Modifier& modifier : modifiers) { + const QString oldId = modifier.instance_id; + modifier.instance_id = + QUuid::createUuid().toString(QUuid::WithoutBraces); + replacementByOldId.insert(oldId, modifier.instance_id); + } + + for (Modifier& modifier : modifiers) { + for (ModifierChanceRule& rule : modifier.rules) { + for (ModifierCondition& condition : rule.conditions) { + const auto replacement = + replacementByOldId.constFind(condition.modifier_id); + if (replacement != replacementByOldId.cend()) + condition.modifier_id = replacement.value(); + } + } + } + } } /* ProjectView constructor initializing values for XML file*/ ProjectView::ProjectView(MainWindow* _mainWindow, QString _pathAndName) { @@ -106,13 +130,65 @@ void ProjectView::writeInlineXml(QXmlStreamWriter& xmlWriter, const QString& xml } /* Function that creates and saves the xml .dissco file */ -void ProjectView::save(){ +bool ProjectView::save(){ qDebug() << "In Project View Save Function"; eventAttributesView->saveCurrentShownEventData(); ProjectManager *pm = Inst::get_project_manager(); - modifiedButNotSaved = false; // changes bool value because file is being saved + const auto modifierListNeedsReview = [](const QList& modifiers) { + for (const Modifier& modifier : modifiers) { + if (modifier.usage_metadata_needs_review) + return true; + } + return false; + }; + bool needsCompatibilityBackup = false; + needsCompatibilityBackup = + modifierListNeedsReview(pm->topevent().modifiers); + for (const HEvent& event : pm->highevents()) + needsCompatibilityBackup = needsCompatibilityBackup + || modifierListNeedsReview(event.modifiers); + for (const HEvent& event : pm->midevents()) + needsCompatibilityBackup = needsCompatibilityBackup + || modifierListNeedsReview(event.modifiers); + for (const HEvent& event : pm->lowevents()) + needsCompatibilityBackup = needsCompatibilityBackup + || modifierListNeedsReview(event.modifiers); + for (const BottomEvent& bottomEvent : pm->bottomevents()) { + needsCompatibilityBackup = needsCompatibilityBackup + || bottomEvent.extra_info.modifier_usage_needs_review + || modifierListNeedsReview(bottomEvent.extra_info.modifiers); + } + + const QFileInfo originalFileInfo = pm->fileinfo(); + if (needsCompatibilityBackup && originalFileInfo.exists()) { + const QString backupName = + originalFileInfo.completeBaseName() + + QStringLiteral(".pre-modifier-usage.") + + QUuid::createUuid().toString(QUuid::WithoutBraces) + + QStringLiteral(".dissco"); + const QString backupPath = + originalFileInfo.absoluteDir().filePath(backupName); + // QFile::copy refuses to overwrite an existing file. Combined with a + // fresh UUID, every conversion save preserves the exact current input + // instead of silently reusing a stale backup from an earlier save. + if (!QFile::copy(originalFileInfo.absoluteFilePath(), backupPath)) { + QMessageBox::critical( + mainWindow, tr("Could not create compatibility backup"), + tr("The project was not saved because LASSIE could not " + "preserve the original Modifier Group file at:\n%1") + .arg(backupPath)); + return false; + } + QMessageBox::information( + mainWindow, tr("Compatibility backup created"), + tr("This project contains legacy or incomplete Modifier " + "Usage data. The original file was preserved at:\n%1\n\n" + "The saved project will use only the new Modifier Usage " + "format.") + .arg(backupPath)); + } // ensure directory exists before creating file QFileInfo fileInfo = pm->fileinfo(); @@ -120,16 +196,26 @@ void ProjectView::save(){ if (!dir.exists()) { if (!dir.mkpath(".")) { qDebug() << "Failed to create directory:" << dir.absolutePath(); - return; + QMessageBox::critical( + mainWindow, tr("Could not save project"), + tr("LASSIE could not create the project directory:\n%1") + .arg(dir.absolutePath())); + return false; } } - // creates the file with the specified /path/name.dissco - QFile file(pm->fileinfo().absoluteFilePath()); + // QSaveFile writes beside the destination and atomically replaces it only + // after the complete XML document has been written successfully. + QSaveFile file(pm->fileinfo().absoluteFilePath()); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { qDebug() << "Failed to open file:" << file.fileName(); qDebug() << "Error reason:" << file.errorString(); - return; + QMessageBox::critical( + mainWindow, tr("Could not save project"), + tr("LASSIE could not open the project file for writing:\n%1\n\n%2") + .arg(QDir::toNativeSeparators(file.fileName()), + file.errorString())); + return false; } // QXmlStreamWriter class writes to the XML file @@ -717,7 +803,47 @@ void ProjectView::save(){ xmlWriter.writeEndElement(); xmlWriter.writeEndElement(); - file.close(); + xmlWriter.writeEndDocument(); + + if (xmlWriter.hasError()) { + const QString error = file.errorString(); + file.cancelWriting(); + QMessageBox::critical( + mainWindow, tr("Could not save project"), + tr("An error occurred while writing the project file:\n%1\n\n%2") + .arg(QDir::toNativeSeparators(file.fileName()), error)); + return false; + } + if (!file.commit()) { + QMessageBox::critical( + mainWindow, tr("Could not save project"), + tr("LASSIE could not replace the project file:\n%1\n\n%2") + .arg(QDir::toNativeSeparators(file.fileName()), + file.errorString())); + return false; + } + + // A successful migration save is the user's confirmation of the new + // format. Clear the transient review markers only after the file commit; + // otherwise a later normal save would back up the already-migrated XML. + const auto clearModifierReviewFlags = [](QList& modifiers) { + for (Modifier& modifier : modifiers) + modifier.usage_metadata_needs_review = false; + }; + clearModifierReviewFlags(pm->topevent().modifiers); + for (HEvent& event : pm->highevents()) + clearModifierReviewFlags(event.modifiers); + for (HEvent& event : pm->midevents()) + clearModifierReviewFlags(event.modifiers); + for (HEvent& event : pm->lowevents()) + clearModifierReviewFlags(event.modifiers); + for (BottomEvent& bottomEvent : pm->bottomevents()) { + bottomEvent.extra_info.modifier_usage_needs_review = false; + clearModifierReviewFlags(bottomEvent.extra_info.modifiers); + } + + modifiedButNotSaved = false; + return true; } void ProjectView::setProperties() { @@ -1229,17 +1355,24 @@ if (nameExists(newName)) { switch (etype) { case high: - dup(pm->highevents()); + dup(pm->highevents()); + PVCHelper::renewModifierIds( + pm->highevents().last().modifiers); break; case mid: dup(pm->midevents()); + PVCHelper::renewModifierIds( + pm->midevents().last().modifiers); break; case low: dup(pm->lowevents()); + PVCHelper::renewModifierIds( + pm->lowevents().last().modifiers); break; case bottom: { BottomEvent copy = pm->bottomevents()[index]; copy.event.name = newName; + PVCHelper::renewModifierIds(copy.extra_info.modifiers); pm->bottomevents().append(copy); break; } case sound: diff --git a/LASSIE/src/widgets/ProjectViewController.hpp b/LASSIE/src/widgets/ProjectViewController.hpp index f4b77b6f..f5b45ec5 100644 --- a/LASSIE/src/widgets/ProjectViewController.hpp +++ b/LASSIE/src/widgets/ProjectViewController.hpp @@ -23,8 +23,8 @@ class ProjectView : public QObject { ProjectView(MainWindow* _mainWindow, QString _pathAndName); ~ProjectView() override; - /* function to write to the xml .dissco file */ - void save(); + /* Write the XML .dissco file; false means nothing was committed. */ + bool save(); void writeInlineXml(QXmlStreamWriter& xmlWriter, const QString& xmlString); /* set properties pop up function */ diff --git a/LASSIE/src/windows/MainWindow.cpp b/LASSIE/src/windows/MainWindow.cpp index 25c0ca58..1e84727d 100644 --- a/LASSIE/src/windows/MainWindow.cpp +++ b/LASSIE/src/windows/MainWindow.cpp @@ -158,6 +158,10 @@ MainWindow::~MainWindow() { void MainWindow::closeEvent(QCloseEvent *event) { + if (!maybeSaveBeforeClose()) { + event->ignore(); + return; + } writeSettings(); event->accept(); } @@ -166,6 +170,8 @@ bool MainWindow::maybeSaveBeforeClose() { if (!projectView) return true; + if (!Inst::get_project_manager()->modified()) + return true; const QMessageBox::StandardButton reply = QMessageBox::question( this, @@ -175,9 +181,8 @@ bool MainWindow::maybeSaveBeforeClose() ); if (reply == QMessageBox::Yes) - saveFile(); - - return reply != QMessageBox::Cancel; + return saveFile(); + return reply == QMessageBox::No; } void MainWindow::closeCurrentProject() @@ -274,8 +279,11 @@ void MainWindow::openProjectPath(const QString &path) showFile(); } -void MainWindow::saveFile() -{ +bool MainWindow::saveFile() +{ + if (!projectView || currentFile.isEmpty()) + return false; + //nhi: ensure directory exists before saving const QFileInfo fileInfo(currentFile); if (const QDir dir = fileInfo.absoluteDir(); !dir.exists()) { @@ -283,30 +291,43 @@ void MainWindow::saveFile() QMessageBox::critical(this, tr("Error"), tr("Failed to create directory:\n%1") .arg(dir.absolutePath())); - return; + return false; } } - projectView->save(); + if (!projectView->save()) + return false; //nhi: update window title and status after successful save - Inst::get_project_manager()->modified() = false; - setWindowModified(false); + ProjectManager *pm = Inst::get_project_manager(); + pm->modified() = false; + setCurrentFile(pm->fileinfo().absoluteFilePath(), false); statusBar()->showMessage(tr("File saved"), 2000); + return true; } -void MainWindow::saveFileAs() +bool MainWindow::saveFileAs() { const QString fileName = QFileDialog::getSaveFileName(this, tr("Save As"), currentFile, tr("DISSCO Files (*.dissco);;All Files (*)")); - if (!fileName.isEmpty()){ - currentFile = fileName; - ProjectManager *pm = Inst::get_project_manager(); - pm->fileinfo() = QFileInfo(currentFile); - saveFile(); - addToRecentProjects(currentFile); + if (fileName.isEmpty()) + return false; + + ProjectManager *pm = Inst::get_project_manager(); + const QString previousCurrentFile = currentFile; + const QFileInfo previousFileInfo = pm->fileinfo(); + + currentFile = fileName; + pm->fileinfo() = QFileInfo(currentFile); + if (!saveFile()) { + currentFile = previousCurrentFile; + pm->fileinfo() = previousFileInfo; + return false; } + + addToRecentProjects(currentFile); + return true; } void MainWindow::showEnvelopeLibraryWindow() const { @@ -358,7 +379,8 @@ void MainWindow::runProject() switch(msgbox.exec()) { case QMessageBox::Save: - saveFile(); + if (!saveFile()) + return; break; case QMessageBox::Ignore: break; diff --git a/LASSIE/src/windows/MainWindow.hpp b/LASSIE/src/windows/MainWindow.hpp index 8ea8288c..80081189 100644 --- a/LASSIE/src/windows/MainWindow.hpp +++ b/LASSIE/src/windows/MainWindow.hpp @@ -60,8 +60,8 @@ class MainWindow : public QMainWindow // File operations void newFile(); void openFile(); - void saveFile(); - void saveFileAs(); + bool saveFile(); + bool saveFileAs(); // Edit operations /* TODO: implement undo /edo */ @@ -99,7 +99,7 @@ class MainWindow : public QMainWindow void showFile(); void openProjectPath(const QString &path); - // Returns false if the user cancelled, true otherwise. + // Returns false if the user cancelled or the requested save failed. // Prompts to save unsaved changes when a project is already open. bool maybeSaveBeforeClose(); // Tears down the current project view and project data, resetting UI state.