// Copyright 2005-2019 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 . #include "mumble_pch.hpp" #include "VoiceRecorder.h" #include "AudioOutput.h" #include "ClientUser.h" #include "ServerHandler.h" #include "../Timer.h" // We define a global macro called 'g'. This can lead to issues when included code uses 'g' as a type or parameter name (like protobuf 3.7 does). As such, for now, we have to make this our last include. #include "Global.h" VoiceRecorder::RecordBuffer::RecordBuffer( int recordInfoIndex_, boost::shared_array buffer_, int samples_, quint64 absoluteStartSample_) : recordInfoIndex(recordInfoIndex_) , buffer(buffer_) , samples(samples_) , absoluteStartSample(absoluteStartSample_) { // Nothing } VoiceRecorder::RecordInfo::RecordInfo(const QString& userName_) : userName(userName_) , soundFile(NULL) , 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 (g.sh && g.uiSession != 0) { unsigned short port; QString uname, pw; g.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 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 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 == NULL); return (m_config.mixDownMode) ? 0 : 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.g. 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; } Q_ASSERT(sf_format_check(&sfinfo)); return sfinfo; } bool VoiceRecorder::ensureFileIsOpenedFor(SF_INFO& soundFileInfo, boost::shared_ptr& ri) { if (ri->soundFile != NULL) { // 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 == NULL) { qWarning() << "Failed to open file for recorder: "<< sf_strerror(NULL); 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, NULL, SF_TRUE); } return true; } void VoiceRecorder::run() { Q_ASSERT(!m_recording); if (g.sh && g.sh->uiVersion < 0x010203) 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 || (g.sh && g.sh->uiVersion < 0x010203)) { m_sleepLock.unlock(); break; } while (!m_abort && !m_recordBuffer.isEmpty()) { boost::shared_ptr 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 ri = m_recordInfo.value(rb->recordInfoIndex); if (!ensureFileIsOpenedFor(soundFileInfo, ri)) { return; } const qint64 missingSamples = rb->absoluteStartSample - 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 += 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 += 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) * (m_config.sampleRate / 1000); } void VoiceRecorder::addBuffer(const ClientUser *clientUser, boost::shared_array buffer, int samples) { Q_ASSERT(!m_config.mixDownMode || clientUser == NULL); 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 ri = boost::make_shared( m_config.mixDownMode ? QLatin1String("Mixdown") : clientUser->qsName); m_recordInfo.insert(index, ri); } { // Save the buffer in |qlRecordBuffer|. QMutexLocker l(&m_bufferLock); boost::shared_ptr rb = boost::make_shared( 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"); 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"); default: return QString(); } }