mumble-voip_mumble/src/mumble/VoiceRecorder.cpp

495 lines
15 KiB
C++

// Copyright 2010-2023 The Mumble Developers. All rights reserved.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file at the root of the
// Mumble source tree or at <https://www.mumble.info/LICENSE>.
#include "VoiceRecorder.h"
#include "AudioOutput.h"
#include "ClientUser.h"
#include "ServerHandler.h"
#include "Global.h"
#include "../Timer.h"
#include <boost/make_shared.hpp>
VoiceRecorder::RecordBuffer::RecordBuffer(int recordInfoIndex_, boost::shared_array< float > buffer_, int samples_,
quint64 absoluteStartSample_)
: recordInfoIndex(recordInfoIndex_), buffer(buffer_), samples(samples_), absoluteStartSample(absoluteStartSample_) {
// Nothing
}
VoiceRecorder::RecordInfo::RecordInfo(const QString &userName_)
: userName(userName_), soundFile(nullptr), lastWrittenAbsoluteSample(0) {
}
VoiceRecorder::RecordInfo::~RecordInfo() {
if (soundFile) {
// Close libsndfile's handle if we have one.
sf_close(soundFile);
}
}
VoiceRecorder::VoiceRecorder(QObject *p, const Config &config)
: QThread(p), m_recordUser(new RecordUser()), m_timestamp(new Timer()), m_config(config), m_recording(false),
m_abort(false), m_recordingStartTime(QDateTime::currentDateTime()), m_absoluteSampleEstimation(0) {
// Nothing
}
VoiceRecorder::~VoiceRecorder() {
stop();
wait();
}
QString VoiceRecorder::sanitizeFilenameOrPathComponent(const QString &str) const {
// Trim leading/trailing whitespaces
QString res = str.trimmed();
if (res.isEmpty())
return QLatin1String("_");
#ifdef Q_OS_WIN
// Rules according to http://en.wikipedia.org/wiki/Filename#Comparison_of_file_name_limitations
// and http://msdn.microsoft.com/en-us/library/aa365247(VS.85).aspx
// Make sure name doesn't end in "."
if (res.at(res.length() - 1) == QLatin1Char('.')) {
if (res.length() == 255) { // Prevents possible infinite recursion later on
res[254] = QLatin1Char('_');
} else {
res = res.append(QLatin1Char('_'));
}
}
// Replace < > : " / \ | ? * as well as chr(0) to chr(31)
res = res.replace(QRegExp(QLatin1String("[<>:\"/\\\\|\\?\\*\\x00-\\x1F]")), QLatin1String("_"));
// Prepend reserved filenames CON, PRN, AUX, NUL, COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9, LPT1, LPT2,
// LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, and LPT9
res = res.replace(QRegExp(QLatin1String("^((CON|PRN|AUX|NUL|COM[1-9]|LPT1[1-9])(\\.|$))"), Qt::CaseInsensitive),
QLatin1String("_\\1"));
// Make sure we do not exceed 255 characters
if (res.length() > 255) {
res.truncate(255);
// Call ourselves recursively to make sure we do not end up violating any of our rules because of this
res = sanitizeFilenameOrPathComponent(res);
}
#else
// For the rest just make sure the string doesn't contain a \0 or any forward-slashes
res = res.replace(QRegExp(QLatin1String("\\x00|/")), QLatin1String("_"));
#endif
return res;
}
QString VoiceRecorder::expandTemplateVariables(const QString &path, const QString &userName) const {
// Split path into components
QString res;
QStringList comp = path.split(QLatin1Char('/'));
Q_ASSERT(!comp.isEmpty());
// Create a readable representation of the start date.
QString date(m_recordingStartTime.date().toString(Qt::ISODate));
QString time(m_recordingStartTime.time().toString(QLatin1String("hh-mm-ss")));
QString hostname(QLatin1String("Unknown"));
if (Global::get().sh && Global::get().uiSession != 0) {
unsigned short port;
QString uname, pw;
Global::get().sh->getConnectionInfo(hostname, port, uname, pw);
}
// Create hash which stores the names of the variables with the corresponding values.
// Valid variables are:
// %user Inserts the users name
// %date Inserts the current date
// %time Inserts the current time
// %host Inserts the hostname
QHash< const QString, QString > vars;
vars.insert(QLatin1String("user"), userName);
vars.insert(QLatin1String("date"), date);
vars.insert(QLatin1String("time"), time);
vars.insert(QLatin1String("host"), hostname);
// Reassemble and expand
bool first = true;
foreach (QString str, comp) {
bool replacements = false;
QString tmp;
tmp.reserve(str.length() * 2);
for (int i = 0; i < str.size(); ++i) {
bool replaced = false;
if (str[i] == QLatin1Char('%')) {
QHashIterator< const QString, QString > it(vars);
while (it.hasNext()) {
it.next();
if (str.midRef(i + 1, it.key().length()) == it.key()) {
i += it.key().length();
tmp += it.value();
replaced = true;
replacements = true;
break;
}
}
}
if (!replaced)
tmp += str[i];
}
str = tmp;
if (replacements)
str = sanitizeFilenameOrPathComponent(str);
if (first) {
first = false;
res.append(str);
} else {
res.append(QLatin1Char('/') + str);
}
}
return res;
}
int VoiceRecorder::indexForUser(const ClientUser *clientUser) const {
Q_ASSERT(!m_config.mixDownMode || !clientUser);
return (m_config.mixDownMode) ? 0 : static_cast< int >(clientUser->uiSession);
}
SF_INFO VoiceRecorder::createSoundFileInfo() const {
Q_ASSERT(m_config.sampleRate != 0);
// When adding new formats make sure to properly configure needed additional
// behavior after opening the file handle (e.Global::get(). to enable clipping).
// Convert |fmFormat| to a SF_INFO structure for libsndfile.
SF_INFO sfinfo;
switch (m_config.recordingFormat) {
case VoiceRecorderFormat::WAV:
default:
sfinfo.frames = 0;
sfinfo.samplerate = m_config.sampleRate;
sfinfo.channels = 1;
sfinfo.format = SF_FORMAT_WAV | SF_FORMAT_PCM_24;
sfinfo.sections = 0;
sfinfo.seekable = 0;
qWarning() << "VoiceRecorder: recording started to" << m_config.fileName << "@" << m_config.sampleRate
<< "hz in WAV format";
break;
#ifndef NO_VORBIS_RECORDING
case VoiceRecorderFormat::VORBIS:
sfinfo.frames = 0;
sfinfo.samplerate = m_config.sampleRate;
sfinfo.channels = 1;
sfinfo.format = SF_FORMAT_OGG | SF_FORMAT_VORBIS;
sfinfo.sections = 0;
sfinfo.seekable = 0;
qWarning() << "VoiceRecorder: recording started to" << m_config.fileName << "@" << m_config.sampleRate
<< "hz in OGG/Vorbis format";
break;
#endif
case VoiceRecorderFormat::AU:
sfinfo.frames = 0;
sfinfo.samplerate = m_config.sampleRate;
sfinfo.channels = 1;
sfinfo.format = SF_ENDIAN_CPU | SF_FORMAT_AU | SF_FORMAT_FLOAT;
sfinfo.sections = 0;
sfinfo.seekable = 0;
qWarning() << "VoiceRecorder: recording started to" << m_config.fileName << "@" << m_config.sampleRate
<< "hz in AU format";
break;
case VoiceRecorderFormat::FLAC:
sfinfo.frames = 0;
sfinfo.samplerate = m_config.sampleRate;
sfinfo.channels = 1;
sfinfo.format = SF_FORMAT_FLAC | SF_FORMAT_PCM_24;
sfinfo.sections = 0;
sfinfo.seekable = 0;
qWarning() << "VoiceRecorder: recording started to" << m_config.fileName << "@" << m_config.sampleRate
<< "hz in FLAC format";
break;
#ifdef USE_SNDFILE_OPUS
case VoiceRecorderFormat::OPUS:
sfinfo.frames = 0;
sfinfo.samplerate = m_config.sampleRate;
sfinfo.channels = 1;
sfinfo.format = SF_FORMAT_OGG | SF_FORMAT_OPUS;
sfinfo.sections = 0;
sfinfo.seekable = 0;
qWarning() << "VoiceRecorder: recording started to" << m_config.fileName << "@" << m_config.sampleRate
<< "hz in OPUS format";
break;
#endif
#ifdef USE_SNDFILE_MP3
case VoiceRecorderFormat::MP3:
sfinfo.frames = 0;
sfinfo.samplerate = m_config.sampleRate;
sfinfo.channels = 1;
sfinfo.format = SF_FORMAT_MPEG | SF_FORMAT_MPEG_LAYER_III;
sfinfo.sections = 0;
sfinfo.seekable = 0;
qWarning() << "VoiceRecorder: recording started to" << m_config.fileName << "@" << m_config.sampleRate
<< "hz in MP3 format";
break;
#endif
}
Q_ASSERT(sf_format_check(&sfinfo));
return sfinfo;
}
bool VoiceRecorder::ensureFileIsOpenedFor(SF_INFO &soundFileInfo, boost::shared_ptr< RecordInfo > &ri) {
if (ri->soundFile) {
// Nothing to do
return true;
}
QString filename = expandTemplateVariables(m_config.fileName, ri->userName);
// Try to find a unique filename.
{
int cnt = 1;
QString nf(filename);
QFileInfo tfi(filename);
while (QFile::exists(nf)) {
nf = tfi.path() + QLatin1Char('/') + tfi.completeBaseName() + QString(QLatin1String(" (%1).")).arg(cnt)
+ tfi.suffix();
++cnt;
}
filename = nf;
}
qWarning() << "Recorder opens file" << filename;
QFileInfo fi(filename);
// Create the target path.
if (!QDir().mkpath(fi.absolutePath())) {
qWarning() << "Failed to create target directory: " << fi.absolutePath();
m_recording = false;
emit error(CreateDirectoryFailed, tr("Recorder failed to create directory '%1'").arg(fi.absolutePath()));
emit recording_stopped();
return false;
}
#ifdef Q_OS_WIN
// This is needed for unicode filenames on Windows.
ri->soundFile = sf_wchar_open(filename.toStdWString().c_str(), SFM_WRITE, &soundFileInfo);
#else
ri->soundFile = sf_open(qPrintable(filename), SFM_WRITE, &soundFileInfo);
#endif
if (!ri->soundFile) {
qWarning() << "Failed to open file for recorder: " << sf_strerror(nullptr);
m_recording = false;
emit error(CreateFileFailed, tr("Recorder failed to open file '%1'").arg(filename));
emit recording_stopped();
return false;
}
// Store the username in the title attribute of the file (if supported by the format).
sf_set_string(ri->soundFile, SF_STR_TITLE, qPrintable(ri->userName));
// Enable hard-clipping for non-float formats to prevent wrapping
if ((soundFileInfo.format & SF_FORMAT_SUBMASK) != SF_FORMAT_FLOAT
&& (soundFileInfo.format & SF_FORMAT_SUBMASK) != SF_FORMAT_VORBIS) {
sf_command(ri->soundFile, SFC_SET_CLIPPING, nullptr, SF_TRUE);
}
return true;
}
void VoiceRecorder::run() {
Q_ASSERT(!m_recording);
if (Global::get().sh && Global::get().sh->m_version < Version::fromComponents(1, 2, 3))
return;
SF_INFO soundFileInfo = createSoundFileInfo();
m_recording = true;
emit recording_started();
forever {
// Sleep until there is new data for us to process.
m_sleepLock.lock();
m_sleepCondition.wait(&m_sleepLock);
if (!m_recording || m_abort
|| (Global::get().sh && Global::get().sh->m_version < Version::fromComponents(1, 2, 3))) {
m_sleepLock.unlock();
break;
}
while (!m_abort && !m_recordBuffer.isEmpty()) {
boost::shared_ptr< RecordBuffer > rb;
{
QMutexLocker l(&m_bufferLock);
rb = m_recordBuffer.takeFirst();
}
// Create the file for this RecordInfo instance if it's not yet open.
Q_ASSERT(m_recordInfo.contains(rb->recordInfoIndex));
boost::shared_ptr< RecordInfo > ri = m_recordInfo.value(rb->recordInfoIndex);
if (!ensureFileIsOpenedFor(soundFileInfo, ri)) {
return;
}
const qint64 missingSamples =
static_cast< qint64 >(rb->absoluteStartSample) - static_cast< qint64 >(ri->lastWrittenAbsoluteSample);
static const qint64 heuristicSilenceThreshold = m_config.sampleRate / 10; // 100ms
if (missingSamples > heuristicSilenceThreshold) {
static const qint64 maxSamplesPerIteration = m_config.sampleRate * 1; // 1s
const bool requeue = missingSamples > maxSamplesPerIteration;
// Write |missingSamples| samples of silence up to |maxSamplesPerIteration|
const float buffer[1024] = {};
const qint64 silenceToWrite = std::min(missingSamples, maxSamplesPerIteration);
qint64 rest = silenceToWrite;
for (; rest > 1024; rest -= 1024)
sf_write_float(ri->soundFile, buffer, 1024);
if (rest > 0)
sf_write_float(ri->soundFile, buffer, rest);
ri->lastWrittenAbsoluteSample += static_cast< quint64 >(silenceToWrite);
if (requeue) {
// Requeue the writing for this buffer to keep thread responsive
QMutexLocker l(&m_bufferLock);
m_recordBuffer.prepend(rb);
continue;
}
}
// Write the audio buffer and update the timestamp in |ri|.
sf_write_float(ri->soundFile, rb->buffer.get(), rb->samples);
ri->lastWrittenAbsoluteSample += static_cast< quint64 >(rb->samples);
}
m_sleepLock.unlock();
}
m_recording = false;
{
QMutexLocker l(&m_bufferLock);
m_recordInfo.clear();
m_recordBuffer.clear();
}
emit recording_stopped();
qWarning() << "VoiceRecorder: recording stopped";
}
void VoiceRecorder::stop(bool force) {
// Tell the main loop to terminate and wake up the sleep lock.
m_recording = false;
m_abort = force;
m_sleepCondition.wakeAll();
}
void VoiceRecorder::prepareBufferAdds() {
// Should be ms accurat
m_absoluteSampleEstimation = (m_timestamp->elapsed() / 1000) * (static_cast< quint64 >(m_config.sampleRate) / 1000);
}
void VoiceRecorder::addBuffer(const ClientUser *clientUser, boost::shared_array< float > buffer, int samples) {
Q_ASSERT(!m_config.mixDownMode || !clientUser);
if (!m_recording)
return;
// Create a new RecordInfo object if this is a new user.
const int index = indexForUser(clientUser);
if (!m_recordInfo.contains(index)) {
boost::shared_ptr< RecordInfo > ri =
boost::make_shared< RecordInfo >(m_config.mixDownMode ? QLatin1String("Mixdown") : clientUser->qsName);
m_recordInfo.insert(index, ri);
}
{
// Save the buffer in |qlRecordBuffer|.
QMutexLocker l(&m_bufferLock);
boost::shared_ptr< RecordBuffer > rb =
boost::make_shared< RecordBuffer >(index, buffer, samples, m_absoluteSampleEstimation);
m_recordBuffer << rb;
}
// Tell the main loop that we have new audio data.
m_sleepCondition.wakeAll();
}
quint64 VoiceRecorder::getElapsedTime() const {
return m_timestamp->elapsed();
}
RecordUser &VoiceRecorder::getRecordUser() const {
return *m_recordUser;
}
bool VoiceRecorder::isInMixDownMode() const {
return m_config.mixDownMode;
}
QString VoiceRecorderFormat::getFormatDescription(VoiceRecorderFormat::Format fm) {
switch (fm) {
case VoiceRecorderFormat::WAV:
return VoiceRecorder::tr(".wav - Uncompressed");
#ifndef NO_VORBIS_RECORDING
case VoiceRecorderFormat::VORBIS:
return VoiceRecorder::tr(".ogg (Vorbis) - Compressed");
#endif
case VoiceRecorderFormat::AU:
return VoiceRecorder::tr(".au - Uncompressed");
case VoiceRecorderFormat::FLAC:
return VoiceRecorder::tr(".flac - Lossless compressed");
#ifdef USE_SNDFILE_OPUS
case VoiceRecorderFormat::OPUS:
return VoiceRecorder::tr(".opus - Lossy compressed");
#endif
#ifdef USE_SNDFILE_MP3
case VoiceRecorderFormat::MP3:
return VoiceRecorder::tr(".mp3 - Lossy compressed");
#endif
default:
return QString();
}
}
QString VoiceRecorderFormat::getFormatDefaultExtension(VoiceRecorderFormat::Format fm) {
switch (fm) {
case VoiceRecorderFormat::WAV:
return QLatin1String("wav");
#ifndef NO_VORBIS_RECORDING
case VoiceRecorderFormat::VORBIS:
return QLatin1String("ogg");
#endif
case VoiceRecorderFormat::AU:
return QLatin1String("au");
case VoiceRecorderFormat::FLAC:
return QLatin1String("flac");
#ifdef USE_SNDFILE_OPUS
case VoiceRecorderFormat::OPUS:
return QLatin1String("opus");
#endif
#ifdef USE_SNDFILE_MP3
case VoiceRecorderFormat::MP3:
return QLatin1String("mp3");
#endif
default:
return QString();
}
}