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.
This commit is contained in:
Peter L Jones 2019-04-03 18:12:45 +01:00
parent 3609332754
commit 8c1deffda7
21 changed files with 1145 additions and 22 deletions

17
.gitignore vendored Normal file
View File

@ -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

View File

@ -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 \

View File

@ -102,3 +102,11 @@ void CChatDlg::AddChatText ( QString strChatText )
#endif
);
}
void CChatDlg::keyPressEvent(QKeyEvent *e)
{
if (e->key() != Qt::Key_Escape)
{
QDialog::keyPressEvent(e);
}
}

View File

@ -48,6 +48,8 @@ public slots:
void OnLocalInputTextTextChanged ( const QString& strNewText );
void OnClearPressed();
void keyPressEvent(QKeyEvent *e);
signals:
void NewLocalInputText ( QString strNewText );
};

View File

@ -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);
}
}

View File

@ -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);
};

View File

@ -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 <QString>
#include <QEvent>
#include <QDebug>

View File

@ -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"

107
src/recorder/creaperproject.cpp Executable file
View File

@ -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 "<ITEM>" 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 << " <ITEM " << endl;
sOut << " FADEIN 0 0 0 0 0 0" << endl;
sOut << " FADEOUT 0 0 0 0 0 0" << endl;
sOut << " POSITION " << secondsAt48K(trackItem.startFrame) << endl;
sOut << " LENGTH " << secondsAt48K(trackItem.frameCount) << endl;
sOut << " IGUID " << iguid.toString() << endl;
sOut << " IID " << iid << endl;
sOut << " NAME " << name << endl;
sOut << " GUID " << guid.toString() << endl;
sOut << " <SOURCE WAVE" << endl;
sOut << " FILE " << '"' << wavName << '"' << endl;
sOut << " >" << endl;
sOut << " >";
sOut.flush();
}
/**
* @brief CReaperTrack::CReaperTrack Construct a Reaper RPP "<TRACK>" 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<STrackItem> items)
{
QTextStream sOut(&out);
sOut << " <TRACK " << trackId.toString() << endl;
sOut << " NAME " << name << endl;
sOut << " TRACKID " << trackId.toString() << endl;
int ino = 1;
foreach (auto item, items) {
sOut << CReaperItem(name + " (" + QString::number(ino) + ")", item, iid).toString() << endl;
ino++;
iid++;
}
sOut << " >";
sOut.flush();
}
/**
* @brief CReaperProject::CReaperProject Construct a Reaper RPP "<REAPER_PROJECT>" for a given list of tracks
* @param tracks the list of tracks
*/
CReaperProject::CReaperProject(QMap<QString, QList<STrackItem>> tracks)
{
QTextStream sOut(&out);
sOut << "<REAPER_PROJECT 0.1 \"5.0\" 1551567848" << endl;
sOut << " RECORD_PATH \"\" \"\"" << endl;
sOut << " SAMPLERATE 48000 0 0" << endl;
sOut << " TEMPO 120 4 4" << endl;
qint32 iid = 0;
foreach(auto trackName, tracks.keys())
{
sOut << CReaperTrack(trackName, iid, tracks[trackName]).toString() << endl;
}
sOut << ">";
sOut.flush();
}

70
src/recorder/creaperproject.h Executable file
View File

@ -0,0 +1,70 @@
#ifndef CREAPERPROJECT_H
#define CREAPERPROJECT_H
#include <QUuid>
#include <QFileInfo>
#include <QDir>
#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<double>(frames * SYSTEM_FRAME_SIZE_SAMPLES) / 48000, 'f', 14); }
};
class CReaperTrack : public QObject
{
Q_OBJECT
public:
CReaperTrack(QString name, qint32 &iid, QList<STrackItem> items);
QString toString() { return out; }
private:
QUuid trackId = QUuid::createUuid();
QString out;
};
class CReaperProject : public QObject
{
Q_OBJECT
public:
CReaperProject(QMap<QString, QList<STrackItem> > tracks);
QString toString() { return out; }
private:
QString out;
};
}
#endif // CREAPERPROJECT_H

