diff --git a/.github/workflows/cmake-linux.yml b/.github/workflows/cmake-linux.yml
index 3c83a2a3d..2e1533ecd 100644
--- a/.github/workflows/cmake-linux.yml
+++ b/.github/workflows/cmake-linux.yml
@@ -15,33 +15,64 @@ jobs:
     # well on Windows or Mac.  You can convert this to a matrix build if you need
     # cross-platform coverage.
     # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v4
 
     - name: Install packages
       shell: bash
       run: |
            sudo apt-get update
-           sudo apt-get install codespell libpulse-dev libspeexdsp-dev libsamplerate0-dev sox git libwxgtk3.0-gtk3-dev portaudio19-dev libhamlib-dev libasound2-dev libao-dev libgsm1-dev libsndfile-dev python3-numpy
+           sudo apt-get upgrade -y
+           sudo apt-get install codespell libpulse-dev libspeexdsp-dev libsamplerate0-dev sox git libwxgtk3.2-dev portaudio19-dev libhamlib-dev libasound2-dev libao-dev libgsm1-dev libsndfile-dev xvfb pipewire pulseaudio-utils pipewire-pulse wireplumber metacity dbus-x11 at-spi2-core rtkit
             
     - name: Spellcheck codebase
       shell: bash
-      run: codespell --ignore-words-list=radae,rade,inout,nin,ontop,parm,tthe,ue `find src -name '*.c*' -o -name '*.h' -o -name '*.mm'`
+      run: codespell --ignore-words-list=caf,radae,rade,inout,nin,ontop,parm,tthe,ue `find src -name '*.c*' -o -name '*.h' -o -name '*.mm'`
+
+    - name: Install Python required modules
+      shell: bash
+      working-directory: ${{github.workspace}}
+      run: |
+          python3 -m venv rade-venv
+          . ./rade-venv/bin/activate
+          pip3 install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
+          pip3 install matplotlib
 
     - name: Build freedv-gui using PortAudio
       shell: bash
       working-directory: ${{github.workspace}}
-      run: UT_ENABLE=1 ./build_linux.sh portaudio
+      run: |
+          . ./rade-venv/bin/activate
+          UT_ENABLE=1 ./build_linux.sh portaudio
 
     - name: Build freedv-gui using PulseAudio
       shell: bash
       working-directory: ${{github.workspace}}
-      run: UT_ENABLE=1 ./build_linux.sh pulseaudio
+      run: |
+          . ./rade-venv/bin/activate
+          UT_ENABLE=1 ./build_linux.sh pulseaudio
 
     - name: Execute unit tests
       shell: bash
       working-directory: ${{github.workspace}}/build_linux
-      run: make test
+      run: |
+          sudo systemctl enable rtkit-daemon
+          sudo systemctl start rtkit-daemon
+          Xvfb :99 -screen 0 1024x768x16 &
+          sleep 5
+          export DISPLAY=:99.0
+          export XDG_RUNTIME_DIR=/run/user/$(id -u)
+          mkdir -p $XDG_RUNTIME_DIR
+          chmod 700 $XDG_RUNTIME_DIR
+          eval "$(dbus-launch --sh-syntax --exit-with-x11)"
+          pipewire &
+          pipewire-pulse &
+          wireplumber &
+          metacity --sm-disable --replace &
+          sleep 5
+          ln -s ${{github.workspace}}/build_linux/rade_src/model19_check3 model19_check3
+          . ../rade-venv/bin/activate
+          PYTHONPATH=${{github.workspace}}/build_linux/rade_src:$PYTHONPATH ctest -V
       
diff --git a/.github/workflows/cmake-macos.yml b/.github/workflows/cmake-macos.yml
index 94366c8d9..fd1d41374 100644
--- a/.github/workflows/cmake-macos.yml
+++ b/.github/workflows/cmake-macos.yml
@@ -18,20 +18,31 @@ jobs:
     runs-on: macos-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v4
 
     - name: Install packages
       shell: bash
       working-directory: ${{github.workspace}}
-      run: brew install automake libtool numpy
+      run: brew install automake libtool numpy sox
+
+    - name: Install virtual audio devices
+      shell: bash
+      working-directory: ${{github.workspace}}
+      run: ./build_macos_sound_drivers.sh
 
     - name: Build freedv-gui
       shell: bash
       working-directory: ${{github.workspace}}
       run: UT_ENABLE=1 ./build_osx.sh 
 
+    - name: Workaround macOS permission issues
+      run: |
+          sqlite3 $HOME/Library/Application\ Support/com.apple.TCC/TCC.db "INSERT OR IGNORE INTO access VALUES ('kTCCServiceMicrophone','/usr/local/opt/runner/provisioner/provisioner',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,0,1687786159,NULL,NULL,'UNUSED',1687786159);"
+          sqlite3 $HOME/Library/Application\ Support/com.apple.TCC/TCC.db "INSERT OR IGNORE INTO access VALUES ('kTCCServiceMicrophone','/opt/off/opt/runner/provisioner/provisioner',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,0,1687786159,NULL,NULL,'UNUSED',1687786159);"
+
     - name: Execute unit tests
       shell: bash
       working-directory: ${{github.workspace}}/build_osx
-      run: make test
-      
+      run: |
+          FREEDV_COMPUTER_TO_RADIO_DEVICE="BlackHoleRadio 2ch" FREEDV_RADIO_TO_COMPUTER_DEVICE="BlackHoleRadio 2ch 2" FREEDV_COMPUTER_TO_SPEAKER_DEVICE="BlackHole1 2ch" FREEDV_MICROPHONE_TO_COMPUTER_DEVICE="BlackHole2 2ch" ctest -V
+           
diff --git a/.github/workflows/cmake-windows.yml b/.github/workflows/cmake-windows.yml
index ef993ec03..1b3ebb971 100644
--- a/.github/workflows/cmake-windows.yml
+++ b/.github/workflows/cmake-windows.yml
@@ -18,7 +18,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v4
 
     - name: Install WINE
       run: |
@@ -48,6 +48,7 @@ jobs:
           export WINEPREFIX=`pwd`/wine-env
           wget https://www.python.org/ftp/python/3.12.7/python-3.12.7-amd64.exe
           Xvfb :99 -screen 0 1024x768x16 &
+          sleep 10
           export DISPLAY=:99.0
           wine ./python-3.12.7-amd64.exe /quiet /log c:\\python.log InstallAllUsers=1 Include_doc=0 Include_tcltk=0 || :
           cat $WINEPREFIX/drive_c/python.log
@@ -76,3 +77,105 @@ jobs:
       run: |
         export PATH=${{github.workspace}}/llvm-mingw-20230320-ucrt-ubuntu-18.04-x86_64/bin:$PATH
         make -j6 package
