From 8c1deffda77bb045a124e58f8320bd19e196ad1b Mon Sep 17 00:00:00 2001 From: Peter L Jones Date: Wed, 3 Apr 2019 18:12:45 +0100 Subject: [PATCH] Add recording support with Reaper Project generation Includes the following changes * Initial .gitignore Administrative * Fix up warning message * Not all Windows file systems are case insensitive Bugfixes * (Qt5) Use QCoreApplication for headless Possible solution to get the application to run as a headless server but it loses the nice history graph, so not ideal. * Avoid ESC closing chat Because ESC shouldn't close the chat window. Or the main app window. * Add console logging support for Windows Whilst looking for the headless support, I found this idea for Windows logging. New improved version. This makes far fewer changes. ---- * Add recording support with Reaper Project generation The main feature! * New -r option to enable recording of PCM files and conversion to Reaper RPP with WAV files * New -R option to set the directory in which to create recording sessions You need to specify the -R option, there's no default... so I guess -r and -R could be combined. * New -T option to convert a session directory with PCM files into a Reaper RPP with WAV files You can use -T on "failed" sessions, if the -r option captures the PCMs but the RPP converter doesn't run for some reaon. (It was useful during development, maybe less so once things seem stable.) The recorder is implemented as a new thread with queuing from the main "real time" server thread. When a new client connects or if its audio format changes (e.g. mono to stereo), a new RIFF WAVE file is started. Each frame of decompressed audio for each client written out as LPCM to the file. When the client disconnects, the RIFF WAVE headers are updated to reflect the file length. Once all clients disconnect, the session is considered ended and a Reaper RPP file is written. --- .gitignore | 17 ++ Jamulus.pro | 12 +- src/chatdlg.cpp | 8 + src/chatdlg.h | 2 + src/clientdlg.cpp | 13 + src/clientdlg.h | 3 + src/global.h | 4 + src/main.cpp | 107 +++++++- src/recorder/creaperproject.cpp | 107 ++++++++ src/recorder/creaperproject.h | 70 ++++++ src/recorder/cwavestream.cpp | 121 +++++++++ src/recorder/cwavestream.h | 69 ++++++ src/recorder/jamrecorder.cpp | 418 ++++++++++++++++++++++++++++++++ src/recorder/jamrecorder.h | 156 ++++++++++++ src/server.cpp | 13 + src/server.h | 10 + src/serverdlg.cpp | 8 + src/serverdlg.h | 2 + src/serverlogging.cpp | 16 +- src/util.h | 9 + windows/sound.cpp | 2 +- 21 files changed, 1145 insertions(+), 22 deletions(-) create mode 100644 .gitignore create mode 100755 src/recorder/creaperproject.cpp create mode 100755 src/recorder/creaperproject.h create mode 100755 src/recorder/cwavestream.cpp create mode 100755 src/recorder/cwavestream.h create mode 100755 src/recorder/jamrecorder.cpp create mode 100755 src/recorder/jamrecorder.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..affbd25d --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.qmake.stash +Jamulus +Makefile +*.pro.user* +**.cppe +**.he +.cproject +.project +.settings +*.user +*.user.* +*.o +moc_*.cpp +ui_*.h +moc_predefs.h +src/res/qrc_resources.cpp +windows/ASIOSDK2 diff --git a/Jamulus.pro b/Jamulus.pro index eb4d1382..83cb9984 100755 --- a/Jamulus.pro +++ b/Jamulus.pro @@ -113,7 +113,7 @@ win32 { !exists(/usr/include/jack/jack.h) { !exists(/usr/local/include/jack/jack.h) { - message(Warning: jack.h was not found at the usual place, maybe the jack dev packet is missing) + message("Warning: jack.h was not found at the usual place, maybe the jack dev packet is missing") } } @@ -170,7 +170,10 @@ HEADERS += src/audiomixerboard.h \ src/soundbase.h \ src/testbench.h \ src/util.h \ - src/analyzerconsole.h + src/analyzerconsole.h \ + src/recorder/jamrecorder.h \ + src/recorder/creaperproject.h \ + src/recorder/cwavestream.h HEADERS_OPUS = libs/opus/include/opus.h \ libs/opus/include/opus_multistream.h \ @@ -264,7 +267,10 @@ SOURCES += src/audiomixerboard.cpp \ src/socket.cpp \ src/soundbase.cpp \ src/util.cpp \ - src/analyzerconsole.cpp + src/analyzerconsole.cpp \ + src/recorder/jamrecorder.cpp \ + src/recorder/creaperproject.cpp \ + src/recorder/cwavestream.cpp SOURCES_OPUS = libs/opus/src/opus.c \ libs/opus/src/opus_decoder.c \ diff --git a/src/chatdlg.cpp b/src/chatdlg.cpp index 270175b4..90b13e1e 100755 --- a/src/chatdlg.cpp +++ b/src/chatdlg.cpp @@ -102,3 +102,11 @@ void CChatDlg::AddChatText ( QString strChatText ) #endif ); } + +void CChatDlg::keyPressEvent(QKeyEvent *e) +{ + if (e->key() != Qt::Key_Escape) + { + QDialog::keyPressEvent(e); + } +} diff --git a/src/chatdlg.h b/src/chatdlg.h index 0a480399..cafbcdfd 100755 --- a/src/chatdlg.h +++ b/src/chatdlg.h @@ -48,6 +48,8 @@ public slots: void OnLocalInputTextTextChanged ( const QString& strNewText ); void OnClearPressed(); + void keyPressEvent(QKeyEvent *e); + signals: void NewLocalInputText ( QString strNewText ); }; diff --git a/src/clientdlg.cpp b/src/clientdlg.cpp index 8a519ed5..4decac1e 100755 --- a/src/clientdlg.cpp +++ b/src/clientdlg.cpp @@ -1200,3 +1200,16 @@ rbtReverbSelR->setStyleSheet ( "" ); // also apply GUI design to child GUI controls MainMixerBoard->SetGUIDesign ( eNewDesign ); } + +void CClientDlg::accept() +{ + close(); +} + +void CClientDlg::keyPressEvent(QKeyEvent *e) +{ + if (e->key() != Qt::Key_Escape) + { + QDialog::keyPressEvent(e); + } +} diff --git a/src/clientdlg.h b/src/clientdlg.h index d53fd003..2c91925c 100755 --- a/src/clientdlg.h +++ b/src/clientdlg.h @@ -205,4 +205,7 @@ public slots: void OnAudioChannelsChanged() { UpdateRevSelection(); } void OnNumClientsChanged ( int iNewNumClients ); void OnNewClientLevelChanged() { MainMixerBoard->iNewClientFaderLevel = pClient->iNewClientFaderLevel; } + + void accept(); + void keyPressEvent(QKeyEvent *e); }; diff --git a/src/global.h b/src/global.h index dad51ece..1d2c2beb 100755 --- a/src/global.h +++ b/src/global.h @@ -50,6 +50,10 @@ LED bar: lbr #if !defined ( GLOBAL_H__3B123453_4344_BB2B_23E7A0D31912__INCLUDED_ ) #define GLOBAL_H__3B123453_4344_BB2B_23E7A0D31912__INCLUDED_ +#if _WIN32 +#define _CRT_SECURE_NO_WARNINGS +#endif + #include #include #include diff --git a/src/main.cpp b/src/main.cpp index 68114b3f..4b7fc354 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -33,17 +33,41 @@ #include "testbench.h" + +/******************************************************************************\ +* Console logging * +\******************************************************************************/ +// Try for a portable console -------------------------------------------------- + +QTextStream* ConsoleWriterFactory::get() +{ + if (ptsConsole == nullptr) + { +#if _WIN32 + if (!AttachConsole(ATTACH_PARENT_PROCESS)) + { + // Not run from console, dump logging to nowhere + static QString conout; + ptsConsole = new QTextStream ( &conout ); + } + else + { + freopen("CONOUT$", "w", stdout); + ptsConsole = new QTextStream ( stdout ); + } +#else + ptsConsole = new QTextStream ( stdout ); +#endif + } + return ptsConsole; +} + // Implementation ************************************************************** int main ( int argc, char** argv ) { -#ifdef _WIN32 - // no console on windows -> just write in string and dump it - QString strDummySink; - QTextStream tsConsole ( &strDummySink ); -#else - QTextStream tsConsole ( stdout ); -#endif + QTextStream& tsConsole = *((new ConsoleWriterFactory())->get()); + QString strArgument; double rDbleArgument; @@ -56,6 +80,7 @@ int main ( int argc, char** argv ) bool bDisconnectAllClients = false; bool bShowAnalyzerConsole = false; bool bCentServPingServerInList = false; + bool bEnableRecording = false; int iNumServerChannels = DEFAULT_USED_NUM_CHANNELS; int iCtrlMIDIChannel = INVALID_MIDI_CH; quint16 iPortNumber = LLCON_DEFAULT_PORT_NUMBER; @@ -66,6 +91,8 @@ int main ( int argc, char** argv ) QString strServerName = ""; QString strLoggingFileName = ""; QString strHistoryFileName = ""; + QString strRecordingDirName = ""; + QString strSessionDirName = ""; QString strCentralServer = ""; QString strServerInfo = ""; QString strWelcomeMessage = ""; @@ -157,6 +184,18 @@ int main ( int argc, char** argv ) } + // Enable recording at the server -------------------------------------- + if ( GetFlagArgument ( argv, + i, + "-r", + "--enablerecording" ) ) + { + bEnableRecording = true; + tsConsole << "- enabling recording" << endl; + continue; + } + + // Show all registered servers in the server list ---------------------- // Undocumented debugging command line argument: Show all registered // servers in the server list regardless if a ping to the server is @@ -292,6 +331,37 @@ int main ( int argc, char** argv ) } + // Recording directory ------------------------------------------------- + if ( GetStringArgument ( tsConsole, + argc, + argv, + i, + "-R", + "--recordingdirectory", + strArgument ) ) + { + strRecordingDirName = strArgument; + tsConsole << "- recording directory name: " << strRecordingDirName << endl; + continue; + } + + + // Convert a recording session to a Reaper Project --------------------- + if ( GetStringArgument ( tsConsole, + argc, + argv, + i, + "-T", + "--toreaper", + strArgument ) ) + { + bUseGUI = false; + strSessionDirName = strArgument; + tsConsole << "- convert " << strSessionDirName << " to Reaper project (no GUI)" << endl; + continue; + } + + // Central server ------------------------------------------------------ if ( GetStringArgument ( tsConsole, argc, @@ -402,7 +472,15 @@ int main ( int argc, char** argv ) // Application/GUI setup --------------------------------------------------- // Application object - QApplication app ( argc, argv, bUseGUI ); + if (!bUseGUI && !strHistoryFileName.isEmpty()) + { + tsConsole << "Qt5 requires a windowing system to paint a JPEG image; disabling history graph" << endl; + strHistoryFileName = ""; + } + QCoreApplication* _app = bUseGUI + ? new QApplication ( argc, argv ) + : new QCoreApplication ( argc, argv ); +#define app (*_app) #ifdef _WIN32 // set application priority class -> high priority @@ -425,7 +503,11 @@ int main ( int argc, char** argv ) try { - if ( bIsClient ) + if ( !strSessionDirName.isEmpty() ) + { + CJamRecorder::SessionDirToReaper(strSessionDirName); + } + else if ( bIsClient ) { // Client: // actual client object @@ -473,10 +555,11 @@ int main ( int argc, char** argv ) strCentralServer, strServerInfo, strWelcomeMessage, + strRecordingDirName, + bEnableRecording, bCentServPingServerInList, bDisconnectAllClients, eLicenceType ); - if ( bUseGUI ) { // special case for the GUI mode: as the default we want to use @@ -571,7 +654,11 @@ QString UsageArguments ( char **argv ) " [server1 country as QLocale ID]; ...\n" " [server2 address]; ... (server only)\n" " -p, --port local port number (server only)\n" + " -r --enablerecording create recordings of jam sessions (server only)\n" + " -R, --recordingdirectory\n" + " directory to contain recorded jams (server only)\n" " -s, --server start server\n" + " -T, --toreaper create Reaper project from session in named directory\n" " -u, --numchannels maximum number of channels (server only)\n" " -w, --welcomemessage welcome message on connect (server only)\n" " -y, --history enable connection history and set file\n" diff --git a/src/recorder/creaperproject.cpp b/src/recorder/creaperproject.cpp new file mode 100755 index 00000000..9800c15f --- /dev/null +++ b/src/recorder/creaperproject.cpp @@ -0,0 +1,107 @@ +#include "creaperproject.h" + +/** + * @brief operator << Write details of the STrackItem to the QTextStream + * @param os the QTextStream + * @param trackItem the STrackItem + * @return the QTextStream + * + * Note: unused? + */ +QTextStream& operator<<(QTextStream& os, const recorder::STrackItem& trackItem) +{ + os << "_track( " + << "numAudioChannels(" << trackItem.numAudioChannels << ")" + << ", startFrame(" << trackItem.startFrame << ")" + << ", frameCount(" << trackItem.frameCount << ")" + << ", fileName(" << trackItem.fileName << ")" + << " );"; + return os; +} + +/******************************************************************************\ +* recorder methods * +\******************************************************************************/ +using namespace recorder; + +// Reaper Project writer ------------------------------------------------------- + +/** + * @brief CReaperItem::CReaperItem Construct a Reaper RPP "" for a given RIFF WAVE file + * @param name the item name + * @param trackItem the details of where the item is in the track, along with the RIFF WAVE filename + * @param iid the sequential item id + */ +CReaperItem::CReaperItem(const QString& name, const STrackItem& trackItem, const qint32& iid) +{ + QString wavName = trackItem.fileName; // assume RPP in same location... + + QTextStream sOut(&out); + + sOut << " " << endl; + + sOut << " >"; + + sOut.flush(); +} + +/** + * @brief CReaperTrack::CReaperTrack Construct a Reaper RPP "" for a given list of track items + * @param name the track name + * @param iid the sequential track id + * @param items the list of items in the track + */ +CReaperTrack::CReaperTrack(QString name, qint32& iid, QList items) +{ + QTextStream sOut(&out); + + sOut << " "; + + sOut.flush(); +} + +/** + * @brief CReaperProject::CReaperProject Construct a Reaper RPP "" for a given list of tracks + * @param tracks the list of tracks + */ +CReaperProject::CReaperProject(QMap> tracks) +{ + QTextStream sOut(&out); + + sOut << ""; + + sOut.flush(); +} diff --git a/src/recorder/creaperproject.h b/src/recorder/creaperproject.h new file mode 100755 index 00000000..6743ecf1 --- /dev/null +++ b/src/recorder/creaperproject.h @@ -0,0 +1,70 @@ +#ifndef CREAPERPROJECT_H +#define CREAPERPROJECT_H + +#include +#include +#include +#include "util.h" + +#include "cwavestream.h" + +namespace recorder { + +struct STrackItem +{ + STrackItem(int numAudioChannels, qint64 startFrame, qint64 frameCount, QString fileName) : + numAudioChannels(numAudioChannels), + startFrame(startFrame), + frameCount(frameCount), + fileName(fileName) + { + } + int numAudioChannels; + qint64 startFrame; + qint64 frameCount; + QString fileName; +}; + +class CReaperItem : public QObject +{ + Q_OBJECT + +public: + CReaperItem(const QString& name, const STrackItem& trackItem, const qint32& iid); + QString toString() { return out; } + +private: + const QUuid iguid = QUuid::createUuid(); + const QUuid guid = QUuid::createUuid(); + QString out; + + inline QString secondsAt48K(const qint64 frames) { return QString::number(static_cast(frames * SYSTEM_FRAME_SIZE_SAMPLES) / 48000, 'f', 14); } +}; + +class CReaperTrack : public QObject +{ + Q_OBJECT + +public: + CReaperTrack(QString name, qint32 &iid, QList items); + QString toString() { return out; } + +private: + QUuid trackId = QUuid::createUuid(); + QString out; +}; + +class CReaperProject : public QObject +{ + Q_OBJECT + +public: + CReaperProject(QMap > tracks); + QString toString() { return out; } + +private: + QString out; +}; + +} +#endif // CREAPERPROJECT_H diff --git a/src/recorder/cwavestream.cpp b/src/recorder/cwavestream.cpp new file mode 100755 index 00000000..5fc11912 --- /dev/null +++ b/src/recorder/cwavestream.cpp @@ -0,0 +1,121 @@ +#include "cwavestream.h" + +/******************************************************************************\ +* Overrides in global namespace * +\******************************************************************************/ + +/** + * @brief operator << Emit a hdr_riff object to a QDataStream + * @param os a QDataStream + * @param obj a hdr_riff object + * @return the QDataStream passed + */ +recorder::CWaveStream& operator<<(recorder::CWaveStream& os, const recorder::HdrRiff& obj) +{ + (QDataStream&)os << obj.chunkId << obj.chunkSize << obj.format; + return os; +} + +/** + * @brief operator << Emit a fmtSubChunk object to a QDataStream + * @param os a QDataStream + * @param obj a fmtSubChunk object + * @return the QDataStream passed + */ +recorder::CWaveStream& operator<<(recorder::CWaveStream& os, const recorder::FmtSubChunk& obj) +{ + (QDataStream&)os << obj.chunkId + << obj.chunkSize + << obj.audioFormat + << obj.numChannels + << obj.sampleRate + << obj.byteRate + << obj.blockAlign + << obj.bitsPerSample + ; + return os; +} + +/** + * @brief operator << Emit a dataSubChunkHdr object to a QDataStream + * @param os a QDataStream + * @param obj a dataSubChunkHdr object + * @return the QDataStream passed + */ +recorder::CWaveStream& operator<<(recorder::CWaveStream& os, const recorder::DataSubChunkHdr& obj) +{ + (QDataStream&)os << obj.chunkId << obj.chunkSize; + return os; +} + +/******************************************************************************\ +* Implementations of recorder.CWaveStream methods * +\******************************************************************************/ + +using namespace recorder; + +CWaveStream::CWaveStream(const uint16_t numChannels) : + QDataStream(), + numChannels (numChannels), + initialPos (device()->pos()), + initialByteOrder (byteOrder()) +{ + waveStreamHeaders(); +} +CWaveStream::CWaveStream(QIODevice *iod, const uint16_t numChannels) : + QDataStream(iod), + numChannels (numChannels), + initialPos (device()->pos()), + initialByteOrder (byteOrder()) +{ + waveStreamHeaders(); +} +CWaveStream::CWaveStream(QByteArray *iod, QIODevice::OpenMode flags, const uint16_t numChannels) : + QDataStream(iod, flags), + numChannels (numChannels), + initialPos (device()->pos()), + initialByteOrder (byteOrder()) +{ + waveStreamHeaders(); +} +CWaveStream::CWaveStream(const QByteArray &ba, const uint16_t numChannels) : + QDataStream(ba), + numChannels (numChannels), + initialPos (device()->pos()), + initialByteOrder (byteOrder()) +{ + waveStreamHeaders(); +} + +void CWaveStream::waveStreamHeaders() +{ + static const HdrRiff scHdrRiff; + const FmtSubChunk cFmtSubChunk (numChannels); + static const DataSubChunkHdr scDataSubChunkHdr; + + setByteOrder(LittleEndian); + *this << scHdrRiff << cFmtSubChunk << scDataSubChunkHdr; +} + +CWaveStream::~CWaveStream() +{ + static const uint32_t hdrRiffChunkSizeOffset = sizeof(uint32_t); + static const uint32_t dataSubChunkHdrChunkSizeOffset = sizeof(HdrRiff) + sizeof(FmtSubChunk) + sizeof (uint32_t) + sizeof (uint32_t); + + const int64_t currentPos = this->device()->pos(); + const uint32_t fileLength = static_cast(currentPos - initialPos); + + QDataStream& out = static_cast(*this); + + // Overwrite hdr_riff.chunkSize + this->device()->seek(initialPos + hdrRiffChunkSizeOffset); + out << static_cast(fileLength - (hdrRiffChunkSizeOffset + sizeof (uint32_t))); + + // Overwrite dataSubChunkHdr.chunkSize + this->device()->seek(initialPos + dataSubChunkHdrChunkSizeOffset); + out << static_cast(fileLength - (dataSubChunkHdrChunkSizeOffset + sizeof (uint32_t))); + + // and then restore the position and byte order + this->device()->seek(currentPos); + setByteOrder(initialByteOrder); +} diff --git a/src/recorder/cwavestream.h b/src/recorder/cwavestream.h new file mode 100755 index 00000000..64413ec9 --- /dev/null +++ b/src/recorder/cwavestream.h @@ -0,0 +1,69 @@ +#ifndef CWAVESTREAM_H +#define CWAVESTREAM_H + +#include + +namespace recorder { + +class HdrRiff +{ +public: + HdrRiff() {} + + static const uint32_t chunkId = 0x46464952; // RIFF + static const uint32_t chunkSize = 0xffffffff; // (will be overwritten) Size of file in bytes - 8 = size of data + 36 + static const uint32_t format = 0x45564157; // WAVE +}; + +class FmtSubChunk +{ +public: + FmtSubChunk(const uint16_t _numChannels) : + numChannels (_numChannels) + , byteRate (sampleRate * numChannels * bitsPerSample/8) + , blockAlign (numChannels * bitsPerSample/8) + { + } + static const uint32_t chunkId = 0x20746d66; // "fmt " + static const uint32_t chunkSize = 16; // bytes in fmtSubChunk after chunkSize + static const uint16_t audioFormat = 1; // PCM + const uint16_t numChannels; // 1 for mono, 2 for joy... uh, stereo + static const uint32_t sampleRate = 48000; // because it's Jamulus + const uint32_t byteRate; // sampleRate * numChannels * bitsPerSample/8 + const uint16_t blockAlign; // numChannels * bitsPerSample/8 + static const uint16_t bitsPerSample = 16; +}; + +class DataSubChunkHdr +{ +public: + DataSubChunkHdr() {} + + static const uint32_t chunkId = 0x61746164; // "data" + static const uint32_t chunkSize = 0xffffffff; // (will be overwritten) Size of data +}; + +class CWaveStream : public QDataStream +{ +public: + CWaveStream(const uint16_t numChannels); + explicit CWaveStream(QIODevice *iod, const uint16_t numChannels); + CWaveStream(QByteArray *iod, QIODevice::OpenMode flags, const uint16_t numChannels); + CWaveStream(const QByteArray &ba, const uint16_t numChannels); + ~CWaveStream(); + +private: + void waveStreamHeaders(); + + const uint16_t numChannels; + const int64_t initialPos; + const ByteOrder initialByteOrder; +}; + +} + +recorder::CWaveStream& operator<<(recorder::CWaveStream& out, recorder::HdrRiff& hdrRiff); +recorder::CWaveStream& operator<<(recorder::CWaveStream& out, recorder::FmtSubChunk& fmtSubChunk); +recorder::CWaveStream& operator<<(recorder::CWaveStream& out, recorder::DataSubChunkHdr& dataSubChunkHdr); + +#endif // CWAVESTREAM_H diff --git a/src/recorder/jamrecorder.cpp b/src/recorder/jamrecorder.cpp new file mode 100755 index 00000000..43329431 --- /dev/null +++ b/src/recorder/jamrecorder.cpp @@ -0,0 +1,418 @@ +#include "jamrecorder.h" + +using namespace recorder; + +/* ******************************************************************************************************** + * CJamClient + * ********************************************************************************************************/ + +/** + * @brief CJamClient::CJamClient + * @param frame Start frame of the client within the session + * @param numChannels 1 for mono, 2 for stereo + * @param name The client's current name + * @param address IP and Port + * @param recordBaseDir Session recording directory + * + * Creates a file for the raw PCM data and sets up a QDataStream to which to write received frames. + * The data is stored Little Endian. + */ +CJamClient::CJamClient(const qint64 frame, const int _numChannels, const QString name, const CHostAddress address, const QDir recordBaseDir) : + startFrame (frame), + numChannels (static_cast(_numChannels)), + name (name), + address (address) +{ + // At this point we may not have much of a name + QString fileName = ClientName() + "-" + QString::number(frame) + "-" + QString::number(_numChannels); + QString affix = ""; + while (recordBaseDir.exists(fileName + affix + ".wav")) + { + affix = affix.length() == 0 ? "_1" : "_" + QString::number(affix.remove(0, 1).toInt() + 1); + } + fileName = fileName + affix + ".wav"; + + wavFile = new QFile(recordBaseDir.absoluteFilePath(fileName)); + if (!wavFile->open(QFile::OpenMode(QIODevice::OpenModeFlag::ReadWrite))) // need to allow rewriting headers + { + throw new std::runtime_error( ("Could not write to WAV file " + wavFile->fileName()).toStdString() ); + } + out = new CWaveStream(wavFile, numChannels); + + filename = wavFile->fileName(); +} + +/** + * @brief CJamClient::Frame Handle a frame of PCM data from a client connected to the server + * @param _name The client's current name + * @param pcm The PCM data + */ +void CJamClient::Frame(const QString _name, const CVector& pcm) +{ + name = _name; + + for(int i = 0; i < numChannels * SYSTEM_FRAME_SIZE_SAMPLES; i++) + { + *out << pcm[i]; + } + + frameCount++; +} + +/** + * @brief CJamClient::Disconnect Clean up after a disconnected client + */ +void CJamClient::Disconnect() +{ + delete out; + out = nullptr; + + wavFile->close(); + + delete wavFile; + wavFile = nullptr; +} + +/* ******************************************************************************************************** + * CJamSession + * ********************************************************************************************************/ + +/** + * @brief CJamSession::CJamSession Construct a new jam recording session + * @param recordBaseDir The recording base directory + * + * Each session is stored into its own subdirectory of the recording base directory. + */ +CJamSession::CJamSession(QDir recordBaseDir) : + sessionDir (QDir(recordBaseDir.absoluteFilePath("Jam-" + QDateTime().currentDateTimeUtc().toString("yyyyMMdd-HHmmsszzz")))), + currentFrame (0), + vecptrJamClients (MAX_NUM_CHANNELS), + jamClientConnections() +{ + const QFileInfo fi(sessionDir.absolutePath()); + + if (!fi.exists() && !QDir().mkpath(sessionDir.absolutePath())) + { + throw std::runtime_error( (sessionDir.absolutePath() + " does not exist but could not be created").toStdString() ); + } + if (!fi.isDir()) + { + throw std::runtime_error( (sessionDir.absolutePath() + " exists but is not a directory").toStdString() ); + } + if (!fi.isWritable()) + { + throw std::runtime_error( (sessionDir.absolutePath() + " is a directory but cannot be written to").toStdString() ); + } + + // Explicitly set all the pointers to "empty" + vecptrJamClients.fill(nullptr); + + currentFrame = 0; +} + +/** + * @brief CJamSession::DisconnectClient Capture details of the departing client's connection + * @param iChID the channel id of the client that disconnected + */ +void CJamSession::DisconnectClient(int iChID) +{ + vecptrJamClients[iChID]->Disconnect(); + + jamClientConnections.append(new CJamClientConnection(vecptrJamClients[iChID]->NumAudioChannels(), + vecptrJamClients[iChID]->StartFrame(), + vecptrJamClients[iChID]->FrameCount(), + vecptrJamClients[iChID]->ClientName(), + vecptrJamClients[iChID]->FileName())); + + delete vecptrJamClients[iChID]; + vecptrJamClients[iChID] = nullptr; +} + +/** + * @brief CJamSession::Frame Process a frame emited for a client by the server + * @param iChID the client channel id + * @param name the client name + * @param address the client IP and port number + * @param numAudioChannels the client number of audio channels + * @param data the frame data + * + * Manages changes that affect how the recording is stored - i.e. if the number of audio channels changes, we need a new file. + * Files are grouped by IP and port number, so if either of those change for a connection, we also start a new file. + * + * Also manages the overall current frame counter for the session. + */ +void CJamSession::Frame(const int iChID, const QString name, const CHostAddress address, const int numAudioChannels, const CVector data) +{ + if (vecptrJamClients[iChID] == nullptr) + { + // then we have not seen this client this session + vecptrJamClients[iChID] = new CJamClient(currentFrame, numAudioChannels, name, address, sessionDir); + } + else if (numAudioChannels != vecptrJamClients[iChID]->NumAudioChannels() + || address.InetAddr != vecptrJamClients[iChID]->ClientAddress().InetAddr + || address.iPort != vecptrJamClients[iChID]->ClientAddress().iPort) + { + DisconnectClient(iChID); + if (numAudioChannels == 0) + { + vecptrJamClients[iChID] = nullptr; + } + else + { + vecptrJamClients[iChID] = new CJamClient(currentFrame, numAudioChannels, name, address, sessionDir); + } + } + + if (vecptrJamClients[iChID] == nullptr) + { + // Frame allegedly from iChID but unable to establish client details + return; + } + + vecptrJamClients[iChID]->Frame(name, data); + + // If _any_ connected client frame steps past currentFrame, increase currentFrame + if (vecptrJamClients[iChID]->StartFrame() + vecptrJamClients[iChID]->FrameCount() > currentFrame) + { + currentFrame++; + } +} + +/** + * @brief CJamSession::End Clean up any "hanging" clients when the server thinks they all left + */ +void CJamSession::End() +{ + for (int iChID = 0; iChID < vecptrJamClients.size(); iChID++) + { + if (vecptrJamClients[iChID] != nullptr) + { + DisconnectClient(iChID); + vecptrJamClients[iChID] = nullptr; + } + } +} + +/** + * @brief CJamSession::Tracks Retrieve a map of (latest) client name to connection items + * @return a map of (latest) client name to connection items + */ +QMap> CJamSession::Tracks() +{ + QMap> tracks; + + for (int i = 0; i < jamClientConnections.count(); i++ ) + { + STrackItem track ( + jamClientConnections[i]->Format(), + jamClientConnections[i]->StartFrame(), + jamClientConnections[i]->Length(), + jamClientConnections[i]->FileName() + ); + + if (!tracks.contains(jamClientConnections[i]->Name())) + { + tracks.insert(jamClientConnections[i]->Name(), { }); + } + + tracks[jamClientConnections[i]->Name()].append(track); + } + + return tracks; +} + +/** + * @brief CJamSession::TracksFromSessionDir Replica of CJamSession::Tracks but using the directory contents to construct the track item map + * @param sessionDirName the directory name to scan + * @return a map of (latest) client name to connection items + */ +QMap> CJamSession::TracksFromSessionDir(const QString& sessionDirName) +{ + QMap> tracks; + + const QDir sessionDir(sessionDirName); + foreach(auto entry, sessionDir.entryList({ "*.pcm" })) + { + + auto split = entry.split(".")[0].split("-"); + QString name = split[0]; + QString hostPort = split[1]; + QString frame = split[2]; + QString tail = split[3]; //numChannels may have _nnn + QString numChannels = tail.count("_") > 0 ? tail.split("_")[0] : tail; + + QString trackName = name + "-" + hostPort; + if (!tracks.contains(trackName)) + { + tracks.insert(trackName, { }); + } + + QFileInfo fiEntry(sessionDir.absoluteFilePath(entry)); + qint64 length = fiEntry.size() / numChannels.toInt() / SYSTEM_FRAME_SIZE_SAMPLES; + + STrackItem track ( + numChannels.toInt(), + frame.toLongLong(), + length, + sessionDir.absoluteFilePath(entry) + ); + + tracks[trackName].append(track); + } + + return tracks; +} + +/* ******************************************************************************************************** + * CJamRecorder + * ********************************************************************************************************/ + +/** + * @brief CJamRecorder::CJamRecorder Create recording directory, if necessary, and connect signal handlers + * @param server Server object emiting signals + * @param recordingDirName Requested recording directory name + */ +CJamRecorder::CJamRecorder(const CServer* server, const QString recordingDirName) : + recordBaseDir (recordingDirName), + isRecording (false) +{ + const QFileInfo fi(recordBaseDir.absolutePath()); + + if (!fi.exists() && !QDir().mkpath(recordBaseDir.absolutePath())) + { + throw std::runtime_error( (recordBaseDir.absolutePath() + " does not exist but could not be created").toStdString() ); + } + if (!fi.isDir()) + { + throw std::runtime_error( (recordBaseDir.absolutePath() + " exists but is not a directory").toStdString() ); + } + if (!fi.isWritable()) + { + throw std::runtime_error( (recordBaseDir.absolutePath() + " is a directory but cannot be written to").toStdString() ); + } + + QObject::connect((const QObject *)server, SIGNAL ( Stopped() ), + this, SLOT( OnEnd() ), + Qt::ConnectionType::QueuedConnection); + + QObject::connect((const QObject *)server, SIGNAL ( ClientDisconnected(int) ), + this, SLOT( OnDisconnected(int) ), + Qt::ConnectionType::QueuedConnection); + + qRegisterMetaType>(); + QObject::connect((const QObject *)server, SIGNAL ( Frame(const int, const QString, const CHostAddress, const int, const CVector) ), + this, SLOT( OnFrame(const int, const QString, const CHostAddress, const int, const CVector) ), + Qt::ConnectionType::QueuedConnection); +} + +/** + * @brief CJamRecorder::OnStart Start up tasks when the first client connects + */ +void CJamRecorder::OnStart() { + // Ensure any previous cleaning up has been done. + OnEnd(); + + currentSession = new CJamSession(recordBaseDir); + isRecording = true; +} + +/** + * @brief CJamRecorder::OnEnd Finalise the recording and emit the Reaper RPP file + */ +void CJamRecorder::OnEnd() +{ + if (isRecording) + { + isRecording = false; + currentSession->End(); + + QString reaperProjectFileName = currentSession->SessionDir().filePath(currentSession->Name().append(".rpp")); + const QFileInfo fi(reaperProjectFileName); + + if (fi.exists()) + { + tsConsole << "CJamRecorder::OnEnd() - " << fi.absolutePath() << " exists and will not be overwritten." << endl; + } + else + { + QFile outf (reaperProjectFileName); + outf.open(QFile::WriteOnly); + QTextStream out(&outf); + out << CReaperProject(currentSession->Tracks()).toString() << endl; + tsConsole << "Session RPP: " << reaperProjectFileName << endl; + } + + delete currentSession; + currentSession = nullptr; + } +} + +/** + * @brief CJamRecorder::SessionDirToReaper Replica of CJamRecorder::OnEnd() but using the directory contents to construct the CReaperProject object + * @param strSessionDirName + */ +void CJamRecorder::SessionDirToReaper(QString& strSessionDirName) +{ + const QFileInfo fiSessionDir(QDir::cleanPath(strSessionDirName)); + if (!fiSessionDir.exists() || !fiSessionDir.isDir()) + { + throw std::runtime_error( (fiSessionDir.absoluteFilePath() + " does not exist or is not a directory. Aborting.").toStdString() ); + } + + const QDir dSessionDir(fiSessionDir.absoluteFilePath()); + const QString reaperProjectFileName = dSessionDir.absoluteFilePath(fiSessionDir.baseName().append(".rpp")); + const QFileInfo fiRPP(reaperProjectFileName); + if (fiRPP.exists()) + { + throw std::runtime_error( (fiRPP.absoluteFilePath() + " exists and will not be overwritten. Aborting.").toStdString() ); + } + + QFile outf (fiRPP.absoluteFilePath()); + if (!outf.open(QFile::WriteOnly)) { + throw std::runtime_error( (fiRPP.absoluteFilePath() + " could not be written. Aborting.").toStdString() ); + } + QTextStream out(&outf); + + out << CReaperProject(CJamSession::TracksFromSessionDir(fiSessionDir.absoluteFilePath())).toString() << endl; + + (*(new ConsoleWriterFactory())->get()) << "Session RPP: " << reaperProjectFileName << endl; +} + +/** + * @brief CJamRecorder::OnDisconnected Handle disconnection of a client + * @param iChID the client channel id + */ +void CJamRecorder::OnDisconnected(int iChID) +{ + if (!isRecording) + { + tsConsole << "CJamRecorder::OnDisconnected: channel " << iChID << " disconnected but not recording" << endl; + } + if (currentSession == nullptr) + { + tsConsole << "CJamRecorder::OnDisconnected: channel " << iChID << " disconnected but no currentSession" << endl; + return; + } + currentSession->DisconnectClient(iChID); +} + +/** + * @brief CJamRecorder::OnFrame Handle a frame emited for a client by the server + * @param iChID the client channel id + * @param name the client name + * @param address the client IP and port number + * @param numAudioChannels the client number of audio channels + * @param data the frame data + * + * Ensures recording has started. + */ +void CJamRecorder::OnFrame(const int iChID, const QString name, const CHostAddress address, const int numAudioChannels, const CVector data) +{ + // Make sure we are ready + if (!isRecording) + { + OnStart(); + } + + currentSession->Frame(iChID, name, address, numAudioChannels, data); +} diff --git a/src/recorder/jamrecorder.h b/src/recorder/jamrecorder.h new file mode 100755 index 00000000..66274310 --- /dev/null +++ b/src/recorder/jamrecorder.h @@ -0,0 +1,156 @@ +#ifndef JAMRECORDER_H +#define JAMRECORDER_H + +#include +#include +#include + +#include "../util.h" +#include "../channel.h" + +#include "creaperproject.h" +#include "cwavestream.h" + +namespace recorder { + +class CJamClientConnection : public QObject +{ + Q_OBJECT + +public: + CJamClientConnection(const int _numAudioChannels, const qint64 _startFrame, const qint64 _length, const QString _name, const QString _fileName) : + numAudioChannels (_numAudioChannels), + startFrame (_startFrame), + length (_length), + name (_name), + fileName (_fileName) + { + } + + int Format() { return numAudioChannels; } + qint64 StartFrame() { return startFrame; } + qint64 Length() { return length; } + QString Name() { return name; } + QString FileName() { return fileName; } + +private: + const int numAudioChannels; + const qint64 startFrame; + const qint64 length; + const QString name; + const QString fileName; +}; + +class CJamClient : public QObject +{ + Q_OBJECT + +public: + CJamClient(const qint64 frame, const int numChannels, const QString name, const CHostAddress address, const QDir recordBaseDir); + + void Frame(const QString name, const CVector& pcm); + + void Disconnect(); + + qint64 StartFrame() { return startFrame; } + qint64 FrameCount() { return frameCount; } + uint16_t NumAudioChannels() { return numChannels; } + QString ClientName() { return name.leftJustified(4, '_', false).replace(QRegExp("[-.:/\\ ]"), "_") + .append("-") + .append(address.toString(CHostAddress::EStringMode::SM_IP_NO_LAST_BYTE_PORT).replace(QRegExp("[-.:/\\ ]"), "_")) + ; + } + CHostAddress ClientAddress() { return address; } + + QString FileName() { return filename; } + +private: + const qint64 startFrame; + const uint16_t numChannels; + QString name; + const CHostAddress address; + + QString filename; + QFile* wavFile; + QDataStream* out; + qint64 frameCount = 0; +}; + +class CJamSession : public QObject +{ + Q_OBJECT + +public: + + CJamSession(QDir recordBaseDir); + + void Frame(const int iChID, const QString name, const CHostAddress address, const int numAudioChannels, const CVector data); + + void End(); + + QVector Clients() { return vecptrJamClients; } + + QMap> Tracks(); + + QString Name() { return sessionDir.dirName(); } + + const QDir SessionDir() { return sessionDir; } + + void DisconnectClient(int iChID); + + static QMap> TracksFromSessionDir(const QString& name); + +private: + CJamSession(); + + const QDir sessionDir; + + qint64 currentFrame; + QVector vecptrJamClients; + QList jamClientConnections; +}; + +class CJamRecorder : public QThread +{ + Q_OBJECT + +public: + CJamRecorder(const CServer* server, const QString recordingDirName); + + static void SessionDirToReaper(QString& strSessionDirName); + +public slots: + /** + * @brief Raised when first client joins the server, triggering a new recording. + */ + void OnStart(); + + /** + * @brief Raised when last client leaves the server, ending the recording. + */ + void OnEnd(); + + /** + * @brief Raised when an existing client leaves the server. + * @param iChID channel number of client + */ + void OnDisconnected(int iChID); + + /** + * @brief Raised when a frame of data fis available to process + */ + void OnFrame(const int iChID, const QString name, const CHostAddress address, const int numAudioChannels, const CVector data); + +private: + QDir recordBaseDir; + + bool isRecording; + CJamSession* currentSession; + QTextStream& tsConsole = *((new ConsoleWriterFactory())->get()); +}; + +} + +Q_DECLARE_METATYPE(int16_t) +Q_DECLARE_METATYPE(CVector) +#endif // JAMRECORDER_H diff --git a/src/server.cpp b/src/server.cpp index 410b4c59..cabc2e3f 100755 --- a/src/server.cpp +++ b/src/server.cpp @@ -206,6 +206,8 @@ CServer::CServer ( const int iNewMaxNumChan, const QString& strCentralServer, const QString& strServerInfo, const QString& strNewWelcomeMessage, + const QString& strRecordingDirName, + const bool bEnableRecording, const bool bNCentServPingServerInList, const bool bNDisconnectAllClients, const ELicenceType eNLicenceType ) : @@ -357,6 +359,13 @@ CServer::CServer ( const int iNewMaxNumChan, QString().number( static_cast ( iPortNumber ) ) ); } + // Enable jam recording (if requested) + if ( bEnableRecording ) + { + JamRecorder = new CJamRecorder(this, strRecordingDirName); + JamRecorder->start(); + } + // enable all channels (for the server all channel must be enabled the // entire life time of the software) for ( i = 0; i < iMaxNumChannels; i++ ) @@ -739,8 +748,10 @@ JitterMeas.Measure(); // if channel was just disconnected, set flag that connected // client list is sent to all other clients + // and emit the client disconnected signal if ( eGetStat == GS_CHAN_NOW_DISCONNECTED ) { + emit ClientDisconnected(iCurChanID); //? do outside mutex lock? bChannelIsNowDisconnected = true; } @@ -825,6 +836,8 @@ JitterMeas.Measure(); // get number of audio channels of current channel const int iCurNumAudChan = vecNumAudioChannels[i]; + emit Frame(iCurChanID, vecChannels[iCurChanID].GetName(), vecChannels[iCurChanID].GetAddress(), iCurNumAudChan, vecvecsData[i]); + // generate a sparate mix for each channel // actual processing of audio data -> mix ProcessData ( vecvecsData, diff --git a/src/server.h b/src/server.h index 349659e8..4ef49a0e 100755 --- a/src/server.h +++ b/src/server.h @@ -41,6 +41,9 @@ #include "serverlogging.h" #include "serverlist.h" +#include "recorder/jamrecorder.h" +using recorder::CJamRecorder; + /* Definitions ****************************************************************/ // no valid channel number @@ -127,6 +130,8 @@ public: const QString& strCentralServer, const QString& strServerInfo, const QString& strNewWelcomeMessage, + const QString& strRecordingDirName, + const bool bEnableRecording, const bool bNCentServPingServerInList, const bool bNDisconnectAllClients, const ELicenceType eNLicenceType ); @@ -247,6 +252,9 @@ protected: // logging CServerLogging Logging; + // recording thread + CJamRecorder* JamRecorder; + // HTML file server status bool bWriteStatusHTMLFile; QString strServerHTMLFileListName; @@ -268,6 +276,8 @@ protected: signals: void Started(); void Stopped(); + void ClientDisconnected(const int iChID); + void Frame(const int, const QString, const CHostAddress, const int, const CVector); public slots: void OnTimer(); diff --git a/src/serverdlg.cpp b/src/serverdlg.cpp index 37177a15..bd1922e7 100755 --- a/src/serverdlg.cpp +++ b/src/serverdlg.cpp @@ -588,3 +588,11 @@ void CServerDlg::changeEvent ( QEvent* pEvent ) } } } + +void CServerDlg::keyPressEvent(QKeyEvent *e) +{ + if (e->key() != Qt::Key_Escape) + { + QDialog::keyPressEvent(e); + } +} diff --git a/src/serverdlg.h b/src/serverdlg.h index bf40d8d4..63901b30 100755 --- a/src/serverdlg.h +++ b/src/serverdlg.h @@ -97,4 +97,6 @@ public slots: void OnSysTrayMenuHide() { hide(); } void OnSysTrayMenuExit() { close(); } void OnSysTrayActivated ( QSystemTrayIcon::ActivationReason ActReason ); + + void keyPressEvent(QKeyEvent *e); }; diff --git a/src/serverlogging.cpp b/src/serverlogging.cpp index d4a3577c..e5d41c44 100755 --- a/src/serverlogging.cpp +++ b/src/serverlogging.cpp @@ -342,10 +342,10 @@ void CServerLogging::AddNewConnection ( const QHostAddress& ClientInetAddr ) const QString strLogStr = CurTimeDatetoLogString() + ", " + ClientInetAddr.toString() + ", connected"; -#ifndef _WIN32 - QTextStream tsConsoleStream ( stdout ); - tsConsoleStream << strLogStr << endl; // on console -#endif + + QTextStream* tsConsoleStream = (new ConsoleWriterFactory())->get(); + (*tsConsoleStream) << strLogStr << endl; // on console + *this << strLogStr; // in log file // add element to history @@ -357,10 +357,10 @@ void CServerLogging::AddServerStopped() const QString strLogStr = CurTimeDatetoLogString() + ",, server stopped " "-------------------------------------"; -#ifndef _WIN32 - QTextStream tsConsoleStream ( stdout ); - tsConsoleStream << strLogStr << endl; // on console -#endif + + QTextStream* tsConsoleStream = (new ConsoleWriterFactory())->get(); + (*tsConsoleStream) << strLogStr << endl; // on console + *this << strLogStr; // in log file // add element to history and update on server stop diff --git a/src/util.h b/src/util.h index c5b79f17..6710e3c3 100755 --- a/src/util.h +++ b/src/util.h @@ -1275,4 +1275,13 @@ protected: bool bPreviousState; }; +class ConsoleWriterFactory +{ +public: + ConsoleWriterFactory() : ptsConsole ( nullptr ) { } + QTextStream* get(); +private: + QTextStream* ptsConsole; +}; + #endif /* !defined ( UTIL_HOIH934256GEKJH98_3_43445KJIUHF1912__INCLUDED_ ) */ diff --git a/windows/sound.cpp b/windows/sound.cpp index 9650f4a5..93eef040 100755 --- a/windows/sound.cpp +++ b/windows/sound.cpp @@ -25,7 +25,7 @@ * \******************************************************************************/ -#include "Sound.h" +#include "sound.h" /* Implementation *************************************************************/