121
src/recorder/cwavestream.cpp Executable file
View File

@ -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<uint32_t>(currentPos - initialPos);
QDataStream& out = static_cast<QDataStream&>(*this);
// Overwrite hdr_riff.chunkSize
this->device()->seek(initialPos + hdrRiffChunkSizeOffset);
out << static_cast<uint32_t>(fileLength - (hdrRiffChunkSizeOffset + sizeof (uint32_t)));
// Overwrite dataSubChunkHdr.chunkSize
this->device()->seek(initialPos + dataSubChunkHdrChunkSizeOffset);
out << static_cast<uint32_t>(fileLength - (dataSubChunkHdrChunkSizeOffset + sizeof (uint32_t)));
// and then restore the position and byte order
this->device()->seek(currentPos);
setByteOrder(initialByteOrder);
}

69
src/recorder/cwavestream.h Executable file
View File

@ -0,0 +1,69 @@
#ifndef CWAVESTREAM_H
#define CWAVESTREAM_H
#include <QDataStream>
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

418
src/recorder/jamrecorder.cpp Executable file
View File

@ -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<uint16_t>(_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<int16_t>& 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<int16_t> 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<QString, QList<STrackItem>> CJamSession::Tracks()
{
QMap<QString, QList<STrackItem>> 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<QString, QList<STrackItem>> CJamSession::TracksFromSessionDir(const QString& sessionDirName)
{
QMap<QString, QList<STrackItem>> 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<CVector<int16_t>>();
QObject::connect((const QObject *)server, SIGNAL ( Frame(const int, const QString, const CHostAddress, const int, const CVector<int16_t>) ),
this, SLOT( OnFrame(const int, const QString, const CHostAddress, const int, const CVector<int16_t>) ),
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<int16_t> data)
{
// Make sure we are ready
if (!isRecording)
{
OnStart();
}
currentSession->Frame(iChID, name, address, numAudioChannels, data);
}

156
src/recorder/jamrecorder.h Executable file
View File

@ -0,0 +1,156 @@
#ifndef JAMRECORDER_H
#define JAMRECORDER_H
#include <QDir>
#include <QFile>
#include <QDateTime>
#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<int16_t>& 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<int16_t> data);
void End();
QVector<CJamClient*> Clients() { return vecptrJamClients; }
QMap<QString, QList<STrackItem>> Tracks();
QString Name() { return sessionDir.dirName(); }
const QDir SessionDir() { return sessionDir; }
void DisconnectClient(int iChID);
static QMap<QString, QList<STrackItem>> TracksFromSessionDir(const QString& name);
private:
CJamSession();
const QDir sessionDir;
qint64 currentFrame;
QVector<CJamClient*> vecptrJamClients;
QList<CJamClientConnection*> 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<int16_t> data);
private:
QDir recordBaseDir;
bool isRecording;
CJamSession* currentSession;
QTextStream& tsConsole = *((new ConsoleWriterFactory())->get());
};
}
Q_DECLARE_METATYPE(int16_t)
Q_DECLARE_METATYPE(CVector<int16_t>)
#endif // JAMRECORDER_H

View File

@ -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<int> ( 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,

View File

@ -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<int16_t>);
public slots:
void OnTimer();

View File

@ -588,3 +588,11 @@ void CServerDlg::changeEvent ( QEvent* pEvent )
}
}
}
void CServerDlg::keyPressEvent(QKeyEvent *e)
{
if (e->key() != Qt::Key_Escape)
{
QDialog::keyPressEvent(e);
}
}

View File

@ -97,4 +97,6 @@ public slots:
void OnSysTrayMenuHide() { hide(); }
void OnSysTrayMenuExit() { close(); }
void OnSysTrayActivated ( QSystemTrayIcon::ActivationReason ActReason );
void keyPressEvent(QKeyEvent *e);
};

View File

@ -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

View File

@ -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_ ) */

View File

@ -25,7 +25,7 @@
*
\******************************************************************************/
#include "Sound.h"
#include "sound.h"
/* Implementation *************************************************************/