+
+    - name: Rename installer
+      shell: bash
+      working-directory: ${{github.workspace}}/build_windows
+      run: |
+        mv FreeDV*.exe FreeDV.exe
+    
+    - name: Stash for next step
+      uses: actions/upload-artifact@v4
+      with:
+        name: FreeDVSetupProgram
+        path: ${{github.workspace}}/build_windows/FreeDV.exe
+
+  test:
+    runs-on: windows-latest
+    needs: build
+    env:
+      RADIO_TO_COMPUTER_DEVICE: "CABLE Output (VB-Audio Virtual Cable)"
+      COMPUTER_TO_RADIO_DEVICE: "Speakers (VB-Audio Virtual Cable)"
+      MICROPHONE_TO_COMPUTER_DEVICE: "Line 1 (Virtual Audio Cable)" 
+      COMPUTER_TO_SPEAKER_DEVICE: "Line 1 (Virtual Audio Cable)" 
+    steps: 
+    - uses: actions/checkout@v4
+    - uses: actions/download-artifact@v4
+      with:
+        name: FreeDVSetupProgram
+        path: ${{github.workspace}}
+  
+    - uses: ilammy/msvc-dev-cmd@v1
+ 
+    - name: Install FreeDV on hard drive
+      shell: pwsh
+      run: |
+          .\FreeDV.exe /S /D=${{github.workspace}}\FreeDV-Install-Location | Out-Null
+  
+    - name: Copy test script to install folder
+      shell: pwsh
+      run: |
+          Copy-Item -Path ${{github.workspace}}/test/TestFreeDVFullDuplex.ps1 -Destination ${{github.workspace}}\FreeDV-Install-Location\bin
+          Copy-Item -Path ${{github.workspace}}/test/freedv-ctest-fullduplex.conf.tmpl -Destination ${{github.workspace}}\FreeDV-Install-Location\bin
+  
+    - name: Install VB-Cable ("Radio" sound device)
+      uses: LABSN/sound-ci-helpers@v1
+  
+    - run: 'Invoke-WebRequest https://software.muzychenko.net/trials/vac464.zip -OutFile vac464.zip'
+    - run: 'Expand-Archive -Path vac464.zip -DestinationPath vac464'
+    - run: 'Import-Certificate -FilePath ${{github.workspace}}\test\vac464.cer -CertStoreLocation Cert:\LocalMachine\root'
+    - run: 'Import-Certificate -FilePath ${{github.workspace}}\test\vac464.cer -CertStoreLocation Cert:\LocalMachine\TrustedPublisher'
+    - name: Install driver
+      shell: pwsh
+      run: |
+          .\vac464\setup64.exe -s -k 30570681-0a8b-46e5-8cb2-d835f43af0c5 | Out-Null
+          Start-Sleep -Seconds 10
+      # For convenience, make sure we fail fast if for whatever reason the install gets blocked on some GUI prompt.
+      timeout-minutes: 5
+
+    - name: Grant FreeDV access to the microphone 
+      run: |
+          Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone" -Name Value -Value Allow
+          Set-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone" -Name Value -Value Allow
+          New-Item -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone\" -Name "NonPackaged" -Force
+          Set-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone\NonPackaged" -Name Value -Value Allow
+          New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\" -Name "AppPrivacy" -Force
+          Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy" -Name LetAppsAccessMicrophone -Value 0
+
+    - name: Start Windows Audio Service
+      run: |
+          net start audiosrv
+
+    - name: List audio devices
+      shell: pwsh
+      run: |
+          Get-CimInstance win32_sounddevice
+
+    - name: Test RADE
+      shell: pwsh
+      working-directory: ${{github.workspace}}\FreeDV-Install-Location\bin
+      run: |
+          .\TestFreeDVFullDuplex.ps1 -RadioToComputerDevice "${{env.RADIO_TO_COMPUTER_DEVICE}}" -ComputerToRadioDevice "${{env.COMPUTER_TO_RADIO_DEVICE}}" -MicrophoneToComputerDevice "${{env.MICROPHONE_TO_COMPUTER_DEVICE}}" -ComputerToSpeakerDevice "${{env.COMPUTER_TO_SPEAKER_DEVICE}}" -ModeToTest RADE -NumberOfRuns 1
+      timeout-minutes: 5
+
+    - name: Test 700D
+      shell: pwsh
+      working-directory: ${{github.workspace}}\FreeDV-Install-Location\bin
+      run: |
+          .\TestFreeDVFullDuplex.ps1 -RadioToComputerDevice "${{env.RADIO_TO_COMPUTER_DEVICE}}" -ComputerToRadioDevice "${{env.COMPUTER_TO_RADIO_DEVICE}}" -MicrophoneToComputerDevice "${{env.MICROPHONE_TO_COMPUTER_DEVICE}}" -ComputerToSpeakerDevice "${{env.COMPUTER_TO_SPEAKER_DEVICE}}" -ModeToTest 700D -NumberOfRuns 1
+      timeout-minutes: 5
+
+    - name: Test 700E
+      shell: pwsh
+      working-directory: ${{github.workspace}}\FreeDV-Install-Location\bin
+      run: |
+          .\TestFreeDVFullDuplex.ps1 -RadioToComputerDevice "${{env.RADIO_TO_COMPUTER_DEVICE}}" -ComputerToRadioDevice "${{env.COMPUTER_TO_RADIO_DEVICE}}" -MicrophoneToComputerDevice "${{env.MICROPHONE_TO_COMPUTER_DEVICE}}" -ComputerToSpeakerDevice "${{env.COMPUTER_TO_SPEAKER_DEVICE}}" -ModeToTest 700E -NumberOfRuns 1
+      timeout-minutes: 5
+
+    - name: Test 1600
+      shell: pwsh
+      working-directory: ${{github.workspace}}\FreeDV-Install-Location\bin
+      run: |
+          .\TestFreeDVFullDuplex.ps1 -RadioToComputerDevice "${{env.RADIO_TO_COMPUTER_DEVICE}}" -ComputerToRadioDevice "${{env.COMPUTER_TO_RADIO_DEVICE}}" -MicrophoneToComputerDevice "${{env.MICROPHONE_TO_COMPUTER_DEVICE}}" -ComputerToSpeakerDevice "${{env.COMPUTER_TO_SPEAKER_DEVICE}}" -ModeToTest 1600 -NumberOfRuns 1
+      timeout-minutes: 5
+ 
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 042cacb24..64d81252b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -719,3 +719,17 @@ elseif(UNIX AND NOT APPLE)
     include(CPack)
 
 endif(WIN32)
