summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile.am6
-rw-r--r--pica-client/audiodevice.cpp29
-rw-r--r--pica-client/audiodevice.h22
-rw-r--r--pica-client/audiovideocallcontroller.cpp88
-rw-r--r--pica-client/audiovideocallcontroller.h17
-rw-r--r--pica-client/toneplayer/toneplayer.cpp445
-rw-r--r--pica-client/toneplayer/toneplayer.h81
7 files changed, 687 insertions, 1 deletions
diff --git a/Makefile.am b/Makefile.am
index eccdf2f..a603801 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -95,6 +95,8 @@ pica-client/filetransfercontroller.cpp \
pica-client/filetransfercontroller.h \
pica-client/sound.cpp \
pica-client/sound.h \
+pica-client/toneplayer/toneplayer.cpp \
+pica-client/toneplayer/toneplayer.h \
pica-client/settings.cpp \
pica-client/settings.h \
pica-client/mediadevice.cpp \
@@ -152,6 +154,7 @@ moc_showpicaiddialog.cpp \
moc_dhparam.cpp \
moc_filetransfercontroller.cpp \
moc_nodesdialog.cpp \
+moc_toneplayer.cpp \
ui_mainwindow.h
moc_%.cpp: pica-client/%.h
@@ -160,6 +163,9 @@ moc_%.cpp: pica-client/%.h
moc_%.cpp: pica-client/dialogs/%.h
$(MOC) $< -o $@
+moc_%.cpp: pica-client/toneplayer/%.h
+ $(MOC) $< -o $@
+
pica-client/mainwindow.cpp: ui_mainwindow.h
#ui_mainwindow.h: pica-client/mainwindow.ui
diff --git a/pica-client/audiodevice.cpp b/pica-client/audiodevice.cpp
index d2995e3..17dd1b9 100644
--- a/pica-client/audiodevice.cpp
+++ b/pica-client/audiodevice.cpp
@@ -1,3 +1,19 @@
+/*
+ (c) Copyright 2012 - 2018 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
#include "audiodevice.h"
#ifdef Q_OS_LINUX
@@ -68,3 +84,16 @@ QList<MediaDeviceInfo> AudioDevice::Enumerate(enum MediaDeviceStreamDirection di
#endif
return result;
}
+
+QString AudioDevice::PlatformDriverName()
+{
+#if defined(Q_OS_LINUX)
+ return QStringLiteral("alsa");
+#elif defined(Q_OS_MACOS) || defined(Q_OS_MAC)
+ return QStringLiteral("audiotoolbox");
+#elif defined(Q_OS_WIN)
+ return QStringLiteral("dsound");
+#else
+ return QStringLiteral("oss");
+#endif
+}
diff --git a/pica-client/audiodevice.h b/pica-client/audiodevice.h
index 472dd03..09edbdc 100644
--- a/pica-client/audiodevice.h
+++ b/pica-client/audiodevice.h
@@ -1,3 +1,19 @@
+/*
+ (c) Copyright 2012 - 2018 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
#ifndef AUDIODEVICE_H
#define AUDIODEVICE_H
@@ -12,6 +28,12 @@ public:
virtual void Play();
virtual void Close();
virtual QList<MediaDeviceInfo> Enumerate(enum MediaDeviceStreamDirection dir);
+
+ // Name of the FFmpeg libavdevice muxer/demuxer for the host platform's
+ // native audio API (e.g. "alsa" on Linux, "audiotoolbox" on macOS,
+ // "dsound" on Windows). Shared by every component that opens an audio
+ // device through FFmpeg (capture, playback, TonePlayer).
+ static QString PlatformDriverName();
};
#endif // AUDIODEVICE_H
diff --git a/pica-client/audiovideocallcontroller.cpp b/pica-client/audiovideocallcontroller.cpp
index 9eeb89c..65d9f3f 100644
--- a/pica-client/audiovideocallcontroller.cpp
+++ b/pica-client/audiovideocallcontroller.cpp
@@ -1,8 +1,26 @@
+/*
+ (c) Copyright 2012 - 2018 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
#include "audiovideocallcontroller.h"
#include "globals.h"
#include "skynet.h"
+#include "settings.h"
#include <QByteArray>
#include <QDebug>
+#include <QMetaObject>
AudioVideoCallController::AudioVideoCallController(QObject *parent)
: QObject(parent), callwindow(0), is_active(false)
@@ -12,23 +30,86 @@ AudioVideoCallController::AudioVideoCallController(QObject *parent)
connect(skynet, SIGNAL(CallAccepted(QByteArray)), this, SLOT(call_accepted(QByteArray)));
connect(skynet, SIGNAL(CallRejected(QByteArray)), this, SLOT(call_rejected(QByteArray)));
connect(skynet, SIGNAL(CallHungup(QByteArray)), this, SLOT(call_hungup(QByteArray)));
+
+ // Earpiece tones (ringback/busy/unreachable) and the incoming call bell
+ // are played by two TonePlayer instances, each living on its own
+ // QThread since TonePlayer::play() blocks the calling thread for the
+ // duration of the tone sequence.
+ toneplayer = new TonePlayer();
+ toneplayer->moveToThread(&toneplayer_thread);
+ toneplayer_thread.start();
+
+ ringtoneplayer = new TonePlayer();
+ ringtoneplayer->moveToThread(&ringtoneplayer_thread);
+ ringtoneplayer_thread.start();
+}
+
+AudioVideoCallController::~AudioVideoCallController()
+{
+ stopTones();
+
+ toneplayer_thread.quit();
+ toneplayer_thread.wait();
+ delete toneplayer;
+
+ ringtoneplayer_thread.quit();
+ ringtoneplayer_thread.wait();
+ delete ringtoneplayer;
+}
+
+void AudioVideoCallController::playEarpieceTone(void (TonePlayer::*tone)())
+{
+ Settings st(config_dbname);
+ QString dev = st.loadValue("audio.playback_device", "default").toString();
+
+ toneplayer->stop();
+ QMetaObject::invokeMethod(toneplayer, "setDeviceName", Qt::QueuedConnection, Q_ARG(QString, dev));
+ if (tone == &TonePlayer::playCallInProgress)
+ QMetaObject::invokeMethod(toneplayer, "playCallInProgress", Qt::QueuedConnection);
+ else if (tone == &TonePlayer::playUnreachable)
+ QMetaObject::invokeMethod(toneplayer, "playUnreachable", Qt::QueuedConnection);
+ else if (tone == &TonePlayer::playBusy)
+ QMetaObject::invokeMethod(toneplayer, "playBusy", Qt::QueuedConnection);
+}
+
+void AudioVideoCallController::playRingTone()
+{
+ Settings st(config_dbname);
+ QString dev = st.loadValue("audio.ring_device", "default").toString();
+
+ ringtoneplayer->stop();
+ QMetaObject::invokeMethod(ringtoneplayer, "setDeviceName", Qt::QueuedConnection, Q_ARG(QString, dev));
+ QMetaObject::invokeMethod(ringtoneplayer, "playClassicRingtone", Qt::QueuedConnection);
+}
+
+void AudioVideoCallController::stopTones()
+{
+ toneplayer->stop();
+ ringtoneplayer->stop();
}
void AudioVideoCallController::call_failed(QString reason)
{
qDebug() << "Call failed: " << reason << "\n";
+ /* play unreachable */
+ playEarpieceTone(&TonePlayer::playUnreachable);
}
void AudioVideoCallController::call_accepted(QByteArray peer_id)
{
+ stopTones();
}
void AudioVideoCallController::call_rejected(QByteArray peer_id)
{
+ /* play busy tone */
+ playEarpieceTone(&TonePlayer::playBusy);
}
void AudioVideoCallController::call_hungup(QByteArray peer_id)
{
+ /* play busy tone */
+ playEarpieceTone(&TonePlayer::playBusy);
}
void AudioVideoCallController::start_call(QByteArray peer_id)
@@ -44,6 +125,8 @@ void AudioVideoCallController::start_call(QByteArray peer_id)
connect(callwindow, SIGNAL(start_call_pressed()), this, SLOT(initiate_call()));
connect(callwindow, SIGNAL(hang_call_pressed()), this, SLOT(end_call()));
callwindow->show();
+ /* play ringing tone */
+ playEarpieceTone(&TonePlayer::playCallInProgress);
}
void AudioVideoCallController::call_from(QByteArray peer_id)
@@ -62,7 +145,7 @@ void AudioVideoCallController::call_from(QByteArray peer_id)
callwindow->show();
/* play ring tone */
-
+ playRingTone();
}
void AudioVideoCallController::initiate_call()
@@ -72,11 +155,14 @@ void AudioVideoCallController::initiate_call()
void AudioVideoCallController::accept_call()
{
+ stopTones();
skynet->AcceptCall(m_peer_id);
}
void AudioVideoCallController::end_call()
{
+ stopTones();
+
if (is_active)
skynet->HangupCall(m_peer_id);
else
diff --git a/pica-client/audiovideocallcontroller.h b/pica-client/audiovideocallcontroller.h
index 82e1dc8..08f94af 100644
--- a/pica-client/audiovideocallcontroller.h
+++ b/pica-client/audiovideocallcontroller.h
@@ -18,15 +18,18 @@
#define AUDIOVIDEOCALLCONTROLLER_H
#include <QObject>
+#include <QThread>
#include "callwindow.h"
#include "audiodevice.h"
#include "videodevice.h"
+#include "toneplayer/toneplayer.h"
class AudioVideoCallController : public QObject
{
Q_OBJECT
public:
explicit AudioVideoCallController(QObject *parent = nullptr);
+ ~AudioVideoCallController();
// is call active (i.e. are voice/video packets exchanged now)
bool const isActive() {return is_active;}
void processCall();
@@ -46,6 +49,20 @@ private:
VideoDevice *cam;
bool is_active;
QByteArray m_peer_id;
+
+ // Earpiece tones (ringback, busy, special information/unreachable),
+ // played through the "audio.playback_device" setting.
+ TonePlayer *toneplayer;
+ QThread toneplayer_thread;
+
+ // Incoming call bell, played through the "audio.ring_device" setting.
+ TonePlayer *ringtoneplayer;
+ QThread ringtoneplayer_thread;
+
+ void playEarpieceTone(void (TonePlayer::*tone)());
+ void playRingTone();
+ void stopTones();
+
private slots:
// button handlers
void initiate_call();
diff --git a/pica-client/toneplayer/toneplayer.cpp b/pica-client/toneplayer/toneplayer.cpp
new file mode 100644
index 0000000..d40e1e9
--- /dev/null
+++ b/pica-client/toneplayer/toneplayer.cpp
@@ -0,0 +1,445 @@
+/*
+ (c) Copyright 2012 - 2026 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+#include "toneplayer.h"
+#include "../audiodevice.h"
+
+#include <QDebug>
+#include <cmath>
+
+// FFmpeg headers
+extern "C" {
+#include <libavformat/avformat.h>
+#include <libavdevice/avdevice.h>
+#include <libavcodec/avcodec.h>
+#include <libavutil/opt.h>
+#include <libavutil/channel_layout.h>
+#include <libavutil/samplefmt.h>
+#include <libavutil/frame.h>
+#include <libavutil/mem.h>
+#include <libavutil/error.h>
+}
+
+// FFmpeg 5.1 (libavutil 57.24.100) replaced AVCodecContext/AVFrame's
+// channels + channel_layout fields with the AVChannelLayout ch_layout API.
+// The old fields were deprecated then and removed outright in FFmpeg 7.0.
+// The legacy branch keeps us building on FFmpeg 4.x (Ubuntu 20.04 / 22.04).
+#if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(57, 24, 100)
+# define TP_HAVE_CH_LAYOUT 1
+#else
+# define TP_HAVE_CH_LAYOUT 0
+#endif
+
+TonePlayer::TonePlayer(QObject *parent)
+ : QObject(parent),
+ m_abort(0),
+ m_deviceName("default"),
+ m_sampleRate(48000),
+ m_channels(1), // mono
+ m_volume(0.6)
+{
+}
+
+TonePlayer::~TonePlayer()
+{
+ stop();
+}
+
+void TonePlayer::setSequence(const QVector<Tone> &sequence)
+{
+ m_sequence = sequence;
+}
+
+void TonePlayer::stop()
+{
+ m_abort.storeRelaxed(1);
+}
+
+void TonePlayer::setDeviceName(const QString &deviceName)
+{
+ m_deviceName = deviceName;
+}
+
+void TonePlayer::setSampleRate(int sampleRate)
+{
+ m_sampleRate = sampleRate;
+}
+
+void TonePlayer::setVolume(double vol)
+{
+ m_volume = qBound(0.0, vol, 1.0);
+}
+
+void TonePlayer::playSequence(const QVector<Tone> &sequence)
+{
+ m_sequence = sequence;
+ m_abort.storeRelaxed(0);
+ play();
+}
+
+void TonePlayer::playCallInProgress()
+{
+ // "440:1500,0:3000,440:1500,0:3000,440:1500,0:0"
+ QVector<Tone> seq;
+ seq << Tone(440.0, 1500) << Tone(0.0, 3000)
+ << Tone(440.0, 1500) << Tone(0.0, 3000)
+ << Tone(440.0, 1500) << Tone(0.0, 0);
+ playSequence(seq);
+}
+
+void TonePlayer::playUnreachable()
+{
+ // "950:330,1400:330,1800:330,0:0"
+ QVector<Tone> seq;
+ seq << Tone(950.0, 330) << Tone(1400.0, 330) << Tone(1800.0, 330)
+ << Tone(0.0, 0);
+ playSequence(seq);
+}
+
+void TonePlayer::playBusy()
+{
+ // "440:400,0:400,440:400,0:400,440:400,0:0"
+ QVector<Tone> seq;
+ seq << Tone(440.0, 400) << Tone(0.0, 400)
+ << Tone(440.0, 400) << Tone(0.0, 400)
+ << Tone(440.0, 400) << Tone(0.0, 0);
+ playSequence(seq);
+}
+
+void TonePlayer::playClassicRingtone()
+{
+ // Unlike playCallInProgress/playUnreachable/playBusy, which are steady
+ // single-frequency tones heard in the earpiece, this imitates the sound
+ // of an electromechanical bell ringer: the clapper striking the gongs
+ // produces a fast warble between two close pitches rather than one pure
+ // tone. We approximate that by alternating two frequencies every 40 ms
+ // (~12.5 Hz trill) for the classic 2 s on / 4 s off cadence, 3 bursts.
+ const int trillSegment_ms = 40;
+ const int ringDuration_ms = 2000;
+ const int silenceDuration_ms = 4000;
+ const double freqA = 1000.0;
+ const double freqB = 1200.0;
+ const int bursts = 3;
+
+ QVector<Tone> seq;
+ for (int burst = 0; burst < bursts; ++burst) {
+ for (int t = 0; t < ringDuration_ms; t += trillSegment_ms) {
+ int segLen = qMin(trillSegment_ms, ringDuration_ms - t);
+ double freq = ((t / trillSegment_ms) % 2 == 0) ? freqA : freqB;
+ seq << Tone(freq, segLen);
+ }
+ seq << Tone(0.0, burst == bursts - 1 ? 0 : silenceDuration_ms);
+ }
+ playSequence(seq);
+}
+
+static QString ff_errstr(int err)
+{
+ char buf[256] = {0};
+ av_strerror(err, buf, sizeof(buf));
+ return QString::fromLocal8Bit(buf);
+}
+
+void TonePlayer::play()
+{
+ if (m_sequence.isEmpty()) {
+ emit finishedPlaying();
+ return;
+ }
+
+ avdevice_register_all();
+#if LIBAVFORMAT_VERSION_MAJOR < 58
+ av_register_all();
+ avcodec_register_all();
+#endif
+
+ AVFormatContext *oc = nullptr;
+ const QByteArray deviceUtf8 = m_deviceName.toUtf8();
+ const char *device = deviceUtf8.constData();
+ const QByteArray driverUtf8 = AudioDevice::PlatformDriverName().toUtf8();
+ const char *driver = driverUtf8.constData();
+
+ int ret = avformat_alloc_output_context2(&oc, nullptr, driver, device);
+ if (ret < 0 || !oc) {
+ QString msg = QString("Could not allocate '%1' output context: %2").arg(driver, ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ return;
+ }
+
+ const AVCodecID codec_id = AV_CODEC_ID_PCM_S16LE;
+ const AVCodec *codec = avcodec_find_encoder(codec_id);
+ if (!codec) {
+ QString msg = "PCM encoder not found";
+ qWarning() << msg;
+ emit errorOccured(msg);
+ avformat_free_context(oc);
+ return;
+ }
+
+ AVCodecContext *codecCtx = avcodec_alloc_context3(codec);
+ if (!codecCtx) {
+ QString msg = "Could not alloc codec context";
+ qWarning() << msg;
+ emit errorOccured(msg);
+ avformat_free_context(oc);
+ return;
+ }
+
+ codecCtx->sample_fmt = AV_SAMPLE_FMT_S16;
+ codecCtx->sample_rate = m_sampleRate;
+#if TP_HAVE_CH_LAYOUT
+ av_channel_layout_default(&codecCtx->ch_layout, m_channels);
+#else
+ codecCtx->channels = m_channels;
+ codecCtx->channel_layout = av_get_default_channel_layout(m_channels);
+#endif
+ codecCtx->time_base = AVRational{1, m_sampleRate};
+
+ if ((ret = avcodec_open2(codecCtx, codec, nullptr)) < 0) {
+ QString msg = QString("Could not open codec: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+ return;
+ }
+
+ AVStream *st = avformat_new_stream(oc, nullptr);
+ if (!st) {
+ QString msg = "Could not create stream";
+ qWarning() << msg;
+ emit errorOccured(msg);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+ return;
+ }
+
+ if ((ret = avcodec_parameters_from_context(st->codecpar, codecCtx)) < 0) {
+ QString msg = QString("Failed to copy codec params: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+ return;
+ }
+ st->time_base = codecCtx->time_base;
+
+ if (!(oc->oformat->flags & AVFMT_NOFILE)) {
+ if ((ret = avio_open(&oc->pb, device, AVIO_FLAG_WRITE)) < 0) {
+ QString msg = QString("Could not open '%1' device '%2': %3").arg(driver, m_deviceName, ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+ return;
+ }
+ }
+
+ if ((ret = avformat_write_header(oc, nullptr)) < 0) {
+ QString msg = QString("Error writing header: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ if (!(oc->oformat->flags & AVFMT_NOFILE)) avio_closep(&oc->pb);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+ return;
+ }
+
+ AVFrame *frame = av_frame_alloc();
+ if (!frame) {
+ QString msg = "Could not allocate AVFrame";
+ qWarning() << msg;
+ emit errorOccured(msg);
+ if (!(oc->oformat->flags & AVFMT_NOFILE)) avio_closep(&oc->pb);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+ return;
+ }
+
+ frame->format = codecCtx->sample_fmt;
+ frame->sample_rate = codecCtx->sample_rate;
+#if TP_HAVE_CH_LAYOUT
+ if ((ret = av_channel_layout_copy(&frame->ch_layout, &codecCtx->ch_layout)) < 0) {
+ QString msg = QString("Could not copy channel layout to frame: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ if (!(oc->oformat->flags & AVFMT_NOFILE)) avio_closep(&oc->pb);
+ av_frame_free(&frame);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+ return;
+ }
+#else
+ frame->channels = codecCtx->channels;
+ frame->channel_layout = codecCtx->channel_layout;
+#endif
+
+ int frame_size = codecCtx->frame_size;
+ if (frame_size <= 0) frame_size = 1024;
+
+ frame->nb_samples = frame_size;
+ if ((ret = av_frame_get_buffer(frame, 0)) < 0) {
+ QString msg = QString("Could not allocate initial frame buffer: %1 (frame_size=%2)")
+ .arg(ff_errstr(ret)).arg(frame_size);
+ qWarning() << msg;
+ emit errorOccured(msg);
+ if (!(oc->oformat->flags & AVFMT_NOFILE)) avio_closep(&oc->pb);
+ av_frame_free(&frame);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+ return;
+ }
+
+ int64_t pts = 0;
+ double phase = 0.0;
+ const double two_pi = 2.0 * M_PI;
+
+#if TP_HAVE_CH_LAYOUT
+ int chs = codecCtx->ch_layout.nb_channels;
+#else
+ int chs = codecCtx->channels;
+#endif
+ if (chs <= 0) chs = 1;
+
+ for (int idx = 0; idx < m_sequence.size(); ++idx) {
+ if (m_abort.loadRelaxed()) break;
+
+ const Tone &t = m_sequence[idx];
+ if (t.frequency == 0.0 && t.duration_ms == 0) {
+ break;
+ }
+
+ if (t.duration_ms <= 0) continue;
+
+ int64_t total_samples = (int64_t)qRound((m_sampleRate * t.duration_ms) / 1000.0);
+ int64_t samples_written = 0;
+
+ double phase_inc = 0.0;
+ if (t.frequency > 0.0) {
+ phase_inc = two_pi * t.frequency / double(m_sampleRate);
+ }
+
+ while (samples_written < total_samples) {
+ if (m_abort.loadRelaxed()) break;
+
+ int nb_samples = frame_size;
+ if (nb_samples > (int)(total_samples - samples_written))
+ nb_samples = int(total_samples - samples_written);
+
+ if ((ret = av_frame_make_writable(frame)) < 0) {
+ QString msg = QString("av_frame_make_writable failed: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ break;
+ }
+ frame->nb_samples = nb_samples;
+
+ int16_t *out = reinterpret_cast<int16_t*>(frame->data[0]);
+
+ for (int i = 0; i < nb_samples; ++i) {
+ double sampleVal = 0.0;
+ if (t.frequency > 0.0) {
+ sampleVal = sin(phase) * m_volume;
+ phase += phase_inc;
+ if (phase > 1e9 || phase < -1e9) phase = fmod(phase, two_pi);
+ } else {
+ sampleVal = 0.0;
+ }
+
+ int16_t ival = static_cast<int16_t>(qBound(-1.0, sampleVal, 1.0) * 32767.0);
+ for (int ch = 0; ch < chs; ++ch) {
+ out[i * chs + ch] = ival;
+ }
+ }
+
+ frame->pts = pts;
+ pts += frame->nb_samples;
+
+ ret = avcodec_send_frame(codecCtx, frame);
+ if (ret < 0) {
+ QString msg = QString("Error sending frame to encoder: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ break;
+ }
+
+ while (ret >= 0) {
+ AVPacket *pkt = av_packet_alloc();
+ if (!pkt) {
+ QString msg = "av_packet_alloc failed";
+ qWarning() << msg;
+ emit errorOccured(msg);
+ ret = AVERROR(ENOMEM);
+ break;
+ }
+
+ ret = avcodec_receive_packet(codecCtx, pkt);
+ if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
+ av_packet_free(&pkt);
+ break;
+ } else if (ret < 0) {
+ QString msg = QString("Error encoding audio frame: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ av_packet_free(&pkt);
+ break;
+ }
+
+ pkt->stream_index = st->index;
+
+ ret = av_interleaved_write_frame(oc, pkt);
+ if (ret < 0) {
+ QString msg = QString("Error writing packet to device: %1").arg(ff_errstr(ret));
+ qWarning() << msg;
+ emit errorOccured(msg);
+ av_packet_free(&pkt);
+ break;
+ }
+
+ av_packet_free(&pkt);
+ }
+ samples_written += nb_samples;
+ }
+ if (m_abort.loadRelaxed()) break;
+ }
+
+ avcodec_send_frame(codecCtx, nullptr);
+ while (true) {
+ AVPacket *pkt = av_packet_alloc();
+ if (!pkt) break;
+ ret = avcodec_receive_packet(codecCtx, pkt);
+ if (ret == AVERROR_EOF || ret == AVERROR(EAGAIN)) {
+ av_packet_free(&pkt);
+ break;
+ } else if (ret < 0) {
+ av_packet_free(&pkt);
+ break;
+ }
+ pkt->stream_index = st->index;
+ av_interleaved_write_frame(oc, pkt);
+ av_packet_free(&pkt);
+ }
+
+ av_write_trailer(oc);
+
+ if (!(oc->oformat->flags & AVFMT_NOFILE)) avio_closep(&oc->pb);
+ av_frame_free(&frame);
+ avcodec_free_context(&codecCtx);
+ avformat_free_context(oc);
+
+ emit finishedPlaying();
+}
diff --git a/pica-client/toneplayer/toneplayer.h b/pica-client/toneplayer/toneplayer.h
new file mode 100644
index 0000000..b3e237d
--- /dev/null
+++ b/pica-client/toneplayer/toneplayer.h
@@ -0,0 +1,81 @@
+/*
+ (c) Copyright 2012 - 2026 Anton Sviridenko
+ https://picapica.im
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, version 3.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+#pragma once
+
+#include <QObject>
+#include <QVector>
+#include <QAtomicInt>
+#include <QtGlobal>
+#include <QString>
+
+// Simple tone descriptor: frequency in Hz, duration in milliseconds.
+struct Tone {
+ double frequency; // Hz
+ int duration_ms; // milliseconds
+ Tone(double f = 0.0, int d = 0): frequency(f), duration_ms(d) {}
+};
+
+class TonePlayer : public QObject
+{
+ Q_OBJECT
+public:
+ explicit TonePlayer(QObject *parent = nullptr);
+ ~TonePlayer() override;
+
+ // Replace the sequence to play. Call before play().
+ void setSequence(const QVector<Tone> &sequence);
+
+ // Stop playback early. This will cause play() to return.
+ Q_INVOKABLE void stop();
+
+ // Set the platform playback device name (e.g. "default" or "hw:0" on
+ // ALSA, a CoreAudio/DirectSound device name on macOS/Windows). Default:
+ // "default". Typically set from the "audio.playback_device" or
+ // "audio.ring_device" setting (see SettingsDialog). Q_INVOKABLE so it
+ // can be queued across threads via QMetaObject::invokeMethod(), the
+ // same way play() is invoked when TonePlayer lives on its own QThread.
+ Q_INVOKABLE void setDeviceName(const QString &deviceName);
+
+ // Set playback parameters (optional before play)
+ void setSampleRate(int sampleRate);
+ void setVolume(double vol); // 0.0 .. 1.0
+
+signals:
+ void finishedPlaying();
+ void errorOccured(const QString &err);
+
+public slots:
+ void play();
+
+ // Standard telephony tone sequences. Each replaces the current sequence
+ // and plays it to completion (blocking, like play()).
+ void playCallInProgress(); // ringing tone: 440 Hz, 1.5 s on / 3 s off, 3 bursts
+ void playUnreachable(); // special information tone: 950/1400/1800 Hz, 330 ms each
+ void playBusy(); // busy tone: 440 Hz, 400 ms on / 400 ms off, 3 bursts
+ void playClassicRingtone(); // electromechanical bell: warbling trill, 2 s on / 4 s off, 3 bursts
+
+private:
+ // Set the sequence, clear any pending stop request and play it.
+ void playSequence(const QVector<Tone> &sequence);
+
+ QVector<Tone> m_sequence;
+ QAtomicInt m_abort;
+ QString m_deviceName;
+ int m_sampleRate;
+ int m_channels; // always 1 (mono) by design
+ double m_volume;
+};