diff --git a/CHANGELOG.md b/CHANGELOG.md index ac8e7b959..8bcad624d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Added a `CancelToken` class (`threads/yup_CancelToken.h`): a thread-safe, copyable observer token with `wasCancelled()`, blocking observation via `waitForCancellation()`, and callback observation via `registerCallback()`/`Registration` - Added a `CancelTokenSource` class (`threads/yup_CancelTokenSource.h`): a move-only RAII owner of a `CancelToken` that is the sole canceller, requesting cancellation automatically when destroyed (unless moved-from), with observer copies obtained via `getToken()` +### Audio + +- Added a `TuningMap` class (`midi/yup_TuningMap.h`): maps MIDI note numbers to frequencies under an arbitrary scale and key map, loading Scala `.scl` scale files and `.kbm` key map files via `loadScale()` / `loadKeyMap()` (which return a `yup::Result` and keep the previous tuning when a file fails to parse). `isNoteActive()` reports the notes a key map asks to retune, taken from the range in its header unless the file carries `< first last` lines, which declare it instead + ### Graphics - `GpuTexture` now caches the backend texture views it hands out, keyed by view descriptor. A render pass previously allocated a fresh `ore::TextureView` for every attachment on every pass and for every sampled texture on every draw - all identical frame after frame - which on Metal made building the attachment descriptors cost more than creating the command encoder they were for. The cache needs no invalidation because a `GpuTexture` wraps one underlying texture for its whole lifetime: `GpuCanvas` and `GpuTarget` build a new `GpuTexture` whenever their backing changes diff --git a/modules/yup_audio_basics/midi/yup_TuningMap.cpp b/modules/yup_audio_basics/midi/yup_TuningMap.cpp new file mode 100644 index 000000000..8205228f5 --- /dev/null +++ b/modules/yup_audio_basics/midi/yup_TuningMap.cpp @@ -0,0 +1,482 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include +#include + +namespace yup +{ + +namespace TuningMapHelpers +{ + +constexpr int maxMidiNote = 127; + +constexpr int maxScaleDegree = 100000; + +// Returns true and sets the result if the token is a plain non-negative integer. +static bool parseInteger (const String& token, int& result) noexcept +{ + if (token.isEmpty() || ! token.containsOnly ("0123456789")) + return false; + + const int64 value = token.getLargeIntValue(); + if (value > std::numeric_limits::max()) + return false; + + result = static_cast (value); + return true; +} + +// Returns true and sets the result if the token is a plain decimal number. +static bool parseDecimal (const String& token, double& result) noexcept +{ + int i = 0; + const int length = token.length(); + + if (i < length && (token[i] == '+' || token[i] == '-')) + ++i; + + bool seenDigit = false; + bool seenDot = false; + for (; i < length; ++i) + { + const auto c = token[i]; + if (c >= '0' && c <= '9') + { + seenDigit = true; + continue; + } + if (c == '.' && ! seenDot) + { + seenDot = true; + continue; + } + return false; + } + + if (! seenDigit) + return false; + + result = token.getDoubleValue(); + return true; +} + +// Parses one Scala interval, either a rational ratio "n/d" or a value in cents. +// Returns false if the token does not describe a usable interval. +static bool parseScalaInterval (const String& token, double& ratioOut) noexcept +{ + if (token.containsChar ('.')) + { + double cents = 0.0; + if (! parseDecimal (token, cents)) + return false; + + ratioOut = std::pow (2.0, cents / 1200.0); + return true; + } + + const int slash = token.indexOfChar ('/'); + + int numerator = 0; + int denominator = 1; // a token without a slash is a ratio over 1, so "2" means "2/1" + + if (slash < 0) + { + if (! parseInteger (token, numerator)) + return false; + } + else + { + if (slash == 0 || slash == token.length() - 1) + return false; + + if (! parseInteger (token.substring (0, slash), numerator) + || ! parseInteger (token.substring (slash + 1), denominator)) + return false; + } + + if (numerator <= 0 || denominator <= 0) + return false; + + ratioOut = static_cast (numerator) / static_cast (denominator); + return true; +} + +// Applies an optional "< first last" range line of a .kbm file. +static bool parseActiveRange (const StringArray& tokens, std::array& range) noexcept +{ + if (tokens.size() < 3 || ! tokens[0].startsWithChar ('<')) + return false; + + int firstNote = 0; + int lastNote = 0; + if (! parseInteger (tokens[1], firstNote) || ! parseInteger (tokens[2], lastNote)) + return false; + + if (! isPositiveAndBelow (firstNote, maxMidiNote + 1) || ! isPositiveAndBelow (lastNote, maxMidiNote + 1)) + return false; + + if (firstNote > lastNote) + return false; + + for (int i = firstNote; i <= lastNote; ++i) + range[static_cast (i)] = true; + + return true; +} + +} // namespace TuningMapHelpers + +//============================================================================== +TuningMap::TuningMap() +{ + scale.resize (12); + for (int i = 1; i <= 12; ++i) + scale[static_cast (i - 1)] = std::pow (2.0, i / 12.0); + + mapping = { 0 }; + activateRange (0, TuningMapHelpers::maxMidiNote); + updateBasePitch(); +} + +//============================================================================== +double TuningMap::noteToPitch (int note) const noexcept +{ + jassert (isPositiveAndBelow (note, TuningMapHelpers::maxMidiNote + 1)); + + if (! isPositiveAndBelow (note, TuningMapHelpers::maxMidiNote + 1)) + return -1.0; + + if (mapping.empty() || scale.empty()) + return -1.0; + + const int mapSize = static_cast (mapping.size()); + const int scaleSize = static_cast (scale.size()); + + int nRepeats = (note - zeroNote) / mapSize; + int mapIndex = (note - zeroNote) % mapSize; + if (mapIndex < 0) + { + --nRepeats; + mapIndex += mapSize; + } + + if (mapping[static_cast (mapIndex)] < 0) + return -1.0; + + const int64 totalDegrees = static_cast (nRepeats) * mapRepeatInc + + mapping[static_cast (mapIndex)]; + + int64 nOctaves = totalDegrees / scaleSize; + int64 scaleIndex = totalDegrees % scaleSize; + if (scaleIndex < 0) + { + --nOctaves; + scaleIndex += scaleSize; + } + + const double octaveRatio = scale.back(); + const double octaves = std::pow (octaveRatio, static_cast (nOctaves)); + + if (scaleIndex == 0) + return basePitch * octaves; + + return basePitch * octaves * scale[static_cast (scaleIndex - 1)]; +} + +bool TuningMap::isNoteMapped (int note) const noexcept +{ + jassert (isPositiveAndBelow (note, TuningMapHelpers::maxMidiNote + 1)); + + if (! isPositiveAndBelow (note, TuningMapHelpers::maxMidiNote + 1)) + return false; + + if (mapping.empty()) + return false; + + const int mapSize = static_cast (mapping.size()); + + int mapIndex = (note - zeroNote) % mapSize; + if (mapIndex < 0) + mapIndex += mapSize; + + return mapping[static_cast (mapIndex)] >= 0; +} + +bool TuningMap::isNoteActive (int note) const noexcept +{ + jassert (isPositiveAndBelow (note, TuningMapHelpers::maxMidiNote + 1)); + + if (! isPositiveAndBelow (note, TuningMapHelpers::maxMidiNote + 1)) + return false; + + return activeRange[static_cast (note)]; +} + +//============================================================================== +Result TuningMap::loadScale (const File& file) +{ + auto stream = file.createInputStream(); + if (stream == nullptr) + return Result::fail ("Cannot open the scale file: " + file.getFullPathName()); + + StringArray lines; + lines.addLines (stream->readEntireStreamAsString()); + + bool descriptionSeen = false; + int noteCount = -1; + std::vector newScale; + + for (int i = 0; i < lines.size(); ++i) + { + const String line = lines[i].trim(); + if (line.startsWithChar ('!')) + continue; + + if (! descriptionSeen) + { + descriptionSeen = true; + continue; + } + + if (line.isEmpty()) + continue; + + if (noteCount < 0) + { + int count = 0; + if (! TuningMapHelpers::parseInteger (line, count) || count <= 0) + return Result::fail ("The scale file contains an invalid note count"); + + noteCount = count; + continue; + } + + double ratio = 0.0; + if (! TuningMapHelpers::parseScalaInterval (line.initialSectionNotContaining (" \t"), ratio)) + return Result::fail ("The scale file contains an invalid interval"); + + newScale.push_back (ratio); + } + + if (! descriptionSeen || noteCount < 0 || static_cast (newScale.size()) != noteCount) + return Result::fail ("The scale file does not describe the declared number of notes"); + + scale = std::move (newScale); + scaleFile = file.getFullPathName(); + updateBasePitch(); + + return Result::ok(); +} + +Result TuningMap::loadKeyMap (const File& file) +{ + auto stream = file.createInputStream(); + if (stream == nullptr) + return Result::fail ("Cannot open the key map file: " + file.getFullPathName()); + + StringArray lines; + lines.addLines (stream->readEntireStreamAsString()); + + int mapSize = -1; + int firstNote = -1; + int lastNote = -1; + int newZeroNote = -1; + int newRefNote = -1; + int newRepeatInc = -1; + double newRefPitch = -1.0; + + std::vector newMapping; + std::array newActiveRange {}; + bool rangeDeclared = false; + + for (int i = 0; i < lines.size(); ++i) + { + const String line = lines[i].trim(); + if (line.isEmpty() || line.startsWithChar ('!')) + continue; + + if (line.startsWithChar ('<')) + { + if (! TuningMapHelpers::parseActiveRange (StringArray::fromTokens (line, " \t", "\""), newActiveRange)) + return Result::fail ("The key map file contains an invalid active range"); + + rangeDeclared = true; + continue; + } + + if (mapSize < 0) + { + if (! TuningMapHelpers::parseInteger (line, mapSize) || mapSize > TuningMapHelpers::maxMidiNote + 1) + return Result::fail ("The key map file contains an invalid map size"); + } + else if (firstNote < 0) + { + if (! TuningMapHelpers::parseInteger (line, firstNote) || ! isPositiveAndBelow (firstNote, 128)) + return Result::fail ("The key map file contains an invalid first note"); + } + else if (lastNote < 0) + { + if (! TuningMapHelpers::parseInteger (line, lastNote) || ! isPositiveAndBelow (lastNote, 128) || lastNote < firstNote) + return Result::fail ("The key map file contains an invalid last note"); + } + else if (newZeroNote < 0) + { + if (! TuningMapHelpers::parseInteger (line, newZeroNote) || ! isPositiveAndBelow (newZeroNote, 128)) + return Result::fail ("The key map file contains an invalid zero note"); + } + else if (newRefNote < 0) + { + if (! TuningMapHelpers::parseInteger (line, newRefNote) || ! isPositiveAndBelow (newRefNote, 128)) + return Result::fail ("The key map file contains an invalid reference note"); + } + else if (newRefPitch <= 0.0) + { + if (! TuningMapHelpers::parseDecimal (line, newRefPitch) || newRefPitch <= 0.0) + return Result::fail ("The key map file contains an invalid reference pitch"); + } + else if (newRepeatInc < 0) + { + if (! TuningMapHelpers::parseInteger (line, newRepeatInc) || newRepeatInc > TuningMapHelpers::maxScaleDegree) + return Result::fail ("The key map file contains an invalid repeat increment"); + } + else + { + if (line.equalsIgnoreCase ("x")) + { + newMapping.push_back (-1); + } + else + { + int scaleDegree = 0; + if (! TuningMapHelpers::parseInteger (line, scaleDegree) || scaleDegree > TuningMapHelpers::maxScaleDegree) + return Result::fail ("The key map file contains an invalid mapping entry"); + + newMapping.push_back (scaleDegree); + } + } + } + + if (newRepeatInc < 0 || newRefPitch <= 0.0) + return Result::fail ("The key map file ended prematurely"); + + if (mapSize == 0) + { + if (! newMapping.empty()) + return Result::fail ("The key map file declares an automatic mapping but also contains mapping entries"); + + zeroNote = newZeroNote; + refNote = newRefNote; + refPitch = newRefPitch; + mapRepeatInc = 1; + + mapping = { 0 }; + } + else + { + newMapping.resize (static_cast (mapSize), -1); + + int refIndex = (newRefNote - newZeroNote) % mapSize; + if (refIndex < 0) + refIndex += mapSize; + + if (newMapping[static_cast (refIndex)] < 0) + return Result::fail ("The key map file leaves the reference note unmapped"); + + zeroNote = newZeroNote; + refNote = newRefNote; + refPitch = newRefPitch; + mapRepeatInc = newRepeatInc == 0 ? mapSize : newRepeatInc; + + mapping = std::move (newMapping); + } + + if (! rangeDeclared) + { + for (int i = firstNote; i <= lastNote; ++i) + newActiveRange[static_cast (i)] = true; + } + + activeRange = newActiveRange; + + keyMapFile = file.getFullPathName(); + updateBasePitch(); + + return Result::ok(); +} + +//============================================================================== +int TuningMap::getZeroNote() const noexcept +{ + return zeroNote; +} + +int TuningMap::getReferenceNote() const noexcept +{ + return refNote; +} + +double TuningMap::getReferencePitch() const noexcept +{ + return refPitch; +} + +int TuningMap::getKeyMapRepeatIncrement() const noexcept +{ + return mapRepeatInc; +} + +int TuningMap::getNumberOfScaleDegrees() const noexcept +{ + return static_cast (scale.size()); +} + +String TuningMap::getScaleFile() const noexcept +{ + return scaleFile; +} + +String TuningMap::getKeyMapFile() const noexcept +{ + return keyMapFile; +} + +//============================================================================== +void TuningMap::activateRange (int firstNote, int lastNote) noexcept +{ + for (int i = firstNote; i <= lastNote; ++i) + activeRange[static_cast (i)] = true; +} + +void TuningMap::updateBasePitch() noexcept +{ + if (mapping.empty() || scale.empty()) + return; + + basePitch = 1.0; // compute the reference ratio relative to 1/1 first + + const double referenceRatio = noteToPitch (refNote); + if (referenceRatio > 0.0) + basePitch = refPitch / referenceRatio; +} + +} // namespace yup diff --git a/modules/yup_audio_basics/midi/yup_TuningMap.h b/modules/yup_audio_basics/midi/yup_TuningMap.h new file mode 100644 index 000000000..8baebfb3a --- /dev/null +++ b/modules/yup_audio_basics/midi/yup_TuningMap.h @@ -0,0 +1,250 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include + +namespace yup +{ + +//============================================================================== +/** + Maps MIDI note numbers to frequencies using an arbitrary scale and key map. + + A TuningMap combines two independent pieces of state: + + - A scale, i.e. the ordered set of pitch intervals the tuning uses within + each octave. The last scale interval is the octave itself, so a scale + with N intervals defines N pitch classes per octave (the tonic being + interval 0) together with the ratio that repeats them at higher octaves. + + - A key map, which describes how each MIDI key relates to the scale + degrees. Keys are mapped cyclically: after mapSize consecutive keys the + mapping repeats, each pass advancing by an adjustable number of scale + degrees, which is what allows an instrument to be laid out so that, for + example, one octave of keys always spans one octave of the tuning. + + The default configuration is the usual 12 tone equal temperament: the scale + holds the twelve chromatic intervals, every key maps to the corresponding + consecutive scale degree, and MIDI note 69 (A4) sounds at 440 Hz. + + Scales and key maps can be loaded from the two text formats used by Scala: + + - loadScale() reads a .scl file describing the scale intervals, either as + rational frequency ratios (e.g. "5/4") or as values in cents (any value + containing a decimal point). + + - loadKeyMap() reads a .kbm file describing how the MIDI keys map onto the + scale, including which notes to retune, which note carries the reference + frequency, and how the mapping repeats. Keys can be excluded from the + map with "x" entries, and the notes to retune - either the range in the + file header or the optional "< first last" range lines that override + it - are the ones considered playable (see isNoteActive()). + + Loading never leaves the tuning half-modified: if a file fails to parse, a + failed yup::Result is returned and the previously loaded scale/key map stays + in effect. + + noteToPitch() performs no allocation, but it reads state that loadScale() + and loadKeyMap() replace, so it can be called from a real-time thread only + while no load is in progress. + + @tags{Audio} +*/ +class YUP_API TuningMap +{ +public: + //============================================================================== + /** Creates a tuning using a 12 tone equal temperament scale and a + chromatic key map of all 128 MIDI notes, with A4 (MIDI note 69) + tuned to 440 Hz. + + @see noteToPitch + */ + TuningMap(); + + /** Destructor. */ + ~TuningMap() noexcept = default; + + //============================================================================== + /** Returns the frequency, in Hz, of a MIDI note under the current scale + and key map. + + Notes that the current key map excludes (an "x" entry in a .kbm file) + are not audible: this method returns a negative value for them. + + @param note the MIDI note number to look up, in the range 0 to 127 + + @returns the frequency in Hz, or a negative value if the note is + unmapped by the current key map + + @see isNoteMapped, loadScale, loadKeyMap + */ + double noteToPitch (int note) const noexcept; + + /** Returns true if the given MIDI note is mapped to an audible scale + degree by the current key map. + + @param note the MIDI note number to look up, in the range 0 to 127 + + @see noteToPitch + */ + bool isNoteMapped (int note) const noexcept; + + /** Returns true if the given MIDI note falls inside the active note range + declared by the current key map. + + This reflects the notes a .kbm file asks to retune: the range declared + by its header, or the optional "< first last" range lines when the + file has any, which take precedence. Notes outside the active range are + still retuned by noteToPitch(), they are simply reported as inactive. + + @param note the MIDI note number to look up, in the range 0 to 127 + + @see loadKeyMap + */ + bool isNoteActive (int note) const noexcept; + + //============================================================================== + /** Loads a scale from a Scala .scl file. + + The file must contain a description line, which may be empty, the + number of intervals in the scale, and that many intervals, one per + line. Comments ("!" lines) and blank lines are ignored. An interval is + either a rational ratio such as "5/4" or "2" (a whole number being a + ratio over 1), or a number of cents written with a decimal point (e.g. + "386.313714"). Any text following an interval is ignored. + + If the file cannot be read or does not describe the declared number of + intervals, a failed yup::Result is returned and the previously loaded + scale stays in effect. + + @param file the .scl file to read + + @see loadKeyMap, noteToPitch, getScaleFile + */ + Result loadScale (const File& file); + + /** Loads a key map from a Scala .kbm file. + + The file describes the size of the key map, the range of notes to + retune, the note whose frequency is fixed by the reference pitch, and + the mapping from keys to scale degrees. A "x" entry unmaps its key. The + range of notes to retune becomes the active note range, unless the file + carries "< first last" lines, which declare it instead (see + isNoteActive()). + + A key map size of 0 selects the automatic linear layout, where every + key is mapped to the consecutive scale degree. Keys listed after the + header fields map to the given scale degrees, an "x" entry unmaps its + key, keys that are never listed are treated as unmapped, and any + entries beyond the declared map size are ignored. + + If the file cannot be read, ends before declaring all of the header + fields, or leaves the reference note unmapped, a failed yup::Result is + returned and the previously loaded key map stays in effect. + + @param file the .kbm file to read + + @see loadScale, noteToPitch, getKeyMapFile + */ + Result loadKeyMap (const File& file); + + //============================================================================== + /** Returns the MIDI note number whose scale degree is considered the + tonic of the current key map, i.e. the key the mapping is anchored to. + + @see getReferenceNote, getKeyMapRepeatIncrement + */ + int getZeroNote() const noexcept; + + /** Returns the MIDI note whose frequency is fixed by the reference pitch. + + For the default tuning this is 69 (A4). + + @see getReferencePitch + */ + int getReferenceNote() const noexcept; + + /** Returns the frequency, in Hz, of getReferenceNote() under the current + scale and key map. + + For the default tuning this is 440 Hz. + + @see getReferenceNote, noteToPitch + */ + double getReferencePitch() const noexcept; + + /** Returns how many scale degrees the mapping advances every time it + cycles through the full set of keys in the key map. + + @see getZeroNote + */ + int getKeyMapRepeatIncrement() const noexcept; + + /** Returns the number of intervals stored in the current scale, i.e. the + number of pitch classes defined within each octave (the tonic + included). + + @see noteToPitch, loadScale + */ + int getNumberOfScaleDegrees() const noexcept; + + /** Returns the path of the .scl file most recently loaded with loadScale(), + or an empty string if no scale file has been loaded yet. + + @see loadScale + */ + String getScaleFile() const noexcept; + + /** Returns the path of the .kbm file most recently loaded with + loadKeyMap(), or an empty string if no key map file has been loaded + yet. + + @see loadKeyMap + */ + String getKeyMapFile() const noexcept; + +private: + //============================================================================== + void activateRange (int firstNote, int lastNote) noexcept; + void updateBasePitch() noexcept; + + //============================================================================== + std::vector scale; + std::vector mapping; + std::array activeRange {}; + + String scaleFile; + String keyMapFile; + + int zeroNote = 0; + int refNote = 69; + int mapRepeatInc = 1; + double refPitch = 440.0; + double basePitch = 1.0; + + YUP_LEAK_DETECTOR (TuningMap) +}; + +} // namespace yup diff --git a/modules/yup_audio_basics/yup_audio_basics.cpp b/modules/yup_audio_basics/yup_audio_basics.cpp index 714d58f9f..0638bd4d0 100644 --- a/modules/yup_audio_basics/yup_audio_basics.cpp +++ b/modules/yup_audio_basics/yup_audio_basics.cpp @@ -65,6 +65,7 @@ #include "midi/yup_MidiMessage.cpp" #include "midi/yup_MidiMessageSequence.cpp" #include "midi/yup_MidiRPN.cpp" +#include "midi/yup_TuningMap.cpp" #include "midi/ump/yup_UMPPacketBuffer.cpp" #include "midi/ump/yup_UMPKeyboardState.cpp" #include "midi/ump/yup_UMPMidi1ByteStream.cpp" diff --git a/modules/yup_audio_basics/yup_audio_basics.h b/modules/yup_audio_basics/yup_audio_basics.h index 0658e6800..07a572d3e 100644 --- a/modules/yup_audio_basics/yup_audio_basics.h +++ b/modules/yup_audio_basics/yup_audio_basics.h @@ -91,6 +91,7 @@ #include "midi/yup_MidiKeyboardState.h" #include "midi/yup_MidiRPN.h" #include "midi/yup_MidiDataConcatenator.h" +#include "midi/yup_TuningMap.h" #include "mpe/yup_MPEValue.h" #include "mpe/yup_MPENote.h" #include "mpe/yup_MPEZoneLayout.h" diff --git a/tests/yup_audio_basics.cpp b/tests/yup_audio_basics.cpp index b7165e7d5..65c6fc03c 100644 --- a/tests/yup_audio_basics.cpp +++ b/tests/yup_audio_basics.cpp @@ -55,6 +55,7 @@ #include "yup_audio_basics/yup_SmoothedValue.cpp" #include "yup_audio_basics/yup_Synthesiser.cpp" #include "yup_audio_basics/yup_ToneGeneratorAudioSource.cpp" +#include "yup_audio_basics/yup_TuningMap.cpp" #include "yup_audio_basics/yup_UMP.cpp" #include "yup_audio_basics/yup_UMPCapabilityInquiry.cpp" #include "yup_audio_basics/yup_UMPChannelVoice.cpp" diff --git a/tests/yup_audio_basics/yup_TuningMap.cpp b/tests/yup_audio_basics/yup_TuningMap.cpp new file mode 100644 index 000000000..25808aa4e --- /dev/null +++ b/tests/yup_audio_basics/yup_TuningMap.cpp @@ -0,0 +1,503 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include + +using namespace yup; + +class TuningMapTests : public ::testing::Test +{ +protected: + void TearDown() override + { + for (auto& file : tempFiles) + file.deleteFile(); + } + + File writeTempFile (const String& content) + { + auto file = File::createTempFile ("tuning_map_test"); + EXPECT_TRUE (file.replaceWithText (content)); + tempFiles.push_back (file); + return file; + } + + TuningMap map; + std::vector tempFiles; +}; + +TEST_F (TuningMapTests, DefaultTuningIsTwelveToneEqualTemperament) +{ + EXPECT_NEAR (map.noteToPitch (69), 440.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (60), 440.0 * std::pow (2.0, -9.0 / 12.0), 1e-6); + EXPECT_NEAR (map.noteToPitch (81), 880.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (0), 440.0 * std::pow (2.0, -69.0 / 12.0), 1e-6); + EXPECT_NEAR (map.noteToPitch (127), 440.0 * std::pow (2.0, 58.0 / 12.0), 1e-6); + + for (int note = 0; note < 128; ++note) + { + EXPECT_TRUE (map.isNoteMapped (note)); + EXPECT_TRUE (map.isNoteActive (note)); + } + + EXPECT_EQ (map.getZeroNote(), 0); + EXPECT_EQ (map.getReferenceNote(), 69); + EXPECT_EQ (map.getReferencePitch(), 440.0); + EXPECT_EQ (map.getKeyMapRepeatIncrement(), 1); + EXPECT_EQ (map.getNumberOfScaleDegrees(), 12); + EXPECT_TRUE (map.getScaleFile().isEmpty()); + EXPECT_TRUE (map.getKeyMapFile().isEmpty()); +} + +TEST_F (TuningMapTests, DefaultTuningIsSemitoneConsecutive) +{ + for (int note = 1; note < 128; ++note) + EXPECT_NEAR (map.noteToPitch (note) / map.noteToPitch (note - 1), std::pow (2.0, 1.0 / 12.0), 1e-9); +} + +TEST_F (TuningMapTests, LoadingCentsScaleProducesEqualTemperament) +{ + auto file = writeTempFile ( + "! 12 tone equal temperament\n" + "12-EDO\n" + "12\n" + "100.0\n" + "200.0\n" + "300.0\n" + "400.0\n" + "500.0\n" + "600.0\n" + "700.0\n" + "800.0\n" + "900.0\n" + "1000.0\n" + "1100.0\n" + "1200.0\n"); + + EXPECT_TRUE (map.loadScale (file).wasOk()); + EXPECT_EQ (map.getScaleFile(), file.getFullPathName()); + EXPECT_EQ (map.getNumberOfScaleDegrees(), 12); + + for (int note = 0; note < 128; ++note) + EXPECT_NEAR (map.noteToPitch (note), 440.0 * std::pow (2.0, (note - 69) / 12.0), 1e-6); +} + +TEST_F (TuningMapTests, LoadingRatioScaleMapsScaleDegreesToRatios) +{ + auto scaleFile = writeTempFile ( + "! A just intonation major pentatonic\n" + "major pentatonic\n" + "5\n" + "9/8\n" + "5/4\n" + "3/2\n" + "5/3\n" + "2/1\n"); + + auto keyMapFile = writeTempFile ( + "! chromatic key map over the pentatonic scale\n" + "5\n" + "0\n" + "127\n" + "0\n" + "60\n" + "440\n" + "5\n" + "0\n" + "1\n" + "2\n" + "3\n" + "4\n"); + + EXPECT_TRUE (map.loadScale (scaleFile).wasOk()); + EXPECT_TRUE (map.loadKeyMap (keyMapFile).wasOk()); + + EXPECT_EQ (map.getKeyMapFile(), keyMapFile.getFullPathName()); + EXPECT_EQ (map.getZeroNote(), 0); + EXPECT_EQ (map.getReferenceNote(), 60); + EXPECT_EQ (map.getReferencePitch(), 440.0); + EXPECT_EQ (map.getKeyMapRepeatIncrement(), 5); + EXPECT_EQ (map.getNumberOfScaleDegrees(), 5); + + // The tonic (note 60) carries the reference pitch, and each of the next + // keys sounds the consecutive scale interval above it. + EXPECT_NEAR (map.noteToPitch (60), 440.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (61), 440.0 * 9.0 / 8.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (62), 440.0 * 5.0 / 4.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (63), 440.0 * 3.0 / 2.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (64), 440.0 * 5.0 / 3.0, 1e-6); + + // Advancing one full pass through the key map (five keys) raises the + // pitch by one octave. + EXPECT_NEAR (map.noteToPitch (65), 880.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (59), 440.0 * 5.0 / 6.0, 1e-6); +} + +TEST_F (TuningMapTests, ScaleDescriptionCanBeEmpty) +{ + // A .scl file with no description carries a blank line in its place. + auto file = writeTempFile ( + "! a scale without a description\n" + "\n" + "1\n" + "2/1\n"); + + EXPECT_TRUE (map.loadScale (file).wasOk()); + EXPECT_EQ (map.getNumberOfScaleDegrees(), 1); + + EXPECT_NEAR (map.noteToPitch (69), 440.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (70), 880.0, 1e-6); +} + +TEST_F (TuningMapTests, IntervalsAcceptWholeNumberRatiosAndTrailingText) +{ + auto file = writeTempFile ( + "! every interval form the format allows\n" + "annotated scale\n" + "3\n" + "386.313714 major third\n" + "3/2 fifth\n" + "2\n"); + + EXPECT_TRUE (map.loadScale (file).wasOk()); + EXPECT_EQ (map.getNumberOfScaleDegrees(), 3); + + EXPECT_NEAR (map.noteToPitch (69), 440.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (70), 440.0 * std::pow (2.0, 386.313714 / 1200.0), 1e-6); + EXPECT_NEAR (map.noteToPitch (71), 440.0 * 3.0 / 2.0, 1e-6); + + // The bare "2" is the octave, so the scale repeats after three keys. + EXPECT_NEAR (map.noteToPitch (72), 880.0, 1e-6); +} + +TEST_F (TuningMapTests, AutomaticKeyMapMapsKeysToConsecutiveDegrees) +{ + auto file = writeTempFile ( + "! automatic linear key map\n" + "0\n" + "0\n" + "127\n" + "0\n" + "69\n" + "440\n" + "0\n"); + + EXPECT_TRUE (map.loadKeyMap (file).wasOk()); + + EXPECT_EQ (map.getKeyMapRepeatIncrement(), 1); + EXPECT_EQ (map.getZeroNote(), 0); + + for (int note = 0; note < 128; ++note) + EXPECT_NEAR (map.noteToPitch (note), 440.0 * std::pow (2.0, (note - 69) / 12.0), 1e-6); +} + +TEST_F (TuningMapTests, UnmappedKeysAreNotAudible) +{ + auto file = writeTempFile ( + "! key map with an unmapped key\n" + "3\n" + "0\n" + "127\n" + "60\n" + "60\n" + "440\n" + "0\n" + "0\n" + "x\n" + "2\n"); + + EXPECT_TRUE (map.loadKeyMap (file).wasOk()); + + EXPECT_TRUE (map.isNoteMapped (60)); + EXPECT_FALSE (map.isNoteMapped (61)); + EXPECT_TRUE (map.isNoteMapped (62)); + + EXPECT_NEAR (map.noteToPitch (60), 440.0, 1e-6); + EXPECT_LT (map.noteToPitch (61), 0.0); + EXPECT_GE (map.noteToPitch (62), 0.0); + + // A repeat increment of zero selects the key map size. + EXPECT_EQ (map.getKeyMapRepeatIncrement(), 3); +} + +TEST_F (TuningMapTests, ActiveRangeReflectsDeclaredRanges) +{ + auto file = writeTempFile ( + "! key map declaring a playable range\n" + "< 60 72\n" + "12\n" + "0\n" + "127\n" + "60\n" + "69\n" + "440\n" + "12\n" + "0\n" + "1\n" + "2\n" + "3\n" + "4\n" + "5\n" + "6\n" + "7\n" + "8\n" + "9\n" + "10\n" + "11\n"); + + EXPECT_TRUE (map.loadKeyMap (file).wasOk()); + + EXPECT_EQ (map.getZeroNote(), 60); + EXPECT_EQ (map.getReferenceNote(), 69); + EXPECT_EQ (map.getKeyMapRepeatIncrement(), 12); + + EXPECT_TRUE (map.isNoteActive (60)); + EXPECT_TRUE (map.isNoteActive (72)); + EXPECT_FALSE (map.isNoteActive (59)); + EXPECT_FALSE (map.isNoteActive (73)); + EXPECT_FALSE (map.isNoteActive (0)); + + // A note outside the declared range can still be mapped. + EXPECT_TRUE (map.isNoteMapped (59)); + EXPECT_FALSE (map.isNoteActive (59)); +} + +TEST_F (TuningMapTests, ActiveRangeDefaultsToTheDeclaredRetuneRange) +{ + auto file = writeTempFile ( + "! key map retuning the middle octave only\n" + "12\n" + "60\n" + "72\n" + "60\n" + "69\n" + "440\n" + "12\n" + "0\n" + "1\n" + "2\n" + "3\n" + "4\n" + "5\n" + "6\n" + "7\n" + "8\n" + "9\n" + "10\n" + "11\n"); + + EXPECT_TRUE (map.loadKeyMap (file).wasOk()); + + EXPECT_FALSE (map.isNoteActive (0)); + EXPECT_FALSE (map.isNoteActive (59)); + EXPECT_TRUE (map.isNoteActive (60)); + EXPECT_TRUE (map.isNoteActive (72)); + EXPECT_FALSE (map.isNoteActive (73)); + + // Notes outside the range are still mapped and still retuned. + EXPECT_TRUE (map.isNoteMapped (59)); + EXPECT_GT (map.noteToPitch (59), 0.0); +} + +TEST_F (TuningMapTests, MissingAndExtraKeyMapEntriesAreLenient) +{ + // Extra entries beyond the declared map size are dropped. + auto withExtras = writeTempFile ( + "! key map with extra entries\n" + "2\n" + "0\n" + "127\n" + "60\n" + "60\n" + "440\n" + "1\n" + "0\n" + "1\n" + "2\n"); + + EXPECT_TRUE (map.loadKeyMap (withExtras).wasOk()); + + EXPECT_NEAR (map.noteToPitch (60), 440.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (61), 440.0 * std::pow (2.0, 1.0 / 12.0), 1e-6); + EXPECT_NEAR (map.noteToPitch (62), map.noteToPitch (61), 1e-9); + + // Keys that are never listed after the header fields stay unmapped. + auto withMissing = writeTempFile ( + "! key map with a missing entry\n" + "2\n" + "0\n" + "127\n" + "60\n" + "60\n" + "440\n" + "1\n" + "0\n"); + + EXPECT_TRUE (map.loadKeyMap (withMissing).wasOk()); + + EXPECT_NEAR (map.noteToPitch (60), 440.0, 1e-6); + EXPECT_FALSE (map.isNoteMapped (61)); + EXPECT_LT (map.noteToPitch (61), 0.0); +} + +TEST_F (TuningMapTests, OversizedKeyMapIsRejected) +{ + auto file = writeTempFile ( + "! key map spanning more notes than the MIDI range\n" + "129\n" + "0\n" + "127\n" + "0\n" + "69\n" + "440\n" + "1\n"); + + EXPECT_TRUE (map.loadKeyMap (file).failed()); + EXPECT_NEAR (map.noteToPitch (69), 440.0, 1e-6); +} + +TEST_F (TuningMapTests, FailedScaleLoadKeepsPreviousTuning) +{ + auto scaleFile = writeTempFile ( + "major pentatonic\n" + "5\n" + "9/8\n" + "5/4\n" + "3/2\n" + "5/3\n" + "2/1\n"); + + EXPECT_TRUE (map.loadScale (scaleFile).wasOk()); + EXPECT_EQ (map.getNumberOfScaleDegrees(), 5); + + const auto pitch60 = map.noteToPitch (60); + const auto pitch61 = map.noteToPitch (61); + + auto badFile = writeTempFile ( + "broken scale\n" + "5\n" + "9/8\n" + "5/4\n"); + + EXPECT_TRUE (map.loadScale (badFile).failed()); + + EXPECT_EQ (map.getNumberOfScaleDegrees(), 5); + EXPECT_EQ (map.noteToPitch (60), pitch60); + EXPECT_EQ (map.noteToPitch (61), pitch61); + EXPECT_NEAR (map.noteToPitch (61) / map.noteToPitch (60), 9.0 / 8.0, 1e-9); + EXPECT_EQ (map.getScaleFile(), scaleFile.getFullPathName()); +} + +TEST_F (TuningMapTests, FailedKeyMapLoadKeepsPreviousTuning) +{ + auto keyMapFile = writeTempFile ( + "! valid key map\n" + "1\n" + "0\n" + "127\n" + "60\n" + "60\n" + "440\n" + "1\n" + "0\n"); + + EXPECT_TRUE (map.loadKeyMap (keyMapFile).wasOk()); + EXPECT_NEAR (map.noteToPitch (60), 440.0, 1e-6); + + auto badFile = writeTempFile ( + "! key map leaving the reference note unmapped\n" + "1\n" + "0\n" + "127\n" + "60\n" + "60\n" + "440\n" + "1\n" + "x\n"); + + EXPECT_TRUE (map.loadKeyMap (badFile).failed()); + EXPECT_TRUE (map.loadKeyMap (File::getCurrentWorkingDirectory().getChildFile ("missing_key_map.kbm")).failed()); + + EXPECT_NEAR (map.noteToPitch (60), 440.0, 1e-6); + EXPECT_EQ (map.getKeyMapFile(), keyMapFile.getFullPathName()); +} + +TEST_F (TuningMapTests, InvalidScaleAndKeyMapFilesAreRejected) +{ + EXPECT_TRUE (map.loadScale (File::getCurrentWorkingDirectory().getChildFile ("missing_scale.scl")).failed()); + EXPECT_TRUE (map.loadScale (writeTempFile ("just a description\n")).failed()); + + auto invalidInterval = writeTempFile ( + "bad interval\n" + "1\n" + "banana\n"); + EXPECT_TRUE (map.loadScale (invalidInterval).failed()); + + auto invalidRange = writeTempFile ( + "! invalid active range\n" + "< 200 300\n" + "1\n" + "0\n" + "127\n" + "0\n" + "69\n" + "440\n" + "1\n" + "0\n"); + EXPECT_TRUE (map.loadKeyMap (invalidRange).failed()); + + auto truncated = writeTempFile ( + "! truncated key map\n" + "2\n" + "0\n" + "127\n"); + EXPECT_TRUE (map.loadKeyMap (truncated).failed()); + + auto invertedRetuneRange = writeTempFile ( + "! key map whose retune range ends before it starts\n" + "1\n" + "72\n" + "60\n" + "60\n" + "60\n" + "440\n" + "1\n" + "0\n"); + EXPECT_TRUE (map.loadKeyMap (invertedRetuneRange).failed()); + + auto unreachableScaleDegree = writeTempFile ( + "! key map referencing a scale degree no tuning can reach\n" + "1\n" + "0\n" + "127\n" + "60\n" + "60\n" + "440\n" + "1\n" + "2000000000\n"); + EXPECT_TRUE (map.loadKeyMap (unreachableScaleDegree).failed()); + + EXPECT_NEAR (map.noteToPitch (69), 440.0, 1e-6); +}