From 01accd5bf9266eb42bf5e817b217f9f71760c5d4 Mon Sep 17 00:00:00 2001 From: thetwom Date: Sun, 15 Sep 2024 20:18:19 +0200 Subject: [PATCH] (Hopefully) improved detection of frequencies. This scans through several possible harmonics to check if there is a better match than the purely correlation-based approach. --- .../tuner/notedetection/AutoCorrelation.kt | 16 +- .../tuner/notedetection/Correlation.kt | 8 +- .../FrequencyDetectionCollectedResults.kt | 35 +- .../tuner/notedetection/HarmonicPredictor.kt | 70 +++ .../moekadu/tuner/notedetection/Harmonics.kt | 405 +++++++++++++++++- .../moekadu/tuner/notedetection/TimeSeries.kt | 11 + .../main/java/de/moekadu/tuner/tuner/Tuner.kt | 12 +- .../java/de/moekadu/tuner/ui/common/Label.kt | 8 +- .../de/moekadu/tuner/HarmonicPredictorTest.kt | 54 +++ .../java/de/moekadu/tuner/HarmonicsTest.kt | 192 +++++++++ gradle/libs.versions.toml | 8 +- 11 files changed, 781 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/de/moekadu/tuner/notedetection/HarmonicPredictor.kt create mode 100644 app/src/test/java/de/moekadu/tuner/HarmonicPredictorTest.kt diff --git a/app/src/main/java/de/moekadu/tuner/notedetection/AutoCorrelation.kt b/app/src/main/java/de/moekadu/tuner/notedetection/AutoCorrelation.kt index 59678b29..e19a6126 100644 --- a/app/src/main/java/de/moekadu/tuner/notedetection/AutoCorrelation.kt +++ b/app/src/main/java/de/moekadu/tuner/notedetection/AutoCorrelation.kt @@ -18,12 +18,17 @@ */ package de.moekadu.tuner.notedetection +/** Store auto correlation result. + * @param size Number of values in auto correlation. + * @param dt Time shift between two values in auto correlation + */ class AutoCorrelation( val size: Int, - val dt: Float, - + val dt: Float ) { + /** Array with time shift for each auto correlation entry. */ val times = FloatArray(size) { it * dt } + /** Correlation values. */ val values = FloatArray(size) /** Values, normalized to range 0 to 1. */ @@ -31,8 +36,9 @@ class AutoCorrelation( /** The zero position in plotValuesNormalized. */ var plotValuesNormalizedZero = 0f + /** Obtain correlation value at given index. + * @param index Index where correlation value is needed. + * @return Correlation value + */ operator fun get(index: Int) = values[index] -// operator fun set(index: Int, value: Float) { -// values[index] = value -// } } \ No newline at end of file diff --git a/app/src/main/java/de/moekadu/tuner/notedetection/Correlation.kt b/app/src/main/java/de/moekadu/tuner/notedetection/Correlation.kt index d596cc68..1ca96f91 100644 --- a/app/src/main/java/de/moekadu/tuner/notedetection/Correlation.kt +++ b/app/src/main/java/de/moekadu/tuner/notedetection/Correlation.kt @@ -22,6 +22,11 @@ package de.moekadu.tuner.notedetection import de.moekadu.tuner.misc.MemoryPool import kotlin.math.pow +/** Class to compute auto correlation. + * @param size Number of input values, which is a time series. + * @param windowType Type of windowing to apply for FFT (first step of computing auto correlation). + * WindowingFunction.Tophat disables the windowing. + */ class Correlation (val size : Int, val windowType : WindowingFunction = WindowingFunction.Tophat) { private val fft = RealFFT(2 * size) @@ -32,8 +37,7 @@ class Correlation (val size : Int, val windowType : WindowingFunction = Windowin getWindow(windowType, size).copyInto(window) } - /// Autocorrelation of input. - /** + /** Auto correlation of input. * @param input Input data which should be correlated (required size: size) * @param output Output array where we store the autocorrelation, (required size: size+1) * @param disableWindow if true, we disable windowing, even when it is defined in the constructor. diff --git a/app/src/main/java/de/moekadu/tuner/notedetection/FrequencyDetectionCollectedResults.kt b/app/src/main/java/de/moekadu/tuner/notedetection/FrequencyDetectionCollectedResults.kt index cbdc1bec..b7bc33c4 100644 --- a/app/src/main/java/de/moekadu/tuner/notedetection/FrequencyDetectionCollectedResults.kt +++ b/app/src/main/java/de/moekadu/tuner/notedetection/FrequencyDetectionCollectedResults.kt @@ -98,14 +98,14 @@ class MemoryPoolFrequencyDetectionCollectedResults { class FrequencyDetectionResultCollector( private val frequencyMin: Float, private val frequencyMax: Float, - private val subharmonicsTolerance: Float = 0.05f, - private val subharmonicsPeakRatio: Float = 0.8f, - private val harmonicTolerance: Float = 0.1f, - private val minimumFactorOverLocalMean: Float = 5f, - private val maxGapBetweenHarmonics: Int = 10, - private val maxNumHarmonicsForInharmonicity: Int = 8, - private val windowType: WindowingFunction = WindowingFunction.Tophat, - private val acousticWeighting: AcousticWeighting = AcousticCWeighting() + private val subharmonicsTolerance: Float, // = 0.05f, + private val subharmonicsPeakRatio: Float, // = 0.8f, + private val harmonicTolerance: Float, // = 0.1f, + private val minimumFactorOverLocalMean: Float, // = 5f, + private val maxGapBetweenHarmonics: Int, // = 10, + private val maxNumHarmonicsForInharmonicity: Int, // = 8, + private val windowType: WindowingFunction, // = WindowingFunction.Tophat, + private val acousticWeighting: AcousticWeighting, // = AcousticCWeighting() ) { private val collectedResultsMemory = MemoryPoolFrequencyDetectionCollectedResults() private val spectrumAndCorrelationMemory = MemoryPoolCorrelation() @@ -148,9 +148,9 @@ class FrequencyDetectionResultCollector( ) // Log.v("Tuner", "CollectedResults.collectResults: correlationBased frequency = ${collectedResults.memory.correlationBasedFrequency}") if (collectedResults.memory.correlationBasedFrequency.frequency != 0f) { - findHarmonicsFromSpectrum( - collectedResults.memory.harmonics, - collectedResults.memory.correlationBasedFrequency.frequency, + collectedResults.memory.harmonics.findBestMatchingHarmonics( + collectedResults.memory.correlationBasedFrequency, + collectedResults.memory.autoCorrelation, frequencyMin, frequencyMax, collectedResults.memory.frequencySpectrum, @@ -158,7 +158,20 @@ class FrequencyDetectionResultCollector( harmonicTolerance = harmonicTolerance, minimumFactorOverLocalMean = minimumFactorOverLocalMean, maxNumFail = maxGapBetweenHarmonics, + relativePeakThreshold = 5e-3f ) + +// findHarmonicsFromSpectrum( +// collectedResults.memory.harmonics, +// collectedResults.memory.correlationBasedFrequency.frequency, +// frequencyMin, +// frequencyMax, +// collectedResults.memory.frequencySpectrum, +// collectedResults.memory.accuratePeakFrequency, +// harmonicTolerance = harmonicTolerance, +// minimumFactorOverLocalMean = minimumFactorOverLocalMean, +// maxNumFail = maxGapBetweenHarmonics, +// ) collectedResults.memory.harmonics.sort() collectedResults.memory.harmonicStatistics.evaluate( diff --git a/app/src/main/java/de/moekadu/tuner/notedetection/HarmonicPredictor.kt b/app/src/main/java/de/moekadu/tuner/notedetection/HarmonicPredictor.kt new file mode 100644 index 00000000..522d7092 --- /dev/null +++ b/app/src/main/java/de/moekadu/tuner/notedetection/HarmonicPredictor.kt @@ -0,0 +1,70 @@ +package de.moekadu.tuner.notedetection + +/** Predict frequency of a given harmonic, based on previous harmonics. + * This class uses the function + * frequency = f1 * harmonic * (1 + beta * harmonic) + * which is the same as + * frequency = f1 * harmonic + alpha * harmonic**2 + * (with alpha = beta * f1) as modelling function and uses a least squares fit on previous + * information to predict new harmonics. + */ +class HarmonicPredictor { + /** Sum of frequency * harmonicNumber */ + private var sumFh = 0f + /** Sum of frequency * harmonicNumber**2 */ + private var sumFh2 = 0f + /** Sum of harmonicNumber**2 */ + private var sumH2 = 0f + /** Sum of harmonicNumber**3 */ + private var sumH3 = 0f + /** Sum of harmonicNumber**4 */ + private var sumH4 = 0f + /** Alpha factor of modelling function */ + private var alpha = 0f + /** Beta factor of modelling function */ + private var beta = 0f + /** Base frequency of modelling function */ + private var f1 = 0f + + /** Reset predictor. */ + fun clear() { + sumFh = 0f + sumFh2 = 0f + sumH2 = 0f + sumH3 = 0f + sumH4 = 0f + alpha = 0f + beta = 0f + f1 = 0f + } + /** Add new harmonic to the modelling function. + * @param harmonicNumber Harmonic number. + * @param frequency Frequency of harmonic. + */ + fun add(harmonicNumber: Int, frequency: Float) { + val hSqr = harmonicNumber * harmonicNumber + val hCub = harmonicNumber * hSqr + val hQuad = hSqr * hSqr + + sumFh += frequency * harmonicNumber + sumFh2 += frequency * hSqr + sumH2 += hSqr + sumH3 += hCub + sumH4 += hQuad + if (f1 == 0f) { + f1 = frequency / harmonicNumber + } else { + alpha = (sumFh2 * sumH2 - sumFh * sumH3) / (sumH4 * sumH2 - sumH3 * sumH3) + f1 = (sumFh - alpha * sumH3) / sumH2 + beta = alpha / f1 + } + } + + /** Predict frequency of a given harmonic. + * @param harmonicNumber harmonic number. + * @return Predicted frequency. + */ + fun predict(harmonicNumber: Int): Float { + return f1 * harmonicNumber * (1 + beta * harmonicNumber) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/moekadu/tuner/notedetection/Harmonics.kt b/app/src/main/java/de/moekadu/tuner/notedetection/Harmonics.kt index 9de7b04b..42c8da04 100644 --- a/app/src/main/java/de/moekadu/tuner/notedetection/Harmonics.kt +++ b/app/src/main/java/de/moekadu/tuner/notedetection/Harmonics.kt @@ -20,19 +20,29 @@ package de.moekadu.tuner.notedetection import kotlin.math.* +/** Details about a single harmonics. + * @param harmonicNumber Harmonic number. + * @param frequency Frequency of harmonic. + * @param spectrumIndex Index in original spectrum, where the harmonic is located. + * @param spectrumAmplitudeSquared Value of squared amplitude spectrum at given index. + */ data class Harmonic( var harmonicNumber: Int, var frequency: Float, var spectrumIndex: Int, var spectrumAmplitudeSquared: Float ) : Comparable { + /** Define ordering for harmonics which is based on harmonic number. */ override fun compareTo(other: Harmonic): Int { return harmonicNumber - other.harmonicNumber } } - +/** Container, storing several harmonics. + * @param maxCapacity Maximum number of harmonics, which can be stored here. + */ class Harmonics(maxCapacity: Int) { + /** Array, where the harmonic are stored. */ private val harmonics = Array(maxCapacity) { Harmonic(-1, 0f, -1, 0f) } @@ -42,11 +52,21 @@ class Harmonics(maxCapacity: Int) { var size = 0 private set + private var tmpHarmonics: Harmonics? = null + private var isTmp = false // flag which allows checking that we don' create tmp of tmp ... + + /** Sort the harmonics according to the harmonic number. */ fun sort() { if (!isSorted) harmonics.sort(0, size) } + /** Add a new harmonic to the container. + * @param harmonicNumber Harmonic number. + * @param frequency Frequency of harmonic. + * @param spectrumIndex Index in spectrum of the harmonic. + * @param spectrumAmplitudeSquared Value of squared amplitude spectrum at given index. + */ fun addHarmonic(harmonicNumber: Int, frequency: Float, spectrumIndex: Int, spectrumAmplitudeSquared: Float) { require(size < harmonics.size) harmonics[size].harmonicNumber = harmonicNumber @@ -58,15 +78,46 @@ class Harmonics(maxCapacity: Int) { isSorted = (harmonics[size-1].harmonicNumber >= harmonics[size-2].harmonicNumber) } + /** Get harmonic at given container index. + * @param index Container index. + * @return Harmonic at given index. + */ operator fun get(index: Int): Harmonic { require(index < size) return harmonics[index] } + /** Clear container. */ fun clear() { size = 0 isSorted = true } + + /** Adopt harmonics from other class. */ + fun adopt(other: Harmonics){ + if (other === this) + return + clear() + for (i in 0 until other.size) { + addHarmonic( + other[i].harmonicNumber, + other[i].frequency, + other[i].spectrumIndex, + other[i].spectrumAmplitudeSquared + ) + } + } + + /** Return temporary harmonics class. + * This is needed by algorithms like findBestMatchingHarmonics() + */ + fun getTemporary(): Harmonics { + assert(!isTmp) + val tmp = tmpHarmonics ?: Harmonics(harmonics.size) + tmp.isTmp = true + tmpHarmonics = tmp + return tmp + } } /** Find global maximum in an array between two given indices. @@ -106,10 +157,16 @@ fun findGlobalMaximumIndex(indexBegin: Int, indexEnd: Int, values: FloatArray): * is higher than minimum_factor_over_mean * average. * @param meanRadius Range for computing minimumFactorOverMean. Mean is computed around the found * maximum (note the given center). + * @param threshold A value can only be a peak, if it is higher than this given value. * @return Index of the maximum or None if no maximum is found. */ fun findLocalMaximumIndex( - values: FloatArray, center: Float, searchRadius: Float, minimumFactorOverMean: Float, meanRadius: Int + values: FloatArray, + center: Float, + searchRadius: Float, + minimumFactorOverMean: Float, + meanRadius: Int, + threshold: Float = 0f ): Int { val beginIndex = max(ceil(center - searchRadius).toInt(), 1) val endIndex = min(floor(center + searchRadius).toInt() + 1, values.size - 1) @@ -140,15 +197,161 @@ fun findLocalMaximumIndex( average /= meanIndexEnd - meanIndexBegin - 1 } - if (maximumValue < average * minimumFactorOverMean) + if (maximumValue < max(average * minimumFactorOverMean, threshold)) return -1 return maximumValueIndex } +/** Check if all harmonics have a common divisor. + * + * Currently we only check: + * - Only one harmonic, which is larger than 1 + * - divisible by 2 (or of course multiples of 2) + * - divisible by 3 (or of course multiples of 3) + * @return true if there is a common divisor, else false. + */ +fun Harmonics.hasCommonDivisors(): Boolean { + return ((size == 1 && this[0].harmonicNumber > 1) || + allHarmonicsDividableBy(2) || + allHarmonicsDividableBy(3) + ) +} + +/** Check if all harmonics can be divided by a given factor. + * @param factor Factor. + * @return true if all harmonics can be divided by the factor, else false. + */ +private fun Harmonics.allHarmonicsDividableBy(factor: Int): Boolean { + for (i in 0 until size) + if (this[i].harmonicNumber % factor > 0) + return false + return true +} + +/** Compute a rating value for the harmonics. + * The actual value is of no importance, it is just, the larger the value, the better. + * @return Rating value. + */ +private fun Harmonics.rating(): Float { + if (size == 0) + return 0f + val EXPONENT = 0.4f + val ADDITIONAL_CONTRIBUTION = 0.05f + + var harmonicSum = 0.0f + var frequencySum = 0.0f + var maximumAmplitude = 0.0f + + for (i in 0 until size) { + val h = this[i] + harmonicSum += h.spectrumAmplitudeSquared.pow(0.25f) + maximumAmplitude = max(maximumAmplitude, h.spectrumAmplitudeSquared) + frequencySum += h.frequency / h.harmonicNumber + + } + harmonicSum += this.size * maximumAmplitude.pow(0.25f) * ADDITIONAL_CONTRIBUTION + return (frequencySum / size).pow(EXPONENT) * harmonicSum +} + +/** Find a good starting point of a spectrum peak for the harmonic search. + * @param frequencyBase Base frequency. + * @param frequencyMax: Highest frequency which is considered. + * @param spectrum Frequency spectrum where we find the peak. + * @param globalMaximumIndex Global maximum index in spectrum within the valid frequency bounds. + * @param accurateSpectrumPeakFrequency Object for improving the accuracy beyond the resolution + * given in the spectrum. + * @param relativePeakThreshold A local maximum is only considered a real peak if its value is + * higher than the global maximum times this value. + * @return Harmonic of suitable peak or null if none is found. +*/ +fun findSuitableSpectrumPeak( + frequencyBase: Float, + frequencyMax: Float, + spectrum: FrequencySpectrum, + globalMaximumIndex: Int, + accurateSpectrumPeakFrequency: AccurateSpectrumPeakFrequency, + relativePeakThreshold: Float = 5e-3f +): Harmonic? { + val harmonicTolerance = 0.1f + val maxHarmonicTolerance = 0.2f + val minimumFactorOverMean = 3f + + val meanRadiusMax = max(1, (frequencyBase / (2.0 * spectrum.df)).roundToInt()) + val meanRadius = min(meanRadiusMax, 5) // TODO: check if using e.g. 10 is better, in python reference,the spectrum was not zeropadded, so df is smaller here + + val frequencyOfGlobalMax = accurateSpectrumPeakFrequency[globalMaximumIndex] + val harmonicOfGlobalMax = (frequencyOfGlobalMax / frequencyBase).roundToInt() + + val harmonicErrorMaxHarmonic + = (harmonicOfGlobalMax - frequencyOfGlobalMax / frequencyBase).absoluteValue + + if (harmonicErrorMaxHarmonic < harmonicTolerance && harmonicOfGlobalMax > 0) { + return Harmonic( + harmonicOfGlobalMax, + frequencyOfGlobalMax, + globalMaximumIndex, + spectrum.amplitudeSpectrumSquared[globalMaximumIndex] + ) + } + + val searchRadius = maxHarmonicTolerance * frequencyBase / spectrum.df + 1 + val threshold = spectrum.amplitudeSpectrumSquared[globalMaximumIndex] * relativePeakThreshold + + val maxHarmonic = ceil(globalMaximumIndex * spectrum.df * 1.2 / frequencyBase) + .toInt() + .coerceAtLeast(2) + var smallestError = 1f + var harmonicOfSmallestError: Harmonic? = null + + for (harmonic in 1 until maxHarmonic) { + val freq = frequencyBase * harmonic + if (freq > frequencyMax) + break + + val maximumIndex = findLocalMaximumIndex( + values = spectrum.amplitudeSpectrumSquared, + center = freq / spectrum.df, + searchRadius = searchRadius, + minimumFactorOverMean = minimumFactorOverMean, + meanRadius = meanRadius, + threshold = threshold + ) + + if (maximumIndex > 0) { + val freqHarmonic = accurateSpectrumPeakFrequency[maximumIndex] + val harmonicError = (freq - freqHarmonic).absoluteValue / frequencyBase + val h = Harmonic( + harmonic, + freqHarmonic, + maximumIndex, + spectrum.amplitudeSpectrumSquared[maximumIndex] + ) + + if (harmonicError < harmonicTolerance) { + return h + } else if (harmonicError < smallestError) { + smallestError = harmonicError + harmonicOfSmallestError = h + } + } + } + return if (harmonicOfGlobalMax > 0) { + Harmonic( + harmonicOfGlobalMax, + frequencyOfGlobalMax, + globalMaximumIndex, + spectrum.amplitudeSpectrumSquared[globalMaximumIndex] + ) + } else { + harmonicOfSmallestError + } +} + + /** Extract harmonics from a spectrum * * @param harmonics Harmonics class to which we add the single harmonics. - * @param frequency Fundamental frequency. This can e.g. found by autocorrelation. + * @param frequency Fundamental frequency. This can e.g. found by auto correlation. * @param frequencyMin Lowest frequency which is considered. * @param frequencyMax Highest frequency which is considered. * @param spectrum Frequency spectrum on which we find the harmonics. @@ -227,6 +430,200 @@ fun findHarmonicsFromSpectrum( } } +/** Extract harmonics from a spectrum. + * + * @param initialHarmonic Harmonic, where we start the search for further harmonics. + * @param frequencyMin Lowest frequency which is considered. + * @param frequencyMax Highest frequency which is considered. + * @param spectrum Frequency spectrum on which we find the harmonics. + * @param globalMaximumIndex Global maximum index in spectrum within the valid frequency bounds. + * @param accurateSpectrumPeakFrequency Object for improving the accuracy beyond the resolution + * given in the spectrum. + * @param harmonicTolerance Allowed tolerance for a spectrum peak being allowed to be a harmonic. + * This is given as relative value of the base frequency. So harmonicTolerance * baseFrequency + * is the frequency search radius. + * @param minimumFactorOverLocalMean We compute the mean of the search_radius range around a + * potentially found maximum (the maximum itself is excluded during the mean computation). The + * maximum is only considered as a maximum if it is higher than + * minimum_factor_over_mean * average. + * @param maxNumFail We start searching from the harmonic of the peak in the frequency searching + * for harmonics. If we don't find successive expected harmonics for the given number, we stop + * searching for more harmonics. + * @param relativePeakThreshold A local maximum is only considered a real peak if its value is + * higher than the global maximum times this value. + */ +fun Harmonics.findHarmonicsFromSpectrum2( + initialHarmonic: Harmonic, + frequencyMin: Float, + frequencyMax: Float, + spectrum: FrequencySpectrum, + globalMaximumIndex: Int, + accurateSpectrumPeakFrequency: AccurateSpectrumPeakFrequency, + harmonicTolerance: Float = 0.1f, + minimumFactorOverLocalMean: Float = 5f, + maxNumFail: Int = 2, + relativePeakThreshold: Float = 5e-3f +) { + val MEAN_RADIUS_LIMIT = 15 // consider higher value like 30, since in python reference, the spectrum was not zeropadded + clear() + + val df = spectrum.df + val ampSpecSqr = spectrum.amplitudeSpectrumSquared + val frequencyBase = initialHarmonic.frequency / initialHarmonic.harmonicNumber + + if (globalMaximumIndex < 1) + return + + addHarmonic( + initialHarmonic.harmonicNumber, + initialHarmonic.frequency, + initialHarmonic.spectrumIndex, + initialHarmonic.spectrumAmplitudeSquared + ) + + val searchRadius = ( + harmonicTolerance * initialHarmonic.spectrumIndex / initialHarmonic.harmonicNumber + 1 + ) + val meanRadiusMax = max(1, (frequencyBase / (2 * df)).roundToInt()) + val meanRadius = min(meanRadiusMax, MEAN_RADIUS_LIMIT) + + val threshold = ampSpecSqr[globalMaximumIndex] * relativePeakThreshold + val predictor = HarmonicPredictor().apply { + add(initialHarmonic.harmonicNumber, initialHarmonic.frequency) + } + + for (increment in -1 .. 1 step 2) { // this just means, do it once for -1 and once for 1 + var previouslyFoundHarmonicResult = initialHarmonic + var probableHarmonicNumber = previouslyFoundHarmonicResult.harmonicNumber + increment + var numFail = 0 + + while (numFail < maxNumFail && probableHarmonicNumber > 0) { + val freqX = predictor.predict(probableHarmonicNumber) + val centerIndexFloat = freqX / df + + val maximumIndex = findLocalMaximumIndex( + ampSpecSqr, + centerIndexFloat, + searchRadius, + minimumFactorOverLocalMean, + meanRadius, + threshold + ) + if (maximumIndex > 0 && maximumIndex != previouslyFoundHarmonicResult.spectrumIndex) { + val actualFreqX = accurateSpectrumPeakFrequency[maximumIndex] + if (actualFreqX < frequencyMin || actualFreqX > frequencyMax) + break + val lowerBound = freqX - harmonicTolerance * frequencyBase + val upperBound = freqX + harmonicTolerance * frequencyBase + + if (actualFreqX in lowerBound .. upperBound) { + addHarmonic( + probableHarmonicNumber, + actualFreqX, + maximumIndex, + ampSpecSqr[maximumIndex] + ) + predictor.add(probableHarmonicNumber, actualFreqX) + previouslyFoundHarmonicResult = this[size - 1] + numFail = 0 + } else { + ++numFail + } + } else if (freqX < frequencyMin || freqX > frequencyMax) { + break + } else { + ++numFail + } + probableHarmonicNumber += increment + } + } +} + +fun Harmonics.findBestMatchingHarmonics( + correlationBasedFrequency: CorrelationBasedFrequency, + correlation: AutoCorrelation, + frequencyMin: Float, + frequencyMax: Float, + spectrum: FrequencySpectrum, + accurateSpectrumPeakFrequency: AccurateSpectrumPeakFrequency, + harmonicTolerance: Float = 0.1f, + minimumFactorOverLocalMean: Float = 5f, + maxNumFail: Int = 2, + relativePeakThreshold: Float = 5e-3f +) { + val LOWEST_SUBHARMONIC = 6 + val HIGHEST_HIGHER_HARMONIC = 2 + // correlation at a base frequency must have at least CORRELATION_PEAK_FACTOR * initialPeak + // the value to be valid. + val CORRELATION_PEAK_FACTOR = 0.3f + + var bestHarmonics = this + bestHarmonics.clear() + var otherHarmonics = getTemporary() + + val globalMaximumIndex = findGlobalMaximumIndex( + indexBegin = ceil(frequencyMin / spectrum.df).toInt(), + indexEnd = min(spectrum.size, floor(frequencyMax / spectrum.df).toInt() + 1), + values = spectrum.amplitudeSpectrumSquared + ) + if (globalMaximumIndex <= 0) + return + + var bestRating = 0f + + val probableBaseFrequency = correlationBasedFrequency.frequency + + val subharmonic = min(LOWEST_SUBHARMONIC, ceil(probableBaseFrequency / frequencyMin).toInt() - 1) + val higherHarmonic = min(HIGHEST_HIGHER_HARMONIC, ceil(frequencyMax / probableBaseFrequency).toInt() - 1) + + for (harmonicVariant in -(subharmonic-1) .. higherHarmonic) { + val freqBase = if (harmonicVariant < 0) + probableBaseFrequency / (-harmonicVariant + 1) // i.e. / 2, / 3, ... + else + probableBaseFrequency * (harmonicVariant + 1) // i.e. * 1, * 2, ... + + val closestIndex = (1f / (freqBase * correlation.dt)).roundToInt() + val correlationInitialPeak = correlationBasedFrequency.correlationAtTimeShift + + if (closestIndex < correlation.size && + correlation[closestIndex] > CORRELATION_PEAK_FACTOR * correlationInitialPeak) { + val initialHarmonic = findSuitableSpectrumPeak( + frequencyBase = freqBase, + frequencyMax = frequencyMax, + spectrum = spectrum, + globalMaximumIndex = globalMaximumIndex, + accurateSpectrumPeakFrequency = accurateSpectrumPeakFrequency, + relativePeakThreshold = relativePeakThreshold + ) + + if (initialHarmonic != null) { + otherHarmonics.findHarmonicsFromSpectrum2( + initialHarmonic = initialHarmonic, + frequencyMin = frequencyMin, + frequencyMax = frequencyMax, + spectrum = spectrum, + globalMaximumIndex = globalMaximumIndex, + accurateSpectrumPeakFrequency = accurateSpectrumPeakFrequency, + harmonicTolerance = harmonicTolerance, + minimumFactorOverLocalMean = minimumFactorOverLocalMean, + maxNumFail = maxNumFail, + relativePeakThreshold = relativePeakThreshold + ) + + val rating = otherHarmonics.rating() + + if (rating > bestRating && !otherHarmonics.hasCommonDivisors()) { + val tmp = bestHarmonics + bestHarmonics = otherHarmonics + otherHarmonics = tmp + bestRating = rating + } + } + } + } + adopt(bestHarmonics) +} + fun computeEnergyContentOfHarmonicsInSignalRelative(harmonics: Harmonics, ampspecSqr: FloatArray, radius: Int = 1): Float { val totalEnergy = ampspecSqr.sumOf { it.toDouble() } var harmonicEnergy = 0.0 diff --git a/app/src/main/java/de/moekadu/tuner/notedetection/TimeSeries.kt b/app/src/main/java/de/moekadu/tuner/notedetection/TimeSeries.kt index 8dce2a74..4e9a5390 100644 --- a/app/src/main/java/de/moekadu/tuner/notedetection/TimeSeries.kt +++ b/app/src/main/java/de/moekadu/tuner/notedetection/TimeSeries.kt @@ -18,8 +18,19 @@ */ package de.moekadu.tuner.notedetection +/** Store a time series of data with constant time spacing. + * @param size Number of values in time series. + * @param dt Time difference between two successive samples. + */ class TimeSeries(val size: Int, val dt: Float) { + /** Frame position. */ var framePosition = 0 + /** Values of time series. */ val values = FloatArray(size) + + /** Access data of given index. + * @param index Index where to access the time series. + * @return Value of time series at given index. + */ operator fun get(index: Int) = values[index] } diff --git a/app/src/main/java/de/moekadu/tuner/tuner/Tuner.kt b/app/src/main/java/de/moekadu/tuner/tuner/Tuner.kt index 2e484c16..48cacbe2 100644 --- a/app/src/main/java/de/moekadu/tuner/tuner/Tuner.kt +++ b/app/src/main/java/de/moekadu/tuner/tuner/Tuner.kt @@ -182,11 +182,11 @@ class Tuner( val frequencyDetectionResultCollector = FrequencyDetectionResultCollector( frequencyMin = DefaultValues.FREQUENCY_MIN, frequencyMax = DefaultValues.FREQUENCY_MAX, - subharmonicsTolerance = 0.05f, - subharmonicsPeakRatio = 0.8f, - harmonicTolerance = 0.1f, - minimumFactorOverLocalMean = 2f, - maxGapBetweenHarmonics = 10, + subharmonicsTolerance = 0.1f, + subharmonicsPeakRatio = 0.75f, + harmonicTolerance = 0.11f, + minimumFactorOverLocalMean = 3f, + maxGapBetweenHarmonics = 5, maxNumHarmonicsForInharmonicity = 8, windowType = pref.windowing.value, acousticWeighting = AcousticZeroWeighting() @@ -198,7 +198,7 @@ class Tuner( frequencyDetectionResultsChannel.trySend(result) sampleData.decRef() // sampleData is not needed anymore, so we can decrement ref to allow recycling withContext(Dispatchers.Main) { - onResultAvailableListener.onFrequencyDetected(result.memory) // better send this to freqEval flow and directlya afterwards run the listener + onResultAvailableListener.onFrequencyDetected(result.memory) // better send this to freqEval flow and directly afterwards run the listener } result.decRef() } diff --git a/app/src/main/java/de/moekadu/tuner/ui/common/Label.kt b/app/src/main/java/de/moekadu/tuner/ui/common/Label.kt index 55211d5d..c70d68ff 100644 --- a/app/src/main/java/de/moekadu/tuner/ui/common/Label.kt +++ b/app/src/main/java/de/moekadu/tuner/ui/common/Label.kt @@ -21,24 +21,20 @@ package de.moekadu.tuner.ui.common import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.contentColorFor +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.moekadu.tuner.ui.theme.TunerTheme @@ -61,7 +57,7 @@ fun Label( .clip(MaterialTheme.shapes.extraSmall) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), + indication = ripple(), enabled = true, onClick = onClick ), diff --git a/app/src/test/java/de/moekadu/tuner/HarmonicPredictorTest.kt b/app/src/test/java/de/moekadu/tuner/HarmonicPredictorTest.kt new file mode 100644 index 00000000..99ba0214 --- /dev/null +++ b/app/src/test/java/de/moekadu/tuner/HarmonicPredictorTest.kt @@ -0,0 +1,54 @@ +package de.moekadu.tuner + +import de.moekadu.tuner.notedetection.HarmonicPredictor +import org.junit.Test +import org.junit.Assert.assertEquals + +class HarmonicPredictorTest { + @Test + fun testPredictor() { + val predictor = HarmonicPredictor() + + predictor.add(3, 300f) + + val f1 = predictor.predict(1) + var f2 = predictor.predict(2) + assertEquals(f1, 100f, 1e-12f) + assertEquals(f2, 200f, 1e-12f) + + predictor.add(5, 500f) + + f2 = predictor.predict(2) + assertEquals(f2, 200f, 1e-12f) + + predictor.clear() + f2 = predictor.predict(2) + assertEquals(f2, 0f) + } + + @Test + fun testPredictorNonlinear() + { + val predictor = HarmonicPredictor() + + val freqBase = 300f + val beta = 0.2f + val testFunc = { h: Int -> freqBase * h * (1 + beta * h) } + + predictor.add(3, testFunc(3)) + + val f1 = predictor.predict(1) + val f2 = predictor.predict(2) + assertEquals(f1, testFunc(3) / 3, 1e-12f) + assertEquals(f2, 2 * testFunc(3) / 3, 1e-12f) + + predictor.add(5, testFunc(5)) + + val f5 = predictor.predict(5) + assertEquals(f5, testFunc(5), 1e-12f) + + predictor.add(6, testFunc(6)) + val f3 = predictor.predict(3) + assertEquals(f3, testFunc(3), 1e-12f) + } +} \ No newline at end of file diff --git a/app/src/test/java/de/moekadu/tuner/HarmonicsTest.kt b/app/src/test/java/de/moekadu/tuner/HarmonicsTest.kt index 323632d5..89a79b7e 100644 --- a/app/src/test/java/de/moekadu/tuner/HarmonicsTest.kt +++ b/app/src/test/java/de/moekadu/tuner/HarmonicsTest.kt @@ -2,6 +2,7 @@ package de.moekadu.tuner import de.moekadu.tuner.notedetection.* import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Test import kotlin.math.floor @@ -142,6 +143,197 @@ class HarmonicsTest { findHarmonicsFromSpectrum(harmonics, frequency, frequencyMin, frequencyMax, spectrum, spectrumFrequencies, harmonicTolerance = 0.1f) assertEquals(2, harmonics.size) // we find only the second and third harmonic + } + + @Test + fun findHarmonics2() { + val size = 1000 + val harmonics = Harmonics(size) + val frequency = 10f + val frequencyMin = 1f + val frequencyMax = 90f + val df = 0.1f + val spectrum = FrequencySpectrum(size, df) + val spectrumFrequencies = AccurateSpectrumPeakFrequency(spectrum, null) // we omit the other spec, so the resulting values will be "index * df" + + // define the third harmonic + val thirdHarmonicFreq = 3 * frequency + val globMaxIdx = (thirdHarmonicFreq / df).roundToInt() + spectrum.amplitudeSpectrumSquared[globMaxIdx] = 100f + + val initialHarmonic = Harmonic( + 3, + thirdHarmonicFreq, + globMaxIdx, + spectrum.amplitudeSpectrumSquared[globMaxIdx] + ) + + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies + ) + assertEquals(1, harmonics.size) // we find only the third harmonic + assertEquals(3, harmonics[0].harmonicNumber) + assertEquals(globMaxIdx, harmonics[0].spectrumIndex) + assertEquals(100f, harmonics[0].spectrumAmplitudeSquared) + assertEquals(thirdHarmonicFreq, harmonics[0].frequency, 1e-5f * thirdHarmonicFreq) + + // define the second harmonic + val secondHarmonicFreq = 2 * frequency + val secondHarmonicIdx = (secondHarmonicFreq / df).roundToInt() + spectrum.amplitudeSpectrumSquared[secondHarmonicIdx] = 80f + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies + ) + assertEquals(2, harmonics.size) // we find the second and third harmonic + assertEquals(2, harmonics[1].harmonicNumber) + assertEquals(secondHarmonicIdx, harmonics[1].spectrumIndex) + assertEquals(80f, harmonics[1].spectrumAmplitudeSquared) + assertEquals(secondHarmonicFreq, harmonics[1].frequency, 1e-5f * secondHarmonicFreq) + + // define the first harmonic + val firstHarmonicIdx = (frequency / df).roundToInt() + spectrum.amplitudeSpectrumSquared[firstHarmonicIdx] = 80f + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies + ) + assertEquals(3, harmonics.size) // we find first, second and third harmonic + assertEquals(1, harmonics[2].harmonicNumber) + // reset first harmonic + spectrum.amplitudeSpectrumSquared[firstHarmonicIdx] = 0f + + // define the 5th harmonic + val fifthHarmonicFreq = 5 * frequency + val fifthHarmonicIdx = (fifthHarmonicFreq / df).roundToInt() + spectrum.amplitudeSpectrumSquared[fifthHarmonicIdx] = 60f + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies + ) + assertEquals(3, harmonics.size) // we find second, third and fifth harmonic + assertEquals(5, harmonics[2].harmonicNumber) + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies, + maxNumFail = 1 + ) + assertEquals(2, harmonics.size) // we find only second and third harmonic since due to the gap between 3rd and 5th + + // set the 5th harmonic slightly off + spectrum.amplitudeSpectrumSquared[fifthHarmonicIdx] = 0f + val fifthHarmonicIdxSlightlyOff = fifthHarmonicIdx + floor(0.18f * frequency / df).toInt() + spectrum.amplitudeSpectrumSquared[fifthHarmonicIdxSlightlyOff] = 60f + assertNotEquals(fifthHarmonicIdx, fifthHarmonicIdxSlightlyOff) // just make sure that they are different and we really test something slightly off + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies, + harmonicTolerance = 0.2f + ) + assertEquals(3, harmonics.size) // we find only the third harmonic + assertEquals(5, harmonics[2].harmonicNumber) + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies, + harmonicTolerance = 0.1f + ) + assertEquals(2, harmonics.size) // we find only the second and third harmonic + + // set the 5th harmonic slightly off in other direction + spectrum.amplitudeSpectrumSquared[fifthHarmonicIdxSlightlyOff] = 0f + val fifthHarmonicIdxSlightlyOff2 = fifthHarmonicIdx - floor(0.18f * frequency / df).toInt() + spectrum.amplitudeSpectrumSquared[fifthHarmonicIdxSlightlyOff2] = 60f + assertNotEquals(fifthHarmonicIdx, fifthHarmonicIdxSlightlyOff2) // just make sure that they are different and we really test something slightly off + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies, + harmonicTolerance = 0.2f + ) + assertEquals(3, harmonics.size) // we find only the third harmonic + assertEquals(5, harmonics[2].harmonicNumber) + + harmonics.findHarmonicsFromSpectrum2( + initialHarmonic, + frequencyMin, + frequencyMax, + spectrum, + findGlobalMaximumIndex(1, spectrum.size -1, spectrum.amplitudeSpectrumSquared), + spectrumFrequencies, + harmonicTolerance = 0.1f + ) + assertEquals(2, harmonics.size) // we find only the second and third harmonic + } + + + @Test + fun checkForCommonDivisorTest() { + val size = 100 + val harmonics = Harmonics(size) + harmonics.addHarmonic(2, 2f, 2, 1f) + harmonics.addHarmonic(6, 8f, 6, 3f) + harmonics.addHarmonic(8, 8f, 8, 2f) + + var hasCommonDivisor = harmonics.hasCommonDivisors() + assert(hasCommonDivisor) + + harmonics.addHarmonic(9, 9f, 9, 2f) + hasCommonDivisor = harmonics.hasCommonDivisors() + assertFalse(hasCommonDivisor) + + harmonics.clear() + harmonics.addHarmonic(9, 9f, 9, 2f) + harmonics.addHarmonic(15, 15f, 15, 2f) + hasCommonDivisor = harmonics.hasCommonDivisors() + assert(hasCommonDivisor) + + harmonics.clear() + harmonics.addHarmonic(19, 19f, 19, 2f) + assert(hasCommonDivisor) + harmonics.clear() + harmonics.addHarmonic(1, 1f, 1, 2f) + hasCommonDivisor = harmonics.hasCommonDivisors() + assertFalse(hasCommonDivisor) } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 197432de..3cb5cb94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,9 +4,9 @@ agp = "8.5.2" hiltNavigationCompose = "1.2.0" kotlin = "2.0.10" -activityKtx = "1.9.1" +activityKtx = "1.9.2" appcompat = "1.7.0" -composeBom = "2024.06.00" +composeBom = "2024.09.00" coreKtx = "1.13.1" coreTesting = "1.1.1" datastore = "1.1.1" @@ -18,9 +18,9 @@ kotlinxCollectionsImmutable = "0.3.7" kotlinxCoroutinesCore = "1.7.3" kotlinxSerializationJson = "1.6.3" ksp = "2.0.10-1.0.24" # https://github.com/google/ksp/releases -lifecycle = "2.8.4" +lifecycle = "2.8.5" material = "1.12.0" -navigationCompose = "2.8.0-beta06" +navigationCompose = "2.8.0" preferenceKtx = "1.2.1" runtimeTracing = "1.0.0-beta01"