summaryrefslogtreecommitdiffstats
path: root/pica-client
diff options
context:
space:
mode:
authorAnton <anton@picapica.im>2026-08-13 14:36:19 +0400
committerAnton <anton@picapica.im>2026-08-13 14:36:19 +0400
commit528e73385831dcd939c7e6b082fb274c27b80145 (patch)
tree5100be13f42da388a82ab4c04109587ee8d497bf /pica-client
parentd10deee6b3143fffd0d4d55b87bf6167c34dd459 (diff)
downloadpica-pica-528e73385831dcd939c7e6b082fb274c27b80145.tar.gz
pica-pica-528e73385831dcd939c7e6b082fb274c27b80145.zip
pica-client: add test audio devices buttons in the settings dialog
Diffstat (limited to 'pica-client')
-rw-r--r--pica-client/dialogs/settingsdialog.cpp279
-rw-r--r--pica-client/dialogs/settingsdialog.h57
2 files changed, 334 insertions, 2 deletions
diff --git a/pica-client/dialogs/settingsdialog.cpp b/pica-client/dialogs/settingsdialog.cpp
index 536d141..61f2ed9 100644
--- a/pica-client/dialogs/settingsdialog.cpp
+++ b/pica-client/dialogs/settingsdialog.cpp
@@ -31,6 +31,21 @@
#include <QNetworkInterface>
#include <QHostAddress>
#include <QTabWidget>
+#include <QTimer>
+
+// Audio pipeline test: how long the microphone is recorded for, and the
+// sample rate to record at - the same rate a call negotiates.
+static const int kAudioTestRecordMs = 5000;
+static const int kAudioTestSampleRate = 48000;
+
+// One encoded packet covers this much audio (the Opus encoder produces
+// 20 ms frames at 48 kHz), so feeding one packet per this many milliseconds
+// replays the recording at its natural rate.
+static const int kAudioTestPacketMs = 20;
+
+// Grace period after the last packet has been handed over, for the jitter
+// buffer and the sound card's own buffer to drain before closing the device.
+static const int kAudioTestDrainMs = 500;
SettingsDialog::SettingsDialog(QWidget *parent) :
QDialog(parent)
@@ -136,19 +151,69 @@ SettingsDialog::SettingsDialog(QWidget *parent) :
audioRingDev->setMinimumHeight(audioRingDev->height() * 2);
fillAudioRingDevices();
- btAudioTest = new QPushButton(tr("Test"), this);
+ btAudioTest = new QPushButton(tr("Test 🎙️"), this);
+ connect(btAudioTest, SIGNAL(clicked()), this, SLOT(toggleAudioTest()));
+
+ audioTestStatus = new QLabel(this);
+ audioTestStatus->setAlignment(Qt::AlignCenter);
+ btRingTest = new QPushButton(tr("Test ring 🔔"), this);
+ connect(btRingTest, SIGNAL(clicked()), this, SLOT(toggleRingTest()));
+
+ ringTestStatus = new QLabel(this);
+ ringTestStatus->setAlignment(Qt::AlignCenter);
+
+ // The microphone test covers the capture and playback devices, so it sits
+ // with them, above the unrelated ring device below.
audiodevlayout->addWidget(lbAudioCaptureDev);
audiodevlayout->addWidget(audioCaptureDev);
audiodevlayout->addWidget(lbAudioPlaybackDev);
audiodevlayout->addWidget(audioPlaybackDev);
+ audiodevlayout->addWidget(btAudioTest);
+ audiodevlayout->addWidget(audioTestStatus);
audiodevlayout->addWidget(lbAudioRingDev);
audiodevlayout->addWidget(audioRingDev);
- audiodevlayout->addWidget(btAudioTest);
+ audiodevlayout->addWidget(btRingTest);
+ audiodevlayout->addWidget(ringTestStatus);
audiodevlayout->addStretch(1);
audiodevtab->setLayout(audiodevlayout);
+ // Capture+encode and decode+playback ends of the test, each blocking its
+ // own thread while running, exactly as they do during a call. They stay
+ // idle until the Test button is pressed.
+ audioTestState = AudioTestIdle;
+ audioTestPlaybackPos = 0;
+
+ audioTestRecordTimer = new QTimer(this);
+ audioTestRecordTimer->setSingleShot(true);
+ connect(audioTestRecordTimer, SIGNAL(timeout()), this, SLOT(audioTestRecordingFinished()));
+
+ audioTestPlaybackTimer = new QTimer(this);
+ connect(audioTestPlaybackTimer, SIGNAL(timeout()), this, SLOT(audioTestFeedPacket()));
+
+ audioTestDrainTimer = new QTimer(this);
+ audioTestDrainTimer->setSingleShot(true);
+ connect(audioTestDrainTimer, SIGNAL(timeout()), this, SLOT(audioTestPlaybackFinished()));
+
+ testMic = new AudioDevice();
+ testMic->moveToThread(&testMicThread);
+ connect(testMic, SIGNAL(packetReady(QByteArray)), this, SLOT(audioTestPacket(QByteArray)));
+ connect(testMic, SIGNAL(errorOccurred(QString)), this, SLOT(audioTestError(QString)));
+ testMicThread.start();
+
+ testSpeaker = new AudioDevice();
+ testSpeaker->moveToThread(&testSpeakerThread);
+ connect(testSpeaker, SIGNAL(errorOccurred(QString)), this, SLOT(audioTestError(QString)));
+ testSpeakerThread.start();
+
+ ringTestRunning = false;
+ ringTestPlayer = new TonePlayer();
+ ringTestPlayer->moveToThread(&ringTestPlayerThread);
+ connect(ringTestPlayer, SIGNAL(finishedPlaying()), this, SLOT(ringTestFinished()));
+ connect(ringTestPlayer, SIGNAL(errorOccured(QString)), this, SLOT(ringTestError(QString)));
+ ringTestPlayerThread.start();
+
//Video Devices
videoDev = new QComboBox(this);
videoDev->setMinimumHeight(videoDev->height() * 2);
@@ -223,6 +288,8 @@ SettingsDialog::~SettingsDialog()
// are still running inside them.
testCam->Close();
testDecoder->Close();
+ testMic->Close();
+ testSpeaker->Close();
testCamThread.quit();
testCamThread.wait();
@@ -231,6 +298,214 @@ SettingsDialog::~SettingsDialog()
testDecoderThread.quit();
testDecoderThread.wait();
delete testDecoder;
+
+ testMicThread.quit();
+ testMicThread.wait();
+ delete testMic;
+
+ testSpeakerThread.quit();
+ testSpeakerThread.wait();
+ delete testSpeaker;
+
+ // Same reasoning: stop() first, since the thread cannot finish while
+ // TonePlayer::play() is still running inside it.
+ ringTestPlayer->stop();
+ ringTestPlayerThread.quit();
+ ringTestPlayerThread.wait();
+ delete ringTestPlayer;
+}
+
+void SettingsDialog::toggleRingTest()
+{
+ if (ringTestRunning)
+ {
+ // stop() only sets an atomic flag, so it is safe to call directly on
+ // a TonePlayer whose thread is blocked inside play().
+ ringTestPlayer->stop();
+ ringTestRunning = false;
+ btRingTest->setText(tr("Test ring 🔔"));
+ ringTestStatus->clear();
+ return;
+ }
+
+ QString dev = audioRingDev->itemData(audioRingDev->currentIndex()).toString();
+
+ if (dev.isEmpty())
+ {
+ ringTestStatus->setText(tr("No ring device selected"));
+ return;
+ }
+
+ ringTestRunning = true;
+ btRingTest->setText(tr("Stop ⏹"));
+ ringTestStatus->setText(tr("Ringing..."));
+
+ // Same sequence AudioVideoCallController::playRingTone() uses for a real
+ // incoming call: clear any tone still playing, point the player at the
+ // configured device, then start the bell.
+ ringTestPlayer->stop();
+ QMetaObject::invokeMethod(ringTestPlayer, "setDeviceName", Qt::QueuedConnection, Q_ARG(QString, dev));
+ QMetaObject::invokeMethod(ringTestPlayer, "playClassicRingtone", Qt::QueuedConnection);
+}
+
+void SettingsDialog::ringTestFinished()
+{
+ if (!ringTestRunning)
+ return;
+
+ ringTestRunning = false;
+ btRingTest->setText(tr("Test ring 🔔"));
+ ringTestStatus->clear();
+}
+
+void SettingsDialog::ringTestError(QString message)
+{
+ ringTestRunning = false;
+ btRingTest->setText(tr("Test ring 🔔"));
+ ringTestStatus->setText(message);
+}
+
+void SettingsDialog::toggleAudioTest()
+{
+ if (audioTestState != AudioTestIdle)
+ {
+ stopAudioTest();
+ audioTestStatus->setText(tr("Stopped"));
+ return;
+ }
+
+ QString dev = audioCaptureDev->itemData(audioCaptureDev->currentIndex()).toString();
+
+ if (dev.isEmpty())
+ {
+ audioTestStatus->setText(tr("No microphone selected"));
+ return;
+ }
+
+ audioTestPackets.clear();
+ audioTestPlaybackPos = 0;
+ audioTestState = AudioTestRecording;
+ btAudioTest->setText(tr("Stop ⏹"));
+ audioTestStatus->setText(tr("Recording, say something..."));
+
+ QMetaObject::invokeMethod(testMic, "configureCapture", Qt::QueuedConnection,
+ Q_ARG(QString, dev), Q_ARG(QString, QStringLiteral("opus")),
+ Q_ARG(int, kAudioTestSampleRate));
+ QMetaObject::invokeMethod(testMic, "Capture", Qt::QueuedConnection);
+
+ audioTestRecordTimer->start(kAudioTestRecordMs);
+}
+
+void SettingsDialog::audioTestPacket(QByteArray data)
+{
+ // Packets still in flight from the microphone thread after the recording
+ // phase ended are not part of the recording.
+ if (audioTestState != AudioTestRecording)
+ return;
+
+ audioTestPackets.append(data);
+}
+
+void SettingsDialog::audioTestRecordingFinished()
+{
+ // The test may have been stopped, or have failed, while the timer ran.
+ if (audioTestState != AudioTestRecording)
+ return;
+
+ // Direct call: the microphone thread is blocked inside Capture(), so a
+ // queued call would never reach it (see the comments in
+ // AudioVideoCallController::stopAudioPipeline()).
+ testMic->Close();
+
+ if (audioTestPackets.isEmpty())
+ {
+ stopAudioTest();
+ audioTestStatus->setText(tr("Nothing was captured"));
+ return;
+ }
+
+ startAudioTestPlayback();
+}
+
+void SettingsDialog::startAudioTestPlayback()
+{
+ QString dev = audioPlaybackDev->itemData(audioPlaybackDev->currentIndex()).toString();
+
+ if (dev.isEmpty())
+ {
+ stopAudioTest();
+ audioTestStatus->setText(tr("No playback device selected"));
+ return;
+ }
+
+ audioTestState = AudioTestPlaying;
+ audioTestPlaybackPos = 0;
+ audioTestStatus->setText(tr("Playing back..."));
+
+ QMetaObject::invokeMethod(testSpeaker, "configurePlayback", Qt::QueuedConnection,
+ Q_ARG(QString, dev), Q_ARG(QString, QStringLiteral("opus")),
+ Q_ARG(int, kAudioTestSampleRate));
+ QMetaObject::invokeMethod(testSpeaker, "Play", Qt::QueuedConnection);
+
+ // The recording has to be handed over at the rate it plays at, not all at
+ // once: AudioDevice's jitter buffer holds only a few packets and drops
+ // the oldest when full, so dumping the whole recording into it would
+ // discard everything but the tail.
+ audioTestPlaybackTimer->start(kAudioTestPacketMs);
+}
+
+void SettingsDialog::audioTestFeedPacket()
+{
+ if (audioTestState != AudioTestPlaying)
+ return;
+
+ if (audioTestPlaybackPos >= audioTestPackets.size())
+ {
+ audioTestPlaybackTimer->stop();
+ // Let what is already queued, plus the sound card's own buffer, play
+ // out before tearing the playback device down.
+ audioTestDrainTimer->start(kAudioTestDrainMs);
+ return;
+ }
+
+ // Direct call, for the same reason as in the call controller.
+ testSpeaker->enqueuePacket(audioTestPackets.at(audioTestPlaybackPos++));
+}
+
+void SettingsDialog::audioTestPlaybackFinished()
+{
+ if (audioTestState != AudioTestPlaying)
+ return;
+
+ stopAudioTest();
+ audioTestStatus->setText(tr("Done"));
+}
+
+void SettingsDialog::stopAudioTest()
+{
+ audioTestRecordTimer->stop();
+ audioTestPlaybackTimer->stop();
+ audioTestDrainTimer->stop();
+
+ // Direct calls: both devices may be blocked inside their capture/playback
+ // loops, where a queued call would never reach them.
+ testMic->Close();
+ testSpeaker->Close();
+
+ audioTestPackets.clear();
+ audioTestPlaybackPos = 0;
+ audioTestState = AudioTestIdle;
+ btAudioTest->setText(tr("Test 🎙️"));
+ audioTestStatus->clear();
+}
+
+void SettingsDialog::audioTestError(QString message)
+{
+ if (audioTestState == AudioTestIdle)
+ return;
+
+ stopAudioTest();
+ audioTestStatus->setText(message);
}
void SettingsDialog::toggleVideoTest()
diff --git a/pica-client/dialogs/settingsdialog.h b/pica-client/dialogs/settingsdialog.h
index 06a2f13..881dd29 100644
--- a/pica-client/dialogs/settingsdialog.h
+++ b/pica-client/dialogs/settingsdialog.h
@@ -27,8 +27,12 @@
#include <QLabel>
#include <QThread>
#include <QImage>
+#include <QList>
+#include <QTimer>
#include "../mediadevice.h"
+#include "../audiodevice.h"
#include "../videodevice.h"
+#include "../toneplayer/toneplayer.h"
class SettingsDialog : public QDialog
{
@@ -64,7 +68,49 @@ private:
QComboBox *videoDev;
QPushButton *videoDevRefresh;
+ // Local audio pipeline test: a few seconds of the selected microphone are
+ // captured and encoded, then decoded again and played back through the
+ // selected playback device - the same path a call takes, minus the
+ // network. Recording and playback are deliberately kept apart in time
+ // rather than looped back live, which would howl with acoustic feedback
+ // when the test runs on speakers.
QPushButton *btAudioTest;
+ QLabel *audioTestStatus;
+ AudioDevice *testMic;
+ AudioDevice *testSpeaker;
+ QThread testMicThread;
+ QThread testSpeakerThread;
+
+ enum AudioTestState
+ {
+ AudioTestIdle,
+ AudioTestRecording,
+ AudioTestPlaying
+ };
+
+ AudioTestState audioTestState;
+ // The recording, as encoded packets, and how far playback has fed it.
+ QList<QByteArray> audioTestPackets;
+ int audioTestPlaybackPos;
+ // Members rather than QTimer::singleShot() calls so that stopping a test
+ // cancels them - otherwise a timer left over from an aborted run would
+ // fire into the next one.
+ QTimer *audioTestRecordTimer;
+ QTimer *audioTestPlaybackTimer;
+ QTimer *audioTestDrainTimer;
+
+ void stopAudioTest();
+ void startAudioTestPlayback();
+
+ // Ring device test: plays the incoming call bell through the selected
+ // ring device, using the same TonePlayer an actual incoming call uses.
+ // TonePlayer::play() blocks its thread for the length of the tone
+ // sequence, hence the dedicated thread.
+ QPushButton *btRingTest;
+ QLabel *ringTestStatus;
+ TonePlayer *ringTestPlayer;
+ QThread ringTestPlayerThread;
+ bool ringTestRunning;
// Local video pipeline test: frames from the selected camera are
// encoded, split into protocol sized fragments, reassembled and decoded
@@ -99,6 +145,17 @@ private slots:
void fillAudioPlaybackDevices();
void fillAudioRingDevices();
+ void toggleRingTest();
+ void ringTestFinished();
+ void ringTestError(QString message);
+
+ void toggleAudioTest();
+ void audioTestPacket(QByteArray data);
+ void audioTestRecordingFinished();
+ void audioTestFeedPacket();
+ void audioTestPlaybackFinished();
+ void audioTestError(QString message);
+
void toggleVideoTest();
void videoTestFragment(QByteArray data, bool is_last_fragment);
void videoTestFrame(QImage frame);