/*
  ==============================================================================

   This file is part of the JUCE library.
   Copyright (c) 2017 - ROLI Ltd.

   JUCE is an open source library subject to commercial or open-source
   licensing.

   By using JUCE, you agree to the terms of both the JUCE 5 End-User License
   Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
   27th April 2017).

   End User License Agreement: www.juce.com/juce-5-licence
   Privacy Policy: www.juce.com/juce-5-privacy-policy

   Or: You may also use this code under the terms of the GPL v3 (see
   www.gnu.org/licenses).

   JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
   EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
   DISCLAIMED.

  ==============================================================================
*/

#include "../JuceDemoHeader.h"
#include "AudioLiveScrollingDisplay.h"


class LatencyTester  : public AudioIODeviceCallback,
                       private Timer
{
public:
    LatencyTester (TextEditor& resultsBox_)
        : playingSampleNum (0),
          recordedSampleNum (-1),
          sampleRate (0),
          testIsRunning (false),
          resultsBox (resultsBox_)
    {
        MainAppWindow::getSharedAudioDeviceManager().addAudioCallback (this);
    }

    ~LatencyTester()
    {
        MainAppWindow::getSharedAudioDeviceManager().removeAudioCallback (this);
    }

    //==============================================================================
    void beginTest()
    {
        resultsBox.moveCaretToEnd();
        resultsBox.insertTextAtCaret (newLine + newLine + "Starting test..." + newLine);
        resultsBox.moveCaretToEnd();

        startTimer (50);

        const ScopedLock sl (lock);
        createTestSound();
        recordedSound.clear();
        playingSampleNum = recordedSampleNum = 0;
        testIsRunning = true;
    }

    void timerCallback()
    {
        if (testIsRunning && recordedSampleNum >= recordedSound.getNumSamples())
        {
            testIsRunning = false;
            stopTimer();

            // Test has finished, so calculate the result..
            const int latencySamples = calculateLatencySamples();

            resultsBox.moveCaretToEnd();
            resultsBox.insertTextAtCaret (getMessageDescribingResult (latencySamples));
            resultsBox.moveCaretToEnd();
        }
    }

    String getMessageDescribingResult (int latencySamples)
    {
        String message;

        if (latencySamples >= 0)
        {
            message << newLine
                    << "Results:" << newLine
                    << latencySamples << " samples (" << String (latencySamples * 1000.0 / sampleRate, 1)
                    << " milliseconds)" << newLine
                    << "The audio device reports an input latency of "
                    << deviceInputLatency << " samples, output latency of "
                    << deviceOutputLatency << " samples." << newLine
                    << "So the corrected latency = "
                    << (latencySamples - deviceInputLatency - deviceOutputLatency)
                    << " samples (" << String ((latencySamples - deviceInputLatency - deviceOutputLatency) * 1000.0 / sampleRate, 2)
                    << " milliseconds)";
        }
        else
        {
            message << newLine
                    << "Couldn't detect the test signal!!" << newLine
                    << "Make sure there's no background noise that might be confusing it..";
        }

        return message;
    }

    //==============================================================================
    void audioDeviceAboutToStart (AudioIODevice* device)
    {
        testIsRunning = false;
        playingSampleNum = recordedSampleNum = 0;

        sampleRate = device->getCurrentSampleRate();
        deviceInputLatency = device->getInputLatencyInSamples();
        deviceOutputLatency = device->getOutputLatencyInSamples();

        recordedSound.setSize (1, (int) (0.9 * sampleRate));
        recordedSound.clear();
    }

    void audioDeviceStopped()
    {
        // (nothing to do here)
    }

    void audioDeviceIOCallback (const float** inputChannelData,
                                int numInputChannels,
                                float** outputChannelData,
                                int numOutputChannels,
                                int numSamples)
    {
        const ScopedLock sl (lock);

        if (testIsRunning)
        {
            float* const recordingBuffer = recordedSound.getWritePointer (0);
            const float* const playBuffer = testSound.getReadPointer (0);

            for (int i = 0; i < numSamples; ++i)
            {
                if (recordedSampleNum < recordedSound.getNumSamples())
                {
                    float inputSamp = 0;
                    for (int j = numInputChannels; --j >= 0;)
                        if (inputChannelData[j] != 0)
                            inputSamp += inputChannelData[j][i];

                    recordingBuffer [recordedSampleNum] = inputSamp;
                }

                ++recordedSampleNum;

                float outputSamp = (playingSampleNum < testSound.getNumSamples()) ? playBuffer [playingSampleNum] : 0;

                for (int j = numOutputChannels; --j >= 0;)
                    if (outputChannelData[j] != 0)
                        outputChannelData[j][i] = outputSamp;

                ++playingSampleNum;
            }
        }
        else
        {
            // We need to clear the output buffers, in case they're full of junk..
            for (int i = 0; i < numOutputChannels; ++i)
                if (outputChannelData[i] != 0)
                    zeromem (outputChannelData[i], sizeof (float) * (size_t) numSamples);
        }
    }

private:
    AudioSampleBuffer testSound, recordedSound;
    Array<int> spikePositions;
    int playingSampleNum, recordedSampleNum;
    CriticalSection lock;
    double sampleRate;
    bool testIsRunning;
    TextEditor& resultsBox;
    int deviceInputLatency, deviceOutputLatency;

    // create a test sound which consists of a series of randomly-spaced audio spikes..
    void createTestSound()
    {
        const int length = ((int) sampleRate) / 4;
        testSound.setSize (1, length);
        testSound.clear();

        Random rand;

        for (int i = 0; i < length; ++i)
            testSound.setSample (0, i, (rand.nextFloat() - rand.nextFloat() + rand.nextFloat() - rand.nextFloat()) * 0.06f);

        spikePositions.clear();

        int spikePos = 0;
        int spikeDelta = 50;

        while (spikePos < length - 1)
        {
            spikePositions.add (spikePos);

            testSound.setSample (0, spikePos,      0.99f);
            testSound.setSample (0, spikePos + 1, -0.99f);

            spikePos += spikeDelta;
            spikeDelta += spikeDelta / 6 + rand.nextInt (5);
        }
    }

    // Searches a buffer for a set of spikes that matches those in the test sound
    int findOffsetOfSpikes (const AudioSampleBuffer& buffer) const
    {
        const float minSpikeLevel = 5.0f;
        const double smooth = 0.975;
        const float* s = buffer.getReadPointer (0);
        const int spikeDriftAllowed = 5;

        Array<int> spikesFound;
        spikesFound.ensureStorageAllocated (100);
        double runningAverage = 0;
        int lastSpike = 0;

        for (int i = 0; i < buffer.getNumSamples() - 10; ++i)
        {
            const float samp = std::abs (s[i]);

            if (samp > runningAverage * minSpikeLevel && i > lastSpike + 20)
            {
                lastSpike = i;
                spikesFound.add (i);
            }

            runningAverage = runningAverage * smooth + (1.0 - smooth) * samp;
        }

        int bestMatch = -1;
        int bestNumMatches = spikePositions.size() / 3; // the minimum number of matches required

        if (spikesFound.size() < bestNumMatches)
            return -1;

        for (int offsetToTest = 0; offsetToTest < buffer.getNumSamples() - 2048; ++offsetToTest)
        {
            int numMatchesHere = 0;
            int foundIndex = 0;

            for (int refIndex = 0; refIndex < spikePositions.size(); ++refIndex)
            {
                const int referenceSpike = spikePositions.getUnchecked (refIndex) + offsetToTest;
                int spike = 0;

                while ((spike = spikesFound.getUnchecked (foundIndex)) < referenceSpike - spikeDriftAllowed
                         && foundIndex < spikesFound.size() - 1)
                    ++foundIndex;

                if (spike >= referenceSpike - spikeDriftAllowed && spike <= referenceSpike + spikeDriftAllowed)
                    ++numMatchesHere;
            }

            if (numMatchesHere > bestNumMatches)
            {
                bestNumMatches = numMatchesHere;
                bestMatch = offsetToTest;

                if (numMatchesHere == spikePositions.size())
                    break;
            }
        }

        return bestMatch;
    }

    int calculateLatencySamples() const
    {
        // Detect the sound in both our test sound and the recording of it, and measure the difference
        // in their start times..
        const int referenceStart = findOffsetOfSpikes (testSound);
        jassert (referenceStart >= 0);

        const int recordedStart = findOffsetOfSpikes (recordedSound);

        return (recordedStart < 0) ? -1
                                   : (recordedStart - referenceStart);
    }

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (LatencyTester)
};

