495 lines
15 KiB
C++
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();
|
|
}
|
|
}
|