From 2d6d0aeefd7076ffe0e3ce571151818b8840e4f1 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 8 Sep 2026 09:20:53 +0200 Subject: [PATCH 1/2] Tuning map --- CHANGELOG.md | 4 + .../yup_audio_basics/midi/yup_TuningMap.cpp | 465 ++++++++++++++++++ modules/yup_audio_basics/midi/yup_TuningMap.h | 245 +++++++++ modules/yup_audio_basics/yup_audio_basics.cpp | 1 + modules/yup_audio_basics/yup_audio_basics.h | 1 + tests/yup_audio_basics/yup_TuningMap.cpp | 401 +++++++++++++++ 6 files changed, 1117 insertions(+) create mode 100644 modules/yup_audio_basics/midi/yup_TuningMap.cpp create mode 100644 modules/yup_audio_basics/midi/yup_TuningMap.h create mode 100644 tests/yup_audio_basics/yup_TuningMap.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0288adb86..f2397d179 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) + ### 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..0dbb9b740 --- /dev/null +++ b/modules/yup_audio_basics/midi/yup_TuningMap.cpp @@ -0,0 +1,465 @@ +/* + ============================================================================== + + 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; + +// 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 ('/'); + if (slash <= 0 || slash == token.length() - 1) + return false; + + int numerator = 0; + int denominator = 0; + if (! parseInteger (token.substring (0, slash), numerator)) + return false; + if (! 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.isEmpty() || line.startsWithChar ('!')) + continue; + + if (! descriptionSeen) + { + descriptionSeen = true; + 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, 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)) + 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)) + 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)) + 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) + activeRange = newActiveRange; + else + activateRange (0, TuningMapHelpers::maxMidiNote); + + 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..36825af8b --- /dev/null +++ b/modules/yup_audio_basics/midi/yup_TuningMap.h @@ -0,0 +1,245 @@ +/* + ============================================================================== + + 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 optional "< first last" range lines + declare which notes are 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 and can be called from real-time + threads. + + @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 (or out of range) + + @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 optional "< first last" range lines of a .kbm + file. When a key map declares no range at all, every note is + considered active. + + @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, 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 a number of cents written with a decimal point (e.g. + "386.313714"). + + 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, + and the optional "< first last" lines declare the active note range + (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/yup_TuningMap.cpp b/tests/yup_audio_basics/yup_TuningMap.cpp new file mode 100644 index 000000000..7f4127536 --- /dev/null +++ b/tests/yup_audio_basics/yup_TuningMap.cpp @@ -0,0 +1,401 @@ +/* + ============================================================================== + + 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, 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, 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); + + 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_NEAR (map.noteToPitch (60), 440.0, 1e-6); + EXPECT_NEAR (map.noteToPitch (61), 440.0 * 9.0 / 8.0, 1e-6); + 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()); + + EXPECT_NEAR (map.noteToPitch (69), 440.0, 1e-6); +} From c099da370379aacf5c2ee7f23abc5d882503350c Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 8 Sep 2026 14:29:08 +0200 Subject: [PATCH 2/2] Add tuning map --- CHANGELOG.md | 2 +- .../yup_audio_basics/midi/yup_TuningMap.cpp | 49 +++++--- modules/yup_audio_basics/midi/yup_TuningMap.h | 37 +++--- tests/yup_audio_basics.cpp | 1 + tests/yup_audio_basics/yup_TuningMap.cpp | 106 +++++++++++++++++- 5 files changed, 160 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2397d179..077fd539a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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) +- 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 diff --git a/modules/yup_audio_basics/midi/yup_TuningMap.cpp b/modules/yup_audio_basics/midi/yup_TuningMap.cpp index 0dbb9b740..8205228f5 100644 --- a/modules/yup_audio_basics/midi/yup_TuningMap.cpp +++ b/modules/yup_audio_basics/midi/yup_TuningMap.cpp @@ -30,6 +30,8 @@ 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 { @@ -93,15 +95,24 @@ static bool parseScalaInterval (const String& token, double& ratioOut) noexcept } const int slash = token.indexOfChar ('/'); - if (slash <= 0 || slash == token.length() - 1) - return false; int numerator = 0; - int denominator = 0; - if (! parseInteger (token.substring (0, slash), numerator)) - return false; - if (! parseInteger (token.substring (slash + 1), denominator)) - return false; + 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; @@ -238,7 +249,7 @@ Result TuningMap::loadScale (const File& file) for (int i = 0; i < lines.size(); ++i) { const String line = lines[i].trim(); - if (line.isEmpty() || line.startsWithChar ('!')) + if (line.startsWithChar ('!')) continue; if (! descriptionSeen) @@ -247,6 +258,9 @@ Result TuningMap::loadScale (const File& file) continue; } + if (line.isEmpty()) + continue; + if (noteCount < 0) { int count = 0; @@ -258,7 +272,7 @@ Result TuningMap::loadScale (const File& file) } double ratio = 0.0; - if (! TuningMapHelpers::parseScalaInterval (line, ratio)) + if (! TuningMapHelpers::parseScalaInterval (line.initialSectionNotContaining (" \t"), ratio)) return Result::fail ("The scale file contains an invalid interval"); newScale.push_back (ratio); @@ -322,7 +336,7 @@ Result TuningMap::loadKeyMap (const File& file) } else if (lastNote < 0) { - if (! TuningMapHelpers::parseInteger (line, lastNote) || ! isPositiveAndBelow (lastNote, 128)) + 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) @@ -342,7 +356,7 @@ Result TuningMap::loadKeyMap (const File& file) } else if (newRepeatInc < 0) { - if (! TuningMapHelpers::parseInteger (line, newRepeatInc)) + if (! TuningMapHelpers::parseInteger (line, newRepeatInc) || newRepeatInc > TuningMapHelpers::maxScaleDegree) return Result::fail ("The key map file contains an invalid repeat increment"); } else @@ -354,7 +368,7 @@ Result TuningMap::loadKeyMap (const File& file) else { int scaleDegree = 0; - if (! TuningMapHelpers::parseInteger (line, scaleDegree)) + if (! TuningMapHelpers::parseInteger (line, scaleDegree) || scaleDegree > TuningMapHelpers::maxScaleDegree) return Result::fail ("The key map file contains an invalid mapping entry"); newMapping.push_back (scaleDegree); @@ -396,10 +410,13 @@ Result TuningMap::loadKeyMap (const File& file) mapping = std::move (newMapping); } - if (rangeDeclared) - activeRange = newActiveRange; - else - activateRange (0, TuningMapHelpers::maxMidiNote); + if (! rangeDeclared) + { + for (int i = firstNote; i <= lastNote; ++i) + newActiveRange[static_cast (i)] = true; + } + + activeRange = newActiveRange; keyMapFile = file.getFullPathName(); updateBasePitch(); diff --git a/modules/yup_audio_basics/midi/yup_TuningMap.h b/modules/yup_audio_basics/midi/yup_TuningMap.h index 36825af8b..8baebfb3a 100644 --- a/modules/yup_audio_basics/midi/yup_TuningMap.h +++ b/modules/yup_audio_basics/midi/yup_TuningMap.h @@ -57,15 +57,17 @@ namespace yup - 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 optional "< first last" range lines - declare which notes are considered playable (see isNoteActive()). + 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 and can be called from real-time - threads. + 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} */ @@ -94,7 +96,7 @@ class YUP_API TuningMap @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 (or out of range) + unmapped by the current key map @see isNoteMapped, loadScale, loadKeyMap */ @@ -112,9 +114,10 @@ class YUP_API TuningMap /** Returns true if the given MIDI note falls inside the active note range declared by the current key map. - This reflects the optional "< first last" range lines of a .kbm - file. When a key map declares no range at all, every note is - considered active. + 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 @@ -125,11 +128,12 @@ class YUP_API TuningMap //============================================================================== /** Loads a scale from a Scala .scl file. - The file must contain a description line, 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 a number of cents written with a decimal point (e.g. - "386.313714"). + 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 @@ -145,9 +149,10 @@ class YUP_API TuningMap 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, - and the optional "< first last" lines declare the active note range - (see isNoteActive()). + 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 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 index 7f4127536..25808aa4e 100644 --- a/tests/yup_audio_basics/yup_TuningMap.cpp +++ b/tests/yup_audio_basics/yup_TuningMap.cpp @@ -155,6 +155,43 @@ TEST_F (TuningMapTests, LoadingRatioScaleMapsScaleDegreesToRatios) 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 ( @@ -247,6 +284,43 @@ TEST_F (TuningMapTests, ActiveRangeReflectsDeclaredRanges) 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. @@ -318,6 +392,9 @@ TEST_F (TuningMapTests, FailedScaleLoadKeepsPreviousTuning) 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" @@ -327,8 +404,9 @@ TEST_F (TuningMapTests, FailedScaleLoadKeepsPreviousTuning) EXPECT_TRUE (map.loadScale (badFile).failed()); EXPECT_EQ (map.getNumberOfScaleDegrees(), 5); - EXPECT_NEAR (map.noteToPitch (60), 440.0, 1e-6); - EXPECT_NEAR (map.noteToPitch (61), 440.0 * 9.0 / 8.0, 1e-6); + 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()); } @@ -397,5 +475,29 @@ TEST_F (TuningMapTests, InvalidScaleAndKeyMapFilesAreRejected) "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); }