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 *************************************************************/