+
+if(UNITTEST)
+# The below tests are currently Linux-only due to a dependency on
+# PulseAudio/pipewire.
+macro(DefineAudioTest utName)
+    add_test(NAME fullduplex_${utName} COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/test/test_zeros.sh txrx ${utName}) 
+    set_tests_properties(fullduplex_${utName} PROPERTIES PASS_REGULAR_EXPRESSION "Got 1 sync changes")
+endmacro()
+
+DefineAudioTest(RADE)
+DefineAudioTest(700D)
+DefineAudioTest(700E)
+DefineAudioTest(1600)
+endif(UNITTEST)
diff --git a/build_macos_sound_drivers.sh b/build_macos_sound_drivers.sh
new file mode 100755
index 000000000..c93b8edf0
--- /dev/null
+++ b/build_macos_sound_drivers.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+
+git clone https://github.com/tmiw/BlackHole.git
+cd BlackHole
+
+bundleID=audio.existential.BlackHoleRadio
+driverName=BlackHoleRadio
+
+xcodebuild \
+    -project BlackHole.xcodeproj \
+    -configuration Release \
+    -target BlackHole \
+    CONFIGURATION_BUILD_DIR=build \
+    PRODUCT_BUNDLE_IDENTIFIER=$bundleID \
+    GCC_PREPROCESSOR_DEFINITIONS="$GCC_PREPROCESSOR_DEFINITIONS \
+        kNumber_Of_Channels='2' \
+        kPlugIn_BundleID='\"$bundleID\"' \
+        kDriver_Name='\"$driverName\"' \
+        kDevice2_IsHidden=false \
+        kDevice2_HasInput=true \
+        kDevice2_HasOutput=true" \
+    MACOSX_DEPLOYMENT_TARGET=10.13
+
+sudo mv build/BlackHole.driver /Library/Audio/Plug-Ins/HAL/$driverName.driver
+
+for i in {1..2}; do
+    git reset --hard
+    rm -rf build
+
+    export bundleID=audio.existential.BlackHole$i
+    export driverName=BlackHole$i
+
+    xcodebuild \
+        -project BlackHole.xcodeproj \
+        -configuration Release \
+        -target BlackHole \
+        CONFIGURATION_BUILD_DIR=build \
+        PRODUCT_BUNDLE_IDENTIFIER=$bundleID \
+        GCC_PREPROCESSOR_DEFINITIONS="$GCC_PREPROCESSOR_DEFINITIONS \
+            kNumber_Of_Channels='2' \
+            kPlugIn_BundleID='\"$bundleID\"' \
+            kDriver_Name='\"$driverName\"' \
+            kDevice2_IsHidden=false \
+            kDevice2_HasInput=true \
+            kDevice2_HasOutput=true" \
+        MACOSX_DEPLOYMENT_TARGET=10.13
+
+    sudo mv build/BlackHole.driver /Library/Audio/Plug-Ins/HAL/$driverName.driver
+done
+
+sudo killall -9 coreaudiod
diff --git a/cmake/BuildRADE.cmake b/cmake/BuildRADE.cmake
index dfb698630..3fcdf13bf 100644
--- a/cmake/BuildRADE.cmake
+++ b/cmake/BuildRADE.cmake
@@ -13,7 +13,7 @@ ExternalProject_Add(build_rade
    SOURCE_DIR rade_src
    BINARY_DIR rade_build
    GIT_REPOSITORY https://github.com/drowe67/radae.git
-   GIT_TAG dr-reset
+   GIT_TAG main
    CMAKE_ARGS ${RADE_CMAKE_ARGS}
    #CMAKE_CACHE_ARGS -DCMAKE_OSX_DEPLOYMENT_TARGET:STRING=${CMAKE_OSX_DEPLOYMENT_TARGET}
    INSTALL_COMMAND ""
diff --git a/cmake/Buildportaudio-2.0.cmake b/cmake/Buildportaudio-2.0.cmake
index 3c32191b1..788d7806e 100644
--- a/cmake/Buildportaudio-2.0.cmake
+++ b/cmake/Buildportaudio-2.0.cmake
@@ -1,4 +1,5 @@
 #set(BUILD_SHARED_LIBS OFF CACHE STRING "Disable shared libraries for portaudio")
+#set(PA_ENABLE_DEBUG_OUTPUT ON CACHE STRING "Enable debug output")
 
 include(FetchContent)
 FetchContent_Declare(
diff --git a/src/audio/PulseAudioDevice.cpp b/src/audio/PulseAudioDevice.cpp
index 942fd7f32..eb649fdc3 100644
--- a/src/audio/PulseAudioDevice.cpp
+++ b/src/audio/PulseAudioDevice.cpp
@@ -145,6 +145,7 @@ void PulseAudioDevice::start()
         outputPendingLength_ = 0;
         targetOutputPendingLength_ = PULSE_FPB * getNumChannels() * 2;
         outputPendingThreadActive_ = true;
+#if 0
         if (direction_ == IAudioEngine::AUDIO_ENGINE_OUT)
         {
             outputPendingThread_ = new std::thread([&]() {
@@ -200,6 +201,7 @@ void PulseAudioDevice::start()
             });
             assert(outputPendingThread_ != nullptr);
         }
+#endif
     }
 
     pa_threaded_mainloop_unlock(mainloop_);
@@ -268,6 +270,7 @@ void PulseAudioDevice::StreamWriteCallback_(pa_stream *s, size_t length, void *u
         memset(data, 0, sizeof(data));
 
         PulseAudioDevice* thisObj = static_cast<PulseAudioDevice*>(userdata);
+#if 0
         {
             std::unique_lock<std::mutex> lk(thisObj->outputPendingMutex_);
             if (thisObj->outputPendingLength_ >= numSamples)
@@ -286,7 +289,12 @@ void PulseAudioDevice::StreamWriteCallback_(pa_stream *s, size_t length, void *u
 
             thisObj->targetOutputPendingLength_ = std::max(thisObj->targetOutputPendingLength_, 2 * numSamples);
         }
+#endif
 
+        if (thisObj->onAudioDataFunction)
+        {
+            thisObj->onAudioDataFunction(*thisObj, data, numSamples / thisObj->getNumChannels(), thisObj->onAudioDataState);
+        } 
         pa_stream_write(s, &data[0], length, NULL, 0LL, PA_SEEK_RELATIVE);
     }
 }
diff --git a/src/freedv_interface.cpp b/src/freedv_interface.cpp
index 06312a210..b89fb8c35 100644
--- a/src/freedv_interface.cpp
+++ b/src/freedv_interface.cpp
@@ -72,7 +72,8 @@ FreeDVInterface::FreeDVInterface() :
     lastSyncRxMode_(nullptr),
     rade_(nullptr),
     lpcnetEncState_(nullptr),
-    radeTxStep_(nullptr)
+    radeTxStep_(nullptr),
+    sync_(0)
 {
     // empty
 }
@@ -125,6 +126,7 @@ float FreeDVInterface::GetMinimumSNR_(int mode)
 
 void FreeDVInterface::start(int txMode, int fifoSizeMs, bool singleRxThread, bool usingReliableText)
 {
+    sync_ = 0;
     singleRxThread_ = singleRxThread;
 
     modemStatsList_ = new MODEM_STATS[enabledModes_.size()];
@@ -426,13 +428,7 @@ void FreeDVInterface::setSync(int val)
 
 int FreeDVInterface::getSync() const
 {
-    // Special case for RADE.
-    if (currentRxMode_ == nullptr)
-    {
-        return rade_sync(rade_);
-    }
-
-    return freedv_get_sync(currentRxMode_);
+    return sync_;
 }
 
 void FreeDVInterface::setEq(int val)
@@ -676,7 +672,7 @@ IPipelineStep* FreeDVInterface::createTransmitPipeline(int inputSampleRate, int
     std::function<int(ParallelStep*)> modeFn = 
         [&](ParallelStep*) {
             int index = 0;
-
+            
             // Special handling for RADE.
             if (txMode_ >= FREEDV_MODE_RADE) return 0;
 
@@ -879,5 +875,7 @@ int FreeDVInterface::postProcessRxFn_(ParallelStep* stepObj)
         *state->getRxStateFn() = rade_sync(rade_);
     }
 
+    sync_ = *state->getRxStateFn();
+
     return indexWithSync;
 };
diff --git a/src/freedv_interface.h b/src/freedv_interface.h
index 648a95fbd..1d7f2d57e 100644
--- a/src/freedv_interface.h
+++ b/src/freedv_interface.h
@@ -191,7 +191,8 @@ class FreeDVInterface
     FARGANState fargan_;
     LPCNetEncState *lpcnetEncState_; 
     RADETransmitStep *radeTxStep_;
-
+    int sync_;
+    
     int preProcessRxFn_(ParallelStep* ps);
     int postProcessRxFn_(ParallelStep* ps);
 };
diff --git a/src/main.cpp b/src/main.cpp
index bdbaa7530..fecf59640 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -31,6 +31,7 @@
 #include <climits>
 #include <wx/cmdline.h>
 #include <wx/stdpaths.h>
+#include <wx/uiaction.h>
 
 #include "version.h"
 #include "main.h"
@@ -49,6 +50,8 @@
 
 #include "rade_api.h"
 
+using namespace std::chrono_literals;
+
 #define wxUSE_FILEDLG   1
 #define wxUSE_LIBPNG    1
 #define wxUSE_LIBJPEG   1
@@ -184,15 +187,174 @@ FILE *ftest;
 // Config file management 
 wxConfigBase *pConfig = NULL;
 
+// Unit test management
+wxString testName;
+wxString utFreeDVMode;
+
 // WxWidgets - initialize the application
 
 IMPLEMENT_APP(MainApp);
 
+void MainApp::UnitTest_()
+{
+    // List audio devices
+    auto engine = AudioEngineFactory::GetAudioEngine();
+    engine->start();
+    for (auto& dev : engine->getAudioDeviceList(IAudioEngine::AUDIO_ENGINE_IN))
+    {
+        fprintf(stderr, "Input audio device: %s (ID %d, sample rate %d, valid channels: %d-%d)\n", (const char*)dev.name.ToUTF8(), dev.deviceId,  dev.defaultSampleRate, dev.minChannels, dev.maxChannels);
+    }
+    for (auto& dev : engine->getAudioDeviceList(IAudioEngine::AUDIO_ENGINE_OUT))
+    {
+        fprintf(stderr, "Output audio device: %s (ID %d, sample rate %d, valid channels: %d-%d)\n", (const char*)dev.name.ToUTF8(), dev.deviceId,  dev.defaultSampleRate, dev.minChannels, dev.maxChannels);
+    }
+    engine->stop();
+
+    // Bring window to the front
+    CallAfter([&]() {
+        frame->Iconize(false);
+        frame->SetFocus();
+        frame->Raise();
+        frame->Show(true);
+    });
+    
+    // Wait 100ms for FreeDV to come to foreground
+    std::this_thread::sleep_for(100ms);
+
+    // Select FreeDV mode. Note, 2020 is deprecated so not testable here.
+    wxRadioButton* modeBtn = nullptr;
+    if (utFreeDVMode == "RADE")
+    {
+        modeBtn = frame->m_rbRADE;
+    }
+    /*else if (utFreeDVMode == "700C")
+    {
+        modeBtn = frame->m_rb700c;
+    }*/
+    else if (utFreeDVMode == "700D")
+    {
+        modeBtn = frame->m_rb700d;
+    }
+    else if (utFreeDVMode == "700E")
+    {
+        modeBtn = frame->m_rb700e;
+    }
+    /*else if (utFreeDVMode == "800XA")
+    {
+        modeBtn = frame->m_rb800xa;
+    }*/
+    else if (utFreeDVMode == "1600")
+    {
+        modeBtn = frame->m_rb1600;
+    }
+    
+    if (modeBtn != nullptr)
+    {
+        fprintf(stderr, "Firing mode change\n");
+        /*sim.MouseMove(modeBtn->GetScreenPosition());
+        sim.MouseClick();*/
+        CallAfter([&]() {
+            modeBtn->SetValue(true);
+            wxCommandEvent* modeEvent = new wxCommandEvent(wxEVT_RADIOBUTTON, modeBtn->GetId());
+            modeEvent->SetEventObject(modeBtn);
+            QueueEvent(modeEvent);
+        });
+    }
+    
+    // Fire event to start FreeDV
+    fprintf(stderr, "Firing start\n");
+    CallAfter([&]() {
+        frame->m_togBtnOnOff->SetValue(true);
+        wxCommandEvent* onEvent = new wxCommandEvent(wxEVT_COMMAND_TOGGLEBUTTON_CLICKED, frame->m_togBtnOnOff->GetId());
+        onEvent->SetEventObject(frame->m_togBtnOnOff);
+        frame->OnTogBtnOnOff(*onEvent);
+        delete onEvent;
+        //QueueEvent(onEvent);
+    });
+    /*sim.MouseMove(frame->m_togBtnOnOff->GetScreenPosition());
+    sim.MouseClick();*/
+    
+    // Wait 5 seconds for FreeDV to start
+    std::this_thread::sleep_for(5s);
+    
+    if (testName == "tx")
+    {        
+        // Fire event to begin TX
+        //sim.MouseMove(frame->m_btnTogPTT->GetScreenPosition());
+        //sim.MouseClick();
+        fprintf(stderr, "Firing PTT\n");
+        CallAfter([&]() {
+            frame->m_btnTogPTT->SetValue(true);
+            wxCommandEvent* txEvent = new wxCommandEvent(wxEVT_COMMAND_TOGGLEBUTTON_CLICKED, frame->m_btnTogPTT->GetId());
+            txEvent->SetEventObject(frame->m_btnTogPTT);
+            //QueueEvent(txEvent);
+            frame->OnTogBtnPTT(*txEvent);
+            delete txEvent;
+        });
+        
+        // Transmit for 60 seconds
+        std::this_thread::sleep_for(60s);
+        
+        // Stop transmitting
+        fprintf(stderr, "Firing PTT\n");
+        CallAfter([&]() {
+            frame->m_btnTogPTT->SetValue(false);
+            wxCommandEvent* rxEvent = new wxCommandEvent(wxEVT_COMMAND_TOGGLEBUTTON_CLICKED, frame->m_btnTogPTT->GetId());
+            rxEvent->SetEventObject(frame->m_btnTogPTT);
+            frame->OnTogBtnPTT(*rxEvent);
+            delete rxEvent;
+            //QueueEvent(rxEvent);
+        });
+        /*sim.MouseMove(frame->m_btnTogPTT->GetScreenPosition());
+        sim.MouseClick();*/
+        
+        // Wait 5 seconds for FreeDV to stop
+        std::this_thread::sleep_for(5s);
+    }
+    else
+    {
+        // Receive for 60 seconds
+        auto sync = 0;
+        for (int i = 0; i < 60*10; i++)
+        {
+            std::this_thread::sleep_for(100ms);
+            auto newSync = freedvInterface.getSync();
+            if (newSync != sync)
+            {
+                fprintf(stderr, "Sync changed from %d to %d\n", sync, newSync);
+                sync = newSync;
+            }
+        } 
+    }
+ 
+    // Fire event to stop FreeDV
+    fprintf(stderr, "Firing stop\n");
+    CallAfter([&]() {
+        wxCommandEvent* offEvent = new wxCommandEvent(wxEVT_COMMAND_TOGGLEBUTTON_CLICKED, frame->m_togBtnOnOff->GetId());
+        offEvent->SetEventObject(frame->m_togBtnOnOff);
+        frame->m_togBtnOnOff->SetValue(false);
+        //QueueEvent(offEvent);
+        frame->OnTogBtnOnOff(*offEvent);
+        delete offEvent;
+    });
+    //sim.MouseMove(frame->m_togBtnOnOff->GetScreenPosition());
+    //sim.MouseClick();
+    
+    // Wait 5 seconds for FreeDV to stop
+    std::this_thread::sleep_for(5s);
+    
+    // Destroy main window to exit application. Must be done in UI thread to avoid problems.
+    CallAfter([&]() {
+        frame->Destroy();
+    });
+}
 
 void MainApp::OnInitCmdLine(wxCmdLineParser& parser)
 {
     wxApp::OnInitCmdLine(parser);
     parser.AddOption("f", "config", "Use different configuration file instead of the default.");
+    parser.AddOption("ut", "unit_test", "Execute FreeDV in unit test mode.");
+    parser.AddOption("utmode", wxEmptyString, "Switch FreeDV to the given mode before UT execution.");
 }
 
 bool MainApp::OnCmdLineParsed(wxCmdLineParser& parser)
@@ -216,6 +378,15 @@ bool MainApp::OnCmdLineParsed(wxCmdLineParser& parser)
     }
     pConfig->SetRecordDefaults();
     