//==============================================================================
class AudioLatencyDemo  : public Component,
                          private Button::Listener
{
public:
    AudioLatencyDemo()
    {
        setOpaque (true);

        addAndMakeVisible (liveAudioScroller = new LiveScrollingAudioDisplay());

        addAndMakeVisible (resultsBox);
        resultsBox.setMultiLine (true);
        resultsBox.setReturnKeyStartsNewLine (true);
        resultsBox.setReadOnly (true);
        resultsBox.setScrollbarsShown (true);
        resultsBox.setCaretVisible (false);
        resultsBox.setPopupMenuEnabled (true);

        resultsBox.setColour (TextEditor::outlineColourId, Colour (0x1c000000));
        resultsBox.setColour (TextEditor::shadowColourId, Colour (0x16000000));

        resultsBox.setText ("Running this test measures the round-trip latency between the audio output and input "
                            "devices you\'ve got selected.\n\n"
                            "It\'ll play a sound, then try to measure the time at which the sound arrives "
                            "back at the audio input. Obviously for this to work you need to have your "
                            "microphone somewhere near your speakers...");

        addAndMakeVisible (startTestButton);
        startTestButton.addListener (this);
        startTestButton.setButtonText ("Test Latency");

        MainAppWindow::getSharedAudioDeviceManager().addAudioCallback (liveAudioScroller);
    }

    ~AudioLatencyDemo()
    {
        MainAppWindow::getSharedAudioDeviceManager().removeAudioCallback (liveAudioScroller);
        startTestButton.removeListener (this);
        latencyTester = nullptr;
        liveAudioScroller = nullptr;
    }

    void startTest()
    {
        if (latencyTester == nullptr)
            latencyTester = new LatencyTester (resultsBox);

        latencyTester->beginTest();
    }

    void paint (Graphics& g) override
    {
        g.fillAll (getUIColourIfAvailable (LookAndFeel_V4::ColourScheme::UIColour::windowBackground));
    }

    void resized() override
    {
        liveAudioScroller->setBounds (8, 8, getWidth() - 16, 64);
        startTestButton.setBounds (8, getHeight() - 41, 168, 32);
        resultsBox.setBounds (8, 88, getWidth() - 16, getHeight() - 137);
    }

private:
    ScopedPointer<LatencyTester> latencyTester;

    ScopedPointer<LiveScrollingAudioDisplay> liveAudioScroller;
    TextButton startTestButton;
    TextEditor resultsBox;

    void buttonClicked (Button* buttonThatWasClicked) override
    {
        if (buttonThatWasClicked == &startTestButton)
            startTest();
    }

    void lookAndFeelChanged() override
    {
        resultsBox.setColour (TextEditor::backgroundColourId,
                              getUIColourIfAvailable (LookAndFeel_V4::ColourScheme::UIColour::widgetBackground,
                                                      Colour (0x32ffffff)));
    }

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioLatencyDemo)
};


// This static object will register this demo type in a global list of demos..
static JuceDemoType<AudioLatencyDemo> demo ("31 Audio: Latency Detector");
