/* ============================================================================== 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 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 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::backgroundColourId, getUIColourIfAvailable (LookAndFeel_V4::ColourScheme::UIColour::widgetBackground, Colour (0x32ffffff))); 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; ScopedPointer liveAudioScroller; TextButton startTestButton; TextEditor resultsBox; void buttonClicked (Button* buttonThatWasClicked) override { if (buttonThatWasClicked == &startTestButton) startTest(); } JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioLatencyDemo) }; // This static object will register this demo type in a global list of demos.. static JuceDemoType demo ("31 Audio: Latency Detector");