+    if (parser.Found("ut", &testName))
+    {
+        fprintf(stderr, "Executing test %s\n", (const char*)testName.ToUTF8());
+        if (parser.Found("utmode", &utFreeDVMode))
+        {
+            fprintf(stderr, "Using mode %s for tests\n", (const char*)utFreeDVMode.ToUTF8());
+        }
+    }
+    
     return true;
 }
 
@@ -298,8 +469,15 @@ bool MainApp::OnInit()
     frame->m_auiNbookCtrl->ChangeSelection(0);
     frame->Layout();    
     frame->Show();
-    g_parent =frame;
+    g_parent = frame;
 
+    // Begin test execution
+    if (testName != "")
+    {
+        std::thread utThread(std::bind(&MainApp::UnitTest_, this));
+        utThread.detach();
+    }
+    
     return true;
 }
 
@@ -2794,15 +2972,15 @@ void MainFrame::startRxStream()
         // loop.
 
         int m_fifoSize_ms = wxGetApp().appConfiguration.fifoSizeMs;
-        int soundCard1InFifoSizeSamples = m_fifoSize_ms*wxGetApp().appConfiguration.audioConfiguration.soundCard1In.sampleRate/1000;
-        int soundCard1OutFifoSizeSamples = m_fifoSize_ms*wxGetApp().appConfiguration.audioConfiguration.soundCard1Out.sampleRate/1000;
+        int soundCard1InFifoSizeSamples = wxGetApp().appConfiguration.audioConfiguration.soundCard1In.sampleRate;
+        int soundCard1OutFifoSizeSamples = wxGetApp().appConfiguration.audioConfiguration.soundCard1Out.sampleRate;
         g_rxUserdata->infifo1 = codec2_fifo_create(soundCard1InFifoSizeSamples);
         g_rxUserdata->outfifo1 = codec2_fifo_create(soundCard1OutFifoSizeSamples);
 
         if (txInSoundDevice && txOutSoundDevice)
         {
-            int soundCard2InFifoSizeSamples = m_fifoSize_ms*wxGetApp().appConfiguration.audioConfiguration.soundCard2In.sampleRate/1000;
-            int soundCard2OutFifoSizeSamples = m_fifoSize_ms*wxGetApp().appConfiguration.audioConfiguration.soundCard2Out.sampleRate/1000;
+            int soundCard2InFifoSizeSamples = m_fifoSize_ms*wxGetApp().appConfiguration.audioConfiguration.soundCard2In.sampleRate / 1000;
+            int soundCard2OutFifoSizeSamples = m_fifoSize_ms*wxGetApp().appConfiguration.audioConfiguration.soundCard2Out.sampleRate / 1000;
             g_rxUserdata->outfifo2 = codec2_fifo_create(soundCard2OutFifoSizeSamples);
             g_rxUserdata->infifo2 = codec2_fifo_create(soundCard2InFifoSizeSamples);
         
@@ -2885,15 +3063,18 @@ void MainFrame::startRxStream()
         // Set sound card callbacks
         auto errorCallback = [&](IAudioDevice&, std::string error, void*)
         {
-            CallAfter([&, error]() {
+            fprintf(stderr, "AUDIO ERROR: %s\n", error.c_str());
+            /*CallAfter([&, error]() {
                 wxMessageBox(wxString::Format("Error encountered while processing audio: %s", error), wxT("Error"), wxOK);
-            });
+            });*/
         };
 
         rxInSoundDevice->setOnAudioData([&](IAudioDevice& dev, void* data, size_t size, void* state) {
             paCallBackData* cbData = static_cast<paCallBackData*>(state);
             short* audioData = static_cast<short*>(data);
             short  indata[size];
+
+            //fprintf(stderr, "recoded %d samples\n", size);
             for (size_t i = 0; i < size; i++, audioData += dev.getNumChannels())
             {
                 indata[i] = audioData[0];
@@ -2901,6 +3082,7 @@ void MainFrame::startRxStream()
             
             if (codec2_fifo_write(cbData->infifo1, indata, size)) 
             {
+                fprintf(stderr, "RX FIFO full\n");
                 g_infifo1_full++;
             }
 
diff --git a/src/main.h b/src/main.h
index f445600b4..df5eb0285 100644
--- a/src/main.h
+++ b/src/main.h
@@ -225,6 +225,8 @@ class MainApp : public wxApp
 
         int m_reportCounter;
     protected:
+    private:
+        void UnitTest_();
 };
 
 // declare global static function wxGetApp()
@@ -442,6 +444,8 @@ class MainFrame : public TopFrame
         void OnSetMonitorTxAudioVol( wxCommandEvent& event );
         
     private:
+        friend class MainApp; // needed for unit tests
+        
         std::shared_ptr<IAudioDevice> rxInSoundDevice;
         std::shared_ptr<IAudioDevice> rxOutSoundDevice;
         std::shared_ptr<IAudioDevice> txInSoundDevice;
diff --git a/src/pipeline/TxRxThread.cpp b/src/pipeline/TxRxThread.cpp
index 36390e138..c89c2b10d 100644
--- a/src/pipeline/TxRxThread.cpp
+++ b/src/pipeline/TxRxThread.cpp
@@ -20,6 +20,9 @@
 //
 //=========================================================================
 
+#include <chrono>
+using namespace std::chrono_literals;
+
 // This forces us to use freedv-gui's version rather than another one.
 // TBD -- may not be needed once we fully switch over to the audio pipeline.
 #include "../defines.h"
@@ -478,6 +481,7 @@ void* TxRxThread::Entry()
         pthread_setname_np(pthread_self(), threadName);
 #endif // defined(__linux__)
 
+#if 0
         {
             std::unique_lock<std::mutex> lk(m_processingMutex);
             if (m_processingCondVar.wait_for(lk, std::chrono::milliseconds(100)) == std::cv_status::timeout)
@@ -485,9 +489,15 @@ void* TxRxThread::Entry()
                 fprintf(stderr, "txRxThread: timeout while waiting for CV, tx = %d\n", m_tx);
             }
         }
+#endif
+
+        auto currentTime = std::chrono::steady_clock::now();
+
         if (!m_run) break;
         if (m_tx) txProcessing_();
         else rxProcessing_();
+
+        std::this_thread::sleep_until(currentTime + 20ms);
     }
     
     // Force pipeline to delete itself when we're done with the thread.
@@ -509,8 +519,10 @@ void TxRxThread::terminateThread()
 
 void TxRxThread::notify()
 {
+#if 0
     std::unique_lock<std::mutex> lk(m_processingMutex);
     m_processingCondVar.notify_all();
+#endif
 }
 
 void TxRxThread::clearFifos_()
diff --git a/src/topFrame.h b/src/topFrame.h
index 48f583996..d78123f28 100644
--- a/src/topFrame.h
+++ b/src/topFrame.h
@@ -82,9 +82,7 @@ class wxListViewComboPopup;
 /// Class TopFrame
 ///////////////////////////////////////////////////////////////////////////////
 class TopFrame : public wxFrame
-{
-    private:
-        
+{        
     protected:
         wxPanel* m_panel;
         wxMenuBar* m_menubarMain;
diff --git a/test/TestFreeDVFullDuplex.ps1 b/test/TestFreeDVFullDuplex.ps1
new file mode 100644
index 000000000..dd3fd962e
--- /dev/null
+++ b/test/TestFreeDVFullDuplex.ps1
@@ -0,0 +1,138 @@
+<#
+  .SYNOPSIS
+  Executes full-duplex test of FreeDV.
+
+  .DESCRIPTION
+  This script starts FreeDV in full-duplex mode for approximately 60 seconds using an autogenerated configuration file
+  that will access the audio devices passed in. After 60 seconds, FreeDV will terminate and this script will examine
+  the output to determine the number of times that it went into and out of sync. If it detects that FreeDV went out
+  of sync during the test, the test is marked as having failed.
+
+  .INPUTS
+  None. You can't pipe objects to this script.
+
+  .OUTPUTS
+  The script outputs the mode tested as well as the number of passed/failed tests to the console.
+
+  .EXAMPLE
+  PS> .\TestFreeDVFullDuplex.ps1 -ModeToTest RADE -RadioToComputerDevice "Microphone (USB Audio CODEC)" -ComputerToRadioDevice "Speakers (USB Audio CODEC)" -ComputerToSpeakerDevice "Speakers (Realtek High Definition Audio(SST))" -MicrophoneToComputerDevice "Microphone Array (Realtek High Definition Audio(SST))"
+
+#>
+
+param (
+    [Parameter(Mandatory = $true)]
+    [ValidateNotNullOrEmpty()]
+    [string] 
+    # The sound device to receive RX audio from.
+    $RadioToComputerDevice, 
+
+    [Parameter(Mandatory = $true)]
+    [ValidateNotNullOrEmpty()]
+    [string] 
+    # The sound device to emit decoded audio to.
+    $ComputerToSpeakerDevice, 
+
+    [Parameter(Mandatory = $true)]
+    [ValidateNotNullOrEmpty()]
+    [string] 
+    # The sound device to receive analog audio from.
+    $MicrophoneToComputerDevice, 
+
+    [Parameter(Mandatory = $true)]
+    [ValidateNotNullOrEmpty()]
+    [string] 
+    # The sound device to emit TX audio to.
+    $ComputerToRadioDevice,
+    
+    [ValidateSet("RADE", "700D", "700E", "1600")]
+    [ValidateNotNullOrEmpty()]
+    [string]
+    # The FreeDV mode to use for testing.
+    $ModeToTest="RADE", 
+
+    [int] 
+    # The number of times to execute the test.
+    $NumberOfRuns=10)
+
+
+<#
+    .Description
+    Performs the actual test with FreeDV by generating the needed configuration file, starting FreeDV and then examining the output.
+#>
+function Test-FreeDV {
+    param (
+        $ModeToTest,
+        $RadioToComputerDevice,
+        $ComputerToSpeakerDevice,
+        $MicrophoneToComputerDevice,
+        $ComputerToRadioDevice
+    )
+
+    $current_loc = Get-Location
+
+    # Generate new conf 
+    $conf_tmpl = Get-Content "$current_loc\freedv-ctest-fullduplex.conf.tmpl"
+    $conf_tmpl = $conf_tmpl.Replace("@FREEDV_RADIO_TO_COMPUTER_DEVICE@", $RadioToComputerDevice)
+    $conf_tmpl = $conf_tmpl.Replace("@FREEDV_COMPUTER_TO_RADIO_DEVICE@", $ComputerToRadioDevice)
+    $conf_tmpl = $conf_tmpl.Replace("@FREEDV_MICROPHONE_TO_COMPUTER_DEVICE@", $MicrophoneToComputerDevice)
+    $conf_tmpl = $conf_tmpl.Replace("@FREEDV_COMPUTER_TO_SPEAKER_DEVICE@", $ComputerToSpeakerDevice)
+    $tmp_file = New-TemporaryFile
+    $conf_tmpl | Set-Content -Path $tmp_file.FullName
+
+    # Start freedv.exe
+    $psi = New-Object System.Diagnostics.ProcessStartInfo
+    $psi.CreateNoWindow = $true
+    $psi.UseShellExecute = $false
+    $psi.RedirectStandardError = $true
+    $psi.RedirectStandardOutput = $true
+    $psi.FileName = "$current_loc\freedv.exe"
+    $psi.WorkingDirectory = $current_loc
+    $quoted_tmp_filename = "`"" + $tmp_file.FullName + "`""
+    $psi.Arguments = @("/f $quoted_tmp_filename /ut txrx /utmode $ModeToTest")
+
+    $process = New-Object System.Diagnostics.Process
+    $process.StartInfo = $psi
+    [void]$process.Start()
+
+    # Read output from process
+    $err_output = $process.StandardError.ReadToEnd();
+    $output = $process.StandardOutput.ReadToEnd();
+    $process.WaitForExit()
+
+    Write-Host "$err_output"
+
+    $syncs = $err_output.Split([Environment]::NewLine) | Where { $_.Contains("Sync changed") }
+    if ($syncs.Count -eq 1) {
+        return $true
+    }
+    return $false
+}
+
+$passes = 0
+$fails = 0
+
+for (($i = 0); $i -lt $NumberOfRuns; $i++)
+{
+    $result = Test-FreeDV `
+        -ModeToTest $ModeToTest `
+        -RadioToComputerDevice $RadioToComputerDevice `
+        -ComputerToSpeakerDevice $ComputerToSpeakerDevice `
+        -MicrophoneToComputerDevice $MicrophoneToComputerDevice `
+        -ComputerToRadioDevice $ComputerToRadioDevice
+    if ($result -eq $true)
+    {
+        $passes++
+    }
+    else
+    {
+        $fails++
+    }
+}
+
+Write-Host "Mode: $ModeToTest, Total Runs: $NumberOfRuns, Passed: $passes, Failures: $fails"
+
+if ($fails -gt 0) {
+    throw "Test failed"
+    exit 1
+}
+
diff --git a/test/check-for-zeros.py b/test/check-for-zeros.py
new file mode 100644
index 000000000..cad78c369
--- /dev/null
+++ b/test/check-for-zeros.py
@@ -0,0 +1,30 @@
+#!/usr/bin/python3
+
+import sys
+import struct
+
+index = 0
+detected = False
+detected_index = -1
+first_detected_index = -1
+with open(sys.argv[1], "rb") as f:
+    while True:
+        bytes_to_read = struct.calcsize("h")
+        buffer = f.read(bytes_to_read)
+        if len(buffer) != bytes_to_read:
+            break
+
+        if buffer[0] == 0:
+            if first_detected_index == -1:
+                first_detected_index = index
+                detected = True
+            detected_index = index
+        elif index == (detected_index + 1):
+            if (index - first_detected_index) > 1:
+                sys.stderr.write(f"Zero audio detected at index {index} ({index / 8000} seconds)")
+                sys.stderr.write(f" - lasted {(index - first_detected_index) / 8000} seconds\n")
+            first_detected_index = -1
+        index = index + 1
+
+if detected is False:
+    print("No zeros found.")
diff --git a/test/freedv-ctest-fullduplex.conf.tmpl b/test/freedv-ctest-fullduplex.conf.tmpl
new file mode 100644
index 000000000..8b38ad70d
--- /dev/null
+++ b/test/freedv-ctest-fullduplex.conf.tmpl
@@ -0,0 +1,189 @@
+FirstTimeUse=0
+ExperimentalFeatures=0
+[Audio]
+soundCard1SampleRate=-1
+soundCard2SampleRate=-1
+soundCard1InDeviceName=@FREEDV_RADIO_TO_COMPUTER_DEVICE@
+soundCard1InSampleRate=48000
+soundCard1OutDeviceName=@FREEDV_COMPUTER_TO_RADIO_DEVICE@
+soundCard1OutSampleRate=48000
+soundCard2InDeviceName=@FREEDV_MICROPHONE_TO_COMPUTER_DEVICE@
+soundCard2InSampleRate=48000
+soundCard2OutDeviceName=@FREEDV_COMPUTER_TO_SPEAKER_DEVICE@
+soundCard2OutSampleRate=48000
+SquelchActive=1
+SquelchLevel=-4
+fifoSize_ms=440
+transmitLevel=0
+snrSlow=0
+mode=4
+TxRxDelayMilliseconds=0
+[Filter]
+codec2LPCPostFilterGamma=50
+codec2LPCPostFilterBeta=20
+MicInBassFreqHz=100
+MicInBassGaindB=0
+MicInTrebleFreqHz=3000
+MicInTrebleGaindB=0
+MicInMidFreqHz=1500
+MicInMidGaindB=0
+MicInMidQ=100
+MicInVolInDB=0
+SpkOutBassFreqHz=100
+SpkOutBassGaindB=0
+SpkOutTrebleFreqHz=3000
+SpkOutTrebleGaindB=0
+SpkOutMidFreqHz=1500
+SpkOutMidGaindB=0
+SpkOutMidQ=100
+SpkOutVolInDB=0
+codec2LPCPostFilterEnable=1
+codec2LPCPostFilterBassBoost=1
+speexpp_enable=1
+700C_EQ=1
+[Filter/MicIn]
+EQEnable=0
+BassFreqHz=100
+BassGaindB=0
+TrebleFreqHz=3000
+TrebleGaindB=0
+MidFreqHz=1500
+MidGaindB=0
+MidQ=1
+VolInDB=0
+[Filter/SpkOut]
+EQEnable=0
+BassFreqHz=100
+BassGaindB=0
+TrebleFreqHz=3000
+TrebleGaindB=0
+MidFreqHz=1500
+MidGaindB=0
+MidQ=1
+VolInDB=0
+[Filter/codec2LPCPostFilter]
+Gamma=50
+Beta=20
+[Hamlib]
+UseForPTT=0
+EnableFreqModeChanges=1
+UseAnalogModes=0
+IcomCIVHex=0
+RigNameStr=ADAT www.adat.ch ADT-200A
+PttType=0
+SerialRate=0
+SerialPort=
+PttSerialPort=
+RigName=0
+[Rig]
+UseSerialPTT=0
+Port=
+UseRTS=1
+RTSPolarity=1
+UseDTR=0
+DTRPolarity=0
+UseSerialPTTInput=0
+PttInPort=
+CTSPolarity=0
+leftChannelVoxTone=0
+EnableSpacebarForPTT=1
+HalfDuplex=0
+MultipleRx=1
+SingleRxThread=0
+[PSKReporter]
+Enable=0
+Callsign=
+GridSquare=
+FrequencyHzStr=0
+[Data]
+CallSign=
+[Reporting]
+Enable=0
+Callsign=
+GridSquare=
+FrequencyAsKHz=0
+FrequencyList=1.997000,3.625000,3.643000,3.693000,3.697000,3.850000,5.403500,5.366500,5.368500,7.177000,7.197000,14.236000,14.240000,18.118000,21.313000,24.933000,28.330000,28.720000,10489.640000
+ManualFrequencyReporting=0
+DirectionAsCardinal=0
+Frequency=0
+[Reporting/PSKReporter]
+Enable=1
+[Reporting/FreeDV]
+Enable=1
+Hostname=qso.freedv.org
+CurrentBandFilter=0
+UseMetricDistances=1
+BandFilterTracksFrequency=0
+ForceReceiveOnly=0
+StatusText=
+RecentStatusTexts=
+TxRowBackgroundColor=#FC4500
+TxRowForegroundColor=#000000
+RxRowBackgroundColor=#379BAF
+RxRowForegroundColor=#000000
+MsgRowBackgroundColor=#E58BE5
+MsgRowForegroundColor=#000000
+[Reporting/FreeDV/BandFilterTracking]
+TracksFreqBand=1
+TracksExactFreq=0
+[CallsignList]
+UseUTCTime=0
+[FreeDV2020]
+Allowed=0
+[MainFrame]
+left=26
+top=23
+width=800
+height=780
+rxNbookCtrl=0
+TabLayout=
+[Windows]
+[Windows/AudioConfig]
+left=26
+top=23
+width=1148
+height=732
+[Windows/FreeDVReporter]
+left=20
+top=20
+width=-1
+height=-1
+visible=0
+currentSort=-1
+currentSortDirection=1
+reportingUserMsgColWidth=130
+[File]
+playFileToMicInPath=
+recFileFromRadioPath=
+recFileFromRadioSecs=60
+recFileFromModulatorPath=
+recFileFromModulatorSecs=60
+playFileFromRadioPath=
+[VoiceKeyer]
+WaveFilePath=/home/mooneer/Documents
+WaveFile=
+RxPause=10
+Repeats=5
+[FreeDV700]
+txClip=1
+txBPF=1
+[Noise]
+noise_snr=2
+[Debug]
+console=0
+verbose=0
+APIverbose=0
+[Waterfall]
+Color=0
+[Stats]
+ResetTime=10
+[Plot]
+[Plot/Spectrum]
+CurrentAveraging=0
+[Monitor]
+VoiceKeyerAudio=0
+TransmitAudio=0
+VoiceKeyerAudioVol=0
+TransmitAudioVol=0
+[QuickRecord]
+SavePath=/home/mooneer/Documents
diff --git a/test/freedv-ctest.conf.tmpl b/test/freedv-ctest.conf.tmpl
new file mode 100644
index 000000000..c12fe1d96
--- /dev/null
+++ b/test/freedv-ctest.conf.tmpl
@@ -0,0 +1,189 @@
+FirstTimeUse=0
+ExperimentalFeatures=0
+[Audio]
+soundCard1SampleRate=-1
+soundCard2SampleRate=-1
+soundCard1InDeviceName=@FREEDV_RADIO_TO_COMPUTER_DEVICE@
+soundCard1InSampleRate=48000
+soundCard1OutDeviceName=@FREEDV_COMPUTER_TO_RADIO_DEVICE@
+soundCard1OutSampleRate=48000
+soundCard2InDeviceName=@FREEDV_MICROPHONE_TO_COMPUTER_DEVICE@
+soundCard2InSampleRate=48000
+soundCard2OutDeviceName=@FREEDV_COMPUTER_TO_SPEAKER_DEVICE@
+soundCard2OutSampleRate=48000
+SquelchActive=1
+SquelchLevel=-4
+fifoSize_ms=440
+transmitLevel=0
+snrSlow=0
+mode=257
+TxRxDelayMilliseconds=0
+[Filter]
+codec2LPCPostFilterGamma=50
+codec2LPCPostFilterBeta=20
+MicInBassFreqHz=100
+MicInBassGaindB=0
+MicInTrebleFreqHz=3000
+MicInTrebleGaindB=0
+MicInMidFreqHz=1500
+MicInMidGaindB=0
+MicInMidQ=100
+MicInVolInDB=0
+SpkOutBassFreqHz=100
+SpkOutBassGaindB=0
+SpkOutTrebleFreqHz=3000
+SpkOutTrebleGaindB=0
+SpkOutMidFreqHz=1500
+SpkOutMidGaindB=0
+SpkOutMidQ=100
+SpkOutVolInDB=0
+codec2LPCPostFilterEnable=1
+codec2LPCPostFilterBassBoost=1
+speexpp_enable=1
+700C_EQ=1
+[Filter/MicIn]
+EQEnable=0
+BassFreqHz=100
+BassGaindB=0
+TrebleFreqHz=3000
+TrebleGaindB=0
+MidFreqHz=1500
+MidGaindB=0
+MidQ=1
+VolInDB=0
+[Filter/SpkOut]
+EQEnable=0
+BassFreqHz=100
+BassGaindB=0
+TrebleFreqHz=3000
+TrebleGaindB=0
+MidFreqHz=1500
+MidGaindB=0
+MidQ=1
+VolInDB=0
+[Filter/codec2LPCPostFilter]
+Gamma=50
+Beta=20
+[Hamlib]
+UseForPTT=0
+EnableFreqModeChanges=1
+UseAnalogModes=0
+IcomCIVHex=0
+RigNameStr=ADAT www.adat.ch ADT-200A
+PttType=0
+SerialRate=0
+SerialPort=
+PttSerialPort=
+RigName=0
+[Rig]
+UseSerialPTT=0
+Port=
+UseRTS=1
+RTSPolarity=1
+UseDTR=0
+DTRPolarity=0
+UseSerialPTTInput=0
+PttInPort=
+CTSPolarity=0
+leftChannelVoxTone=0
+EnableSpacebarForPTT=1
+HalfDuplex=1
+MultipleRx=1
+SingleRxThread=1
+[PSKReporter]
+Enable=0
+Callsign=
+GridSquare=
+FrequencyHzStr=0
+[Data]
+CallSign=
+[Reporting]
+Enable=0
+Callsign=
+GridSquare=
+FrequencyAsKHz=0
+FrequencyList=1.9970,3.6250,3.6430,3.6930,3.6970,3.8500,5.4035,5.3665,5.3685,7.1770,7.1970,14.2360,14.2400,18.1180,21.3130,24.9330,28.3300,28.7200,10489.6400
+ManualFrequencyReporting=0
+DirectionAsCardinal=0
+Frequency=0
+[Reporting/PSKReporter]
+Enable=1
+[Reporting/FreeDV]
+Enable=1
+Hostname=qso.freedv.org
+CurrentBandFilter=0
+UseMetricDistances=1
+BandFilterTracksFrequency=0
+ForceReceiveOnly=0
+StatusText=
+RecentStatusTexts=
+TxRowBackgroundColor=#fc4500
+TxRowForegroundColor=#000000
+RxRowBackgroundColor=#379baf
+RxRowForegroundColor=#000000
+MsgRowBackgroundColor=#E58BE5
+MsgRowForegroundColor=#000000
+[Reporting/FreeDV/BandFilterTracking]
+TracksFreqBand=1
+TracksExactFreq=0
+[CallsignList]
+UseUTCTime=0
+[FreeDV2020]
+Allowed=0
+[MainFrame]
+left=26
+top=23
+width=800
+height=780
+rxNbookCtrl=0
+TabLayout=
+[Windows]
+[Windows/AudioConfig]
+left=26
+top=23
+width=918
+height=739
+[Windows/FreeDVReporter]
+left=20
+top=20
+width=-1
+height=-1
+visible=0
+currentSort=-1
+currentSortDirection=1
+reportingUserMsgColWidth=130
+[File]
+playFileToMicInPath=
+recFileFromRadioPath=
+recFileFromRadioSecs=60
+recFileFromModulatorPath=
+recFileFromModulatorSecs=60
+playFileFromRadioPath=
+[VoiceKeyer]
+WaveFilePath=/home/mooneer/Documents
+WaveFile=voicekeyer.wav
+RxPause=10
+Repeats=5
+[FreeDV700]
+txClip=1
+txBPF=1
+[Noise]
+noise_snr=2
+[Debug]
+console=0
+verbose=0
+APIverbose=0
+[Waterfall]
+Color=0
+[Stats]
+ResetTime=10
+[Plot]
+[Plot/Spectrum]
+CurrentAveraging=0
+[Monitor]
+VoiceKeyerAudio=0
+TransmitAudio=0
+VoiceKeyerAudioVol=0
+TransmitAudioVol=0
+[QuickRecord]
+SavePath=/home/mooneer/Documents
diff --git a/test/test_zeros.sh b/test/test_zeros.sh
new file mode 100755
index 000000000..69797e83d
--- /dev/null
+++ b/test/test_zeros.sh
@@ -0,0 +1,123 @@
+#!/bin/bash
+
+FREEDV_TEST=$1
+FREEDV_MODE=$2
+FREEDV_RX_FILE=$3
+
+# Determine sox driver to use for recording/playback
+OPERATING_SYSTEM=`uname`
+SOX_DRIVER=alsa
+FREEDV_BINARY=src/freedv
+if [ "$OPERATING_SYSTEM" == "Darwin" ]; then
+    SOX_DRIVER=coreaudio
+    FREEDV_BINARY=src/FreeDV.app/Contents/MacOS/freedv
+fi
+
+createVirtualAudioCable () {
+    CABLE_NAME=$1
+    pactl load-module module-null-sink sink_name=$CABLE_NAME sink_properties=device.description=$CABLE_NAME
+}
+
+FREEDV_RADIO_TO_COMPUTER_DEVICE="${FREEDV_RADIO_TO_COMPUTER_DEVICE:-FreeDV_Radio_To_Computer}"
+FREEDV_COMPUTER_TO_SPEAKER_DEVICE="${FREEDV_COMPUTER_TO_SPEAKER_DEVICE:-FreeDV_Computer_To_Speaker}"
+FREEDV_MICROPHONE_TO_COMPUTER_DEVICE="${FREEDV_MICROPHONE_TO_COMPUTER_DEVICE:-FreeDV_Microphone_To_Computer}"
+FREEDV_COMPUTER_TO_RADIO_DEVICE="${FREEDV_COMPUTER_TO_RADIO_DEVICE:-FreeDV_Computer_To_Radio}"
+
+# Automated script to help find audio dropouts.
+# NOTE: this must be run from "build_linux". Also assumes PulseAudio/pipewire.
+if [ "$OPERATING_SYSTEM" == "Linux" ]; then
+    DRIVER_INDEX_FREEDV_RADIO_TO_COMPUTER=$(createVirtualAudioCable FreeDV_Radio_To_Computer)
+    DRIVER_INDEX_FREEDV_COMPUTER_TO_SPEAKER=$(createVirtualAudioCable FreeDV_Computer_To_Speaker)
+    DRIVER_INDEX_FREEDV_MICROPHONE_TO_COMPUTER=$(createVirtualAudioCable FreeDV_Microphone_To_Computer)
+    DRIVER_INDEX_FREEDV_COMPUTER_TO_RADIO=$(createVirtualAudioCable FreeDV_Computer_To_Radio)
+    DRIVER_INDEX_LOOPBACK=`pactl load-module module-loopback source="FreeDV_Computer_To_Radio.monitor" sink="FreeDV_Radio_To_Computer"`
+fi
+
+# For debugging--list sink info
+#pactl list sinks
+# If full duplex test, use correct config file and assume "rx" mode.  
+FREEDV_CONF_FILE=freedv-ctest.conf 
+if [ "$FREEDV_TEST" == "txrx" ]; then 
+    FREEDV_TEST=rx 
+    FREEDV_CONF_FILE=freedv-ctest-fullduplex.conf
+    REC_DEVICE="$FREEDV_COMPUTER_TO_SPEAKER_DEVICE"
+
+    # Generate sine wave for input
+    if [ "$OPERATING_SYSTEM" == "Linux" ]; then
+        sox -r 48000 -n -b 16 -c 1 -t wav - synth 120 sin 1000 vol -10dB | paplay -d "$FREEDV_MICROPHONE_TO_COMPUTER_DEVICE" &
+    else
+        sox -r 48000 -n -b 16 -c 1 -t $SOX_DRIVER "$FREEDV_MICROPHONE_TO_COMPUTER_DEVICE" - synth 120 sin 1000 vol -10dB &
+    fi
+    PLAY_PID=$!
+elif [ "$FREEDV_TEST" == "rx" ]; then
+    # Start playback if RX
+    if [ "$OPERATING_SYSTEM" == "Linux" ]; then
+        paplay -d "$FREEDV_RADIO_TO_COMPUTER_DEVICE" $FREEDV_RX_FILE &
+    else
+        sox $FREEDV_RX_FILE -t $SOX_DRIVER "$FREEDV_RADIO_TO_COMPUTER_DEVICE" &
+    fi
+    PLAY_PID=$!
+    REC_DEVICE="$FREEDV_COMPUTER_TO_SPEAKER_DEVICE.monitor"
+else
+    REC_DEVICE="$FREEDV_COMPUTER_TO_RADIO_DEVICE.monitor"
+fi
+
+# Generate config file
+SCRIPTPATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
+if [ "$FREEDV_RADIO_TO_COMPUTER_DEVICE" == "FreeDV_Radio_To_Computer" ] && [ "$OPERATING_SYSTEM" == "Linux" ]; then
+    sed "s/@FREEDV_RADIO_TO_COMPUTER_DEVICE@/$FREEDV_RADIO_TO_COMPUTER_DEVICE.monitor/g" $SCRIPTPATH/$FREEDV_CONF_FILE.tmpl > $(pwd)/$FREEDV_CONF_FILE
+else
+    sed "s/@FREEDV_RADIO_TO_COMPUTER_DEVICE@/$FREEDV_RADIO_TO_COMPUTER_DEVICE/g" $SCRIPTPATH/$FREEDV_CONF_FILE.tmpl > $(pwd)/$FREEDV_CONF_FILE
+fi
+
+sed "s/@FREEDV_COMPUTER_TO_RADIO_DEVICE@/$FREEDV_COMPUTER_TO_RADIO_DEVICE/g" $(pwd)/$FREEDV_CONF_FILE > $(pwd)/$FREEDV_CONF_FILE.tmp
+mv $(pwd)/$FREEDV_CONF_FILE.tmp $(pwd)/$FREEDV_CONF_FILE
+sed "s/@FREEDV_COMPUTER_TO_SPEAKER_DEVICE@/$FREEDV_COMPUTER_TO_SPEAKER_DEVICE/g" $(pwd)/$FREEDV_CONF_FILE > $(pwd)/$FREEDV_CONF_FILE.tmp
+mv $(pwd)/$FREEDV_CONF_FILE.tmp $(pwd)/$FREEDV_CONF_FILE
+
+if [ "$FREEDV_MICROPHONE_TO_COMPUTER_DEVICE" == "FreeDV_Microphone_To_Computer" ] && [ "$OPERATING_SYSTEM" == "Linux" ]; then
+    sed "s/@FREEDV_MICROPHONE_TO_COMPUTER_DEVICE@/$FREEDV_MICROPHONE_TO_COMPUTER_DEVICE.monitor/g" $(pwd)/$FREEDV_CONF_FILE > $(pwd)/$FREEDV_CONF_FILE.tmp
+else
+    sed "s/@FREEDV_MICROPHONE_TO_COMPUTER_DEVICE@/$FREEDV_MICROPHONE_TO_COMPUTER_DEVICE/g" $(pwd)/$FREEDV_CONF_FILE > $(pwd)/$FREEDV_CONF_FILE.tmp
+fi
+mv $(pwd)/$FREEDV_CONF_FILE.tmp $(pwd)/$FREEDV_CONF_FILE
+
+# Start recording
+if [ "$FREEDV_TEST" == "tx" ]; then
+    if [ "$OPERATING_SYSTEM" == "Linux" ]; then
+        parecord --channels=1 --rate 8000 --file-format=wav --device "$REC_DEVICE" --latency 1 test.wav &
+    else
+        sox -t $SOX_DRIVER "$REC_DEVICE" -c 1 -r 8000 -t wav test.wav &
+    fi
+    RECORD_PID=$!
+fi
+
+# Start FreeDV in test mode
+$FREEDV_BINARY -f $(pwd)/$FREEDV_CONF_FILE -ut $FREEDV_TEST -utmode $FREEDV_MODE 2>&1 | tee tmp.log
+
+FDV_PID=$!
+#sleep 30 
+#screencapture ../screenshot.png
+#wpctl status
+#pw-top -b -n 5
+#wait $FDV_PID
+
+# Stop recording/playback and process data
+if [ "$FREEDV_TEST" == "rx" ]; then
+    kill $PLAY_PID || echo "Already done playing"
+    NUM_RESYNCS=`grep "Sync changed" tmp.log | wc -l | xargs`
+    echo "Got $NUM_RESYNCS sync changes"
+else
+    kill $RECORD_PID
+    sox test.wav -t raw -r 8k -c 1 -b 16 -e signed-integer test.raw silence 1 0.1 0.1% reverse silence 1 0.1 0.1% reverse
+    python3 $SCRIPTPATH/check-for-zeros.py test.raw
+fi
+
+# Clean up PulseAudio virtual devices
+if [ "$OPERATING_SYSTEM" == "Linux" ]; then
+    pactl unload-module $DRIVER_INDEX_LOOPBACK
+    pactl unload-module $DRIVER_INDEX_FREEDV_RADIO_TO_COMPUTER
+    pactl unload-module $DRIVER_INDEX_FREEDV_COMPUTER_TO_SPEAKER
+    pactl unload-module $DRIVER_INDEX_FREEDV_COMPUTER_TO_RADIO
+    pactl unload-module $DRIVER_INDEX_FREEDV_MICROPHONE_TO_COMPUTER
+fi
diff --git a/test/vac464.cer b/test/vac464.cer
new file mode 100644
index 000000000..3e797f2b7
--- /dev/null
+++ b/test/vac464.cer
@@ -0,0 +1,35 @@
+-----BEGIN CERTIFICATE-----
+MIIGDDCCBPSgAwIBAgIMW5i7HVbfDAJdVbddMA0GCSqGSIb3DQEBCwUAMG4xCzAJ
+BgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMUQwQgYDVQQDEztH
+bG9iYWxTaWduIEV4dGVuZGVkIFZhbGlkYXRpb24gQ29kZVNpZ25pbmcgQ0EgLSBT
+SEEyNTYgLSBHMzAeFw0yMDA1MjYxMjE4NDBaFw0yMzA4MjYxMjE4NDBaMIIBOzEd
+MBsGA1UEDwwUUHJpdmF0ZSBPcmdhbml6YXRpb24xGDAWBgNVBAUTDzMxNjU0NzYw
+MDEwNTU1NjETMBEGCysGAQQBgjc8AgEDEwJSVTEmMCQGCysGAQQBgjc8AgECExVO
+b3Zvc2liaXJza2F5YSBvYmxhc3QxCzAJBgNVBAYTAlJVMR4wHAYDVQQIExVOb3Zv
+c2liaXJza2F5YSBvYmxhc3QxFDASBgNVBAcTC05vdm9zaWJpcnNrMSswKQYDVQQK
+EyJNdXp5Y2hlbmtvIEV2Z2VuaWkgVmlrdG9yb3ZpY2gsIElQMSswKQYDVQQDEyJN
+dXp5Y2hlbmtvIEV2Z2VuaWkgVmlrdG9yb3ZpY2gsIElQMSYwJAYJKoZIhvcNAQkB
+Fhdzb2Z0d2FyZUBtdXp5Y2hlbmtvLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEP
+ADCCAQoCggEBAMzy2+Ke77GcrnUkMh5D8SB99OrONoC3QkniNZ78H0k1t/9qdBB7
+PgsGIGMXlyo3tLx5A6kWI9iczp5iWEpdZUmvTs7AV/NDPGIDbHU7dJR1gFlzc4yn
+0CSYbW5GkQEnYLZutYDzfJP6WkteKdj7EHg2N1iO9AtpcyyC8k4CWRahymT8wQLO
+TmhcwoIgayY1rn4Qfyx5rO4EPf4dYl3NwNItJk5IAL+QgWuSQSIHptqbxM8G1Eiu
+wR0Q+Ab5v3tKktdQc7OQootJv1IX1IhLaT/AYHTxrKugiTArwzAv77fYo6HUZPZ4
+a+lKt/FH/FF6wH0BZZoDtAab1NU2m15Cz8kCAwEAAaOCAdkwggHVMA4GA1UdDwEB
+/wQEAwIHgDCBoAYIKwYBBQUHAQEEgZMwgZAwTgYIKwYBBQUHMAKGQmh0dHA6Ly9z
+ZWN1cmUuZ2xvYmFsc2lnbi5jb20vY2FjZXJ0L2dzZXh0ZW5kY29kZXNpZ25zaGEy
+ZzNvY3NwLmNydDA+BggrBgEFBQcwAYYyaHR0cDovL29jc3AyLmdsb2JhbHNpZ24u
+Y29tL2dzZXh0ZW5kY29kZXNpZ25zaGEyZzMwVQYDVR0gBE4wTDBBBgkrBgEEAaAy
+AQIwNDAyBggrBgEFBQcCARYmaHR0cHM6Ly93d3cuZ2xvYmFsc2lnbi5jb20vcmVw
+b3NpdG9yeS8wBwYFZ4EMAQMwCQYDVR0TBAIwADBFBgNVHR8EPjA8MDqgOKA2hjRo
+dHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL2dzZXh0ZW5kY29kZXNpZ25zaGEyZzMu
+Y3JsMCIGA1UdEQQbMBmBF3NvZnR3YXJlQG11enljaGVua28ubmV0MBMGA1UdJQQM
+MAoGCCsGAQUFBwMDMB8GA1UdIwQYMBaAFNwsWCwqbzUtn3mVqEhdxG0+U7+5MB0G
+A1UdDgQWBBSIENjDBY/ZwM9nURvPaBjwD0BpIDANBgkqhkiG9w0BAQsFAAOCAQEA
+KAHk8s47IfKW8g/P2+Ia1koc/CkHFy4Q+R+uVC3DF3GVvNeBY9qCj8f8xUpQfkF1
+mRUyIxfGCWS6sg+/CtzMY+vuSZnGb1C/2GQ5hBP/CvXXGz1W+sKASy29EU/gzzKd
+j0sBVAUk+dgQ18P8cyUDnBxOGaizG/Yn10/2jzLi2eyBoxnunpzxMcgaaLUhtt/7
+q7Jmor+A6FQ2xiuJlWE6TnUTR4aJH6uPswa6s6msHwNj9P7mbQfgK9P3pVmLuHBK
+k9K9jkyL9KAAlNa7GBoT98a86z4x2lC/GcKOAD78M09qrfsd9+8f2II0XIXVuEuE
+nabx4jlGD9IwXJ+daDrhfA==
+-----END CERTIFICATE-----