diff --git a/README.md b/README.md index 9b9dc98..d9a9954 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ # dbus-soundrecorder -Records sound from Pulse Audio using ffmpeg. Uses dbus to determine current song, album and artist. +Records sound from Pulse Audio using ffmpeg. + +Uses D-Bus to determine current song, album, artist and other meta data +provided by the media player application via MPRIS D-Bus service. + +When the next song start, the recorder automatically starts a new file +and sets available meta data. + +## Usage +``` +dbus-soundrecorder record [options] +``` + +Here's a simple example: + +First, get a list of your Pulse Audio sinks: +``` +pactl list short sinks +``` + +You can also create a new virtual Pulse Audio sink: +``` +pactl load-module module-null-sink sink_name=virtual1 +``` +In any case, you should ensure that the media player uses the sink (eg. using pavucontrol). + +Then start the recorder. You need to specify the media player application and the sink: +``` +dbus-soundrecorder -a yfitops -s virtual1.monitor -o "-c:a libfdk_aac -vbr 4 -ar 44100" +``` +As you can see, it is also possible to specify options for ffmpeg. However input sink, +output file and meta data are provided by the recorder and shouldn't be specified. + +For all available options, use the --help command. + +## Build instructions +The application depends on the c++utilities library. It is built in the same way as c++utilities. + +The following Qt 5 modules are requried: core dbus + + diff --git a/dbus-soundrecorder.pro b/dbus-soundrecorder.pro index ae0b74e..1adc16e 100644 --- a/dbus-soundrecorder.pro +++ b/dbus-soundrecorder.pro @@ -1,4 +1,7 @@ projectname = dbus-soundrecorder +appname = "D-Bus Sound Recorder" +appauthor = Martchus +appurl = "https://github.com/$${appauthor}/$${projectname}" VERSION = 1.0.0 # include ../../common.pri when building as part of a subdirs project; otherwise include general.pri @@ -15,10 +18,12 @@ CONFIG += console QT += core dbus SOURCES += main.cpp \ - playerwatcher.cpp + playerwatcher.cpp \ + ffmpeglauncher.cpp HEADERS += \ - playerwatcher.h + playerwatcher.h \ + ffmpeglauncher.h DBUS_INTERFACES += \ org.freedesktop.DBus.Properties.xml \ diff --git a/ffmpeglauncher.cpp b/ffmpeglauncher.cpp new file mode 100644 index 0000000..f6e4217 --- /dev/null +++ b/ffmpeglauncher.cpp @@ -0,0 +1,177 @@ +#include "ffmpeglauncher.h" +#include "playerwatcher.h" + +#include +#include + +#include +#include + +using namespace std; +using namespace IoUtilities; +using namespace ConversionUtilities; + +namespace DBusSoundRecorder { + +inline ostream &operator <<(ostream &stream, const QString &str) +{ + stream << str.toLocal8Bit().data(); + return stream; +} + +FfmpegLauncher::FfmpegLauncher(PlayerWatcher &watcher, QObject *parent) : + QObject(parent), + m_watcher(watcher), + m_sink(QStringLiteral("default")), + m_options(), + m_targetDir(QStringLiteral(".")), + m_targetExtension(QStringLiteral(".m4a")), + m_ffmpeg(new QProcess(this)) +{ + connect(&watcher, &PlayerWatcher::nextSong, this, &FfmpegLauncher::nextSong); + connect(m_ffmpeg, &QProcess::started, this, &FfmpegLauncher::ffmpegStarted); + connect(m_ffmpeg, static_cast(&QProcess::error), this, &FfmpegLauncher::ffmpegError); + connect(m_ffmpeg, static_cast(&QProcess::finished), this, &FfmpegLauncher::ffmpegFinished); + m_ffmpeg->setProgram(QStringLiteral("ffmpeg")); + m_ffmpeg->setProcessChannelMode(QProcess::ForwardedChannels); +} + +void addMetaData(QStringList &args, const QString &field, const QString &value) +{ + if(!value.isEmpty()) { + args << QStringLiteral("-metadata"); + args << QStringLiteral("%1=%2").arg(field, value); + } +} + +void FfmpegLauncher::nextSong() +{ + // terminate/kill the current process + if(m_ffmpeg->state() != QProcess::NotRunning) { + m_ffmpeg->terminate(); + m_ffmpeg->waitForFinished(250); + if(m_ffmpeg->state() != QProcess::NotRunning) { + m_ffmpeg->kill(); + m_ffmpeg->waitForFinished(250); + if(m_ffmpeg->state() != QProcess::NotRunning) { + throw runtime_error("Unable to terminate/kill ffmpeg process."); + } + } + } + if(m_watcher.isPlaying()) { + // determine output file, create target directory + static const QString miscCategory(QStringLiteral("misc")); + static const QString unknownTitle(QStringLiteral("unknown track")); + QDir targetDir(QStringLiteral("%1/%2").arg(m_watcher.artist().isEmpty() ? miscCategory : m_watcher.artist(), m_watcher.artist().isEmpty() ? miscCategory : m_watcher.album())); + if(!m_targetDir.mkpath(targetDir.path())) { + cerr << "Error: Can not create target directory: " << targetDir.absolutePath() << endl; + return; + } + // determine track number + QString number; + QString length; + if(m_watcher.trackNumber()) { + if(m_watcher.diskNumber()) { + number = QStringLiteral("%2-%1").arg(m_watcher.trackNumber(), 2, 10, QLatin1Char('0')).arg(m_watcher.diskNumber()); + } else { + number = QStringLiteral("%1").arg(m_watcher.trackNumber(), 2, 10, QLatin1Char('0')); + } + } + if(!number.isEmpty()) { + number.append(QStringLiteral(" - ")); + } + // determine additional info + // - from a file called info.ini in the album directory + // - currently only track length is supported (used to get rid of advertisements at the end) + if(targetDir.exists(QStringLiteral("info.ini"))) { + fstream infoFile; + infoFile.exceptions(ios_base::badbit | ios_base::failbit); + try { + infoFile.open((targetDir.path() + QStringLiteral("/info.ini")).toLocal8Bit().data(), ios_base::in); + IniFile infoIni; + infoIni.parse(infoFile); + // read length scope, only possible if track number known because the track number is used for mapping + if(m_watcher.trackNumber()) { + try { + const auto &lengthScope = infoIni.data().at("length"); + for(const auto &entry : lengthScope) { + try { + if(stringToNumber(entry.first) == m_watcher.trackNumber()) { + // length entry for this track + length = QString::fromLocal8Bit(entry.second.data()); + break; + } + } catch( const ConversionException &) { + cerr << "Warning: Ignoring non-numeric key \"" << entry.first << "\" in info.ini." << endl; + } + } + } catch(const out_of_range &) { + // no length for the current track specified + } + } + } catch(const ios_base::failure &) { + cerr << "Warning: Can't parse info.ini because an IO error occured." << endl; + } + } + // determine target name/path + QString targetName(QStringLiteral("%3%1%2").arg(m_watcher.title().isEmpty() ? unknownTitle : m_watcher.title(), m_targetExtension, number)); + unsigned int count = 1; + while(targetDir.exists(targetName)) { + ++count; + targetName = QStringLiteral("%3%1 (%4)%2").arg(m_watcher.title().isEmpty() ? unknownTitle : m_watcher.title(), m_targetExtension, number).arg(count); + } + QString targetPath = targetDir.absoluteFilePath(targetName); + // set input device + QStringList args; + args << QStringLiteral("-f"); + args << QStringLiteral("pulse"); + args << QStringLiteral("-i"); + args << m_sink; + // set length + if(!length.isEmpty()) { + args << "-t"; + args << length; + } + // set additional options + args << m_options; + // set meta data + addMetaData(args, QStringLiteral("title"), m_watcher.title()); + addMetaData(args, QStringLiteral("album"), m_watcher.album()); + addMetaData(args, QStringLiteral("artist"), m_watcher.artist()); + addMetaData(args, QStringLiteral("genre"), m_watcher.genre()); + addMetaData(args, QStringLiteral("year"), m_watcher.year()); + if(m_watcher.trackNumber()) { + addMetaData(args, QStringLiteral("track"), QString::number(m_watcher.trackNumber())); + } + if(m_watcher.diskNumber()) { + addMetaData(args, QStringLiteral("disk"), QString::number(m_watcher.diskNumber())); + } + // set output file + args << targetPath; + m_ffmpeg->setArguments(args); + // start process + m_ffmpeg->start(); + } +} + +void FfmpegLauncher::ffmpegStarted() +{ + cerr << "Started ffmpeg: "; + cerr << m_ffmpeg->program(); + for(const auto &arg : m_ffmpeg->arguments()) { + cerr << ' ' << arg; + } + cerr << endl; +} + +void FfmpegLauncher::ffmpegError() +{ + cerr << "Failed to start ffmpeg: " << m_ffmpeg->errorString(); +} + +void FfmpegLauncher::ffmpegFinished(int exitCode) +{ + cerr << "FFmpeg finished with exit code " << exitCode << endl; +} + +} diff --git a/ffmpeglauncher.h b/ffmpeglauncher.h new file mode 100644 index 0000000..0c4f656 --- /dev/null +++ b/ffmpeglauncher.h @@ -0,0 +1,66 @@ +#ifndef FFMPEGLAUNCHER_H +#define FFMPEGLAUNCHER_H + +#include +#include +#include + +namespace DBusSoundRecorder { + +class PlayerWatcher; + +class FfmpegLauncher : public QObject +{ + Q_OBJECT +public: + explicit FfmpegLauncher(PlayerWatcher &watcher, QObject *parent = nullptr); + + void setSink(const QString &sinkName); + void setFfmpegBinary(const QString &path); + void setFfmpegOptions(const QString &options); + void setTargetDir(const QString &path); + void setTargetExtension(const QString &extension); + +private slots: + void nextSong(); + void ffmpegStarted(); + void ffmpegError(); + void ffmpegFinished(int exitCode); + +private: + PlayerWatcher &m_watcher; + QString m_sink; + QStringList m_options; + QDir m_targetDir; + QString m_targetExtension; + QProcess *m_ffmpeg; +}; + +inline void FfmpegLauncher::setSink(const QString &sinkName) +{ + m_sink = sinkName; +} + +inline void FfmpegLauncher::setFfmpegBinary(const QString &path) +{ + m_ffmpeg->setProgram(path); +} + +inline void FfmpegLauncher::setFfmpegOptions(const QString &options) +{ + m_options = options.split(QChar(' '), QString::SkipEmptyParts); +} + +inline void FfmpegLauncher::setTargetDir(const QString &path) +{ + m_targetDir = QDir(path); +} + +inline void FfmpegLauncher::setTargetExtension(const QString &extension) +{ + m_targetExtension = extension.startsWith(QChar('.')) ? extension : QStringLiteral(".") + extension; +} + +} + +#endif // FFMPEGLAUNCHER_H diff --git a/main.cpp b/main.cpp index ce537b9..afe638c 100644 --- a/main.cpp +++ b/main.cpp @@ -1,4 +1,5 @@ #include "playerwatcher.h" +#include "ffmpeglauncher.h" #include #include @@ -14,8 +15,11 @@ using namespace DBusSoundRecorder; int main(int argc, char *argv[]) { // setup the argument parser + SET_APPLICATION_INFO; ArgumentParser parser; HelpArgument helpArg(parser); + Argument recordArg("record", "r", "starts recording"); + recordArg.setDenotesOperation(true); Argument applicationArg("application", "a", "specifies the application providing meta information via D-Bus interface"); applicationArg.setRequired(true); applicationArg.setValueNames({"name"}); @@ -28,20 +32,57 @@ int main(int argc, char *argv[]) targetDirArg.setValueNames({"path"}); targetDirArg.setRequiredValueCount(1); targetDirArg.setCombinable(true); + Argument targetExtArg("target-extension", "e", "specifies the target extension (default is .m4a)"); + targetExtArg.setValueNames({"extension"}); + targetExtArg.setRequiredValueCount(1); + targetExtArg.setCombinable(true); + Argument ffmpegBinArg("ffmpeg-bin", "f", "specifies the path to the ffmpeg binary"); + ffmpegBinArg.setValueNames({"path"}); + ffmpegBinArg.setRequiredValueCount(1); + ffmpegBinArg.setCombinable(true); Argument ffmpegOptions("ffmpeg-options", "o", "specifies options for ffmpeg"); ffmpegOptions.setValueNames({"options"}); ffmpegOptions.setRequiredValueCount(1); ffmpegOptions.setCombinable(true); - parser.setMainArguments({&applicationArg, &sinkArg, &targetDirArg, &ffmpegOptions, &helpArg}); + recordArg.setSecondaryArguments({&applicationArg, &sinkArg, &targetDirArg, &targetExtArg, &ffmpegBinArg, &ffmpegOptions}); + parser.setMainArguments({&recordArg, &helpArg}); parser.setIgnoreUnknownArguments(false); // parse command line arguments try { parser.parseArgs(argc, argv); - } catch (Failure &e) { + } catch (const Failure &e) { cerr << "Unable to parse arguments: " << e.what() << endl; return 2; } - QCoreApplication app(argc, argv); - PlayerWatcher watcher(QString::fromLocal8Bit(applicationArg.values().front().data())); - return app.exec(); + try { + if(recordArg.isPresent()) { + // start watching/recording + cerr << "Watching MPRIS service of the specified application \"" << applicationArg.values().front() << "\" ..." << endl; + // create app loop, player watcher and ffmpeg launcher + QCoreApplication app(argc, argv); + PlayerWatcher watcher(QString::fromLocal8Bit(applicationArg.values().front().data())); + FfmpegLauncher ffmpeg(watcher); + // pass specified args to ffmpeg launcher + if(sinkArg.isPresent()) { + ffmpeg.setSink(QString::fromLocal8Bit(sinkArg.values().front().data())); + } + if(ffmpegBinArg.isPresent()) { + ffmpeg.setFfmpegBinary(QString::fromLocal8Bit(ffmpegBinArg.values().front().data())); + } + if(ffmpegOptions.isPresent()) { + ffmpeg.setFfmpegOptions(QString::fromLocal8Bit(ffmpegOptions.values().front().data())); + } + if(targetDirArg.isPresent()) { + ffmpeg.setTargetDir(QString::fromLocal8Bit(targetDirArg.values().front().data())); + } + if(targetExtArg.isPresent()) { + ffmpeg.setTargetExtension(QString::fromLocal8Bit(targetExtArg.values().front().data())); + } + // enter app loop + return app.exec(); + } + + } catch(const runtime_error &e) { + cerr << "Fatal error: " << e.what() << endl; + } } diff --git a/playerwatcher.cpp b/playerwatcher.cpp index d003b6f..aa31248 100644 --- a/playerwatcher.cpp +++ b/playerwatcher.cpp @@ -24,10 +24,33 @@ PlayerWatcher::PlayerWatcher(const QString &appName, QObject *parent) : m_serviceWatcher(new QDBusServiceWatcher(m_service, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForOwnerChange, this)), m_propertiesInterface(new OrgFreedesktopDBusPropertiesInterface(m_service, QStringLiteral("/org/mpris/MediaPlayer2"), QDBusConnection::sessionBus(), this)), m_playerInterface(new OrgMprisMediaPlayer2PlayerInterface(m_service, QStringLiteral("/org/mpris/MediaPlayer2"), QDBusConnection::sessionBus(), this)), - m_trackNumber(0) + m_isPlaying(false), + m_trackNumber(0), + m_diskNumber(0) { connect(m_serviceWatcher, &QDBusServiceWatcher::serviceOwnerChanged, this, &PlayerWatcher::serviceOwnerChanged); connect(m_propertiesInterface, &OrgFreedesktopDBusPropertiesInterface::PropertiesChanged, this, &PlayerWatcher::propertiesChanged); + propertiesChanged(); +} + +void PlayerWatcher::play() +{ + m_playerInterface->Play(); +} + +void PlayerWatcher::stop() +{ + m_playerInterface->Stop(); +} + +void PlayerWatcher::pause() +{ + m_playerInterface->Pause(); +} + +void PlayerWatcher::playPause() +{ + m_playerInterface->PlayPause(); } void PlayerWatcher::serviceOwnerChanged(const QString &service, const QString &oldOwner, const QString &newOwner) @@ -40,27 +63,55 @@ void PlayerWatcher::serviceOwnerChanged(const QString &service, const QString &o } } -void PlayerWatcher::propertiesChanged(const QString &, const QVariantMap &, const QStringList &) +void PlayerWatcher::propertiesChanged() { // get meta data - QVariantMap metadata = m_playerInterface->metadata(); - QString title = metadata.value(QStringLiteral("xesam:title")).toString(); - QString album = metadata.value(QStringLiteral("xesam:album")).toString(); - QString artist = metadata.value(QStringLiteral("xesam:artist")).toString(); - // use title, album and artist to identify song - if(m_title != title || m_album != album || m_artist != artist) { - // next song playing - m_title = title; - m_album = album; - m_artist = artist; - // read additional meta data - m_year = metadata.value(QStringLiteral("xesam:contentCreated")).toString(); - m_genre = metadata.value(QStringLiteral("xesam:genre")).toString(); - m_trackNumber = metadata.value(QStringLiteral("xesam:tracknumber")).toUInt(); - // notify - cerr << "Next song: " << m_title << endl; - emit nextSong(); + if(!m_playerInterface->playbackStatus().compare(QLatin1String("playing"), Qt::CaseInsensitive)) { + if(!m_isPlaying) { + m_isPlaying = true; + cerr << "Playback started" << endl; + } + QVariantMap metadata = m_playerInterface->metadata(); + QString title = metadata.value(QStringLiteral("xesam:title")).toString(); + QString album = metadata.value(QStringLiteral("xesam:album")).toString(); + QString artist = metadata.value(QStringLiteral("xesam:artist")).toString(); + // use title, album and artist to identify song + if(m_title != title || m_album != album || m_artist != artist) { + // next song playing + m_title = title; + m_album = album; + m_artist = artist; + // read additional meta data + m_year = metadata.value(QStringLiteral("xesam:contentCreated")).toString(); + m_genre = metadata.value(QStringLiteral("xesam:genre")).toString(); + m_trackNumber = metadata.value(QStringLiteral("xesam:tracknumber")).toUInt(); + if(!m_trackNumber) { + m_trackNumber = metadata.value(QStringLiteral("xesam:trackNumber")).toUInt(); + } + m_diskNumber = metadata.value(QStringLiteral("xesam:discnumber")).toUInt(); + if(!m_diskNumber) { + m_diskNumber = metadata.value(QStringLiteral("xesam:discNumber")).toUInt(); + } + m_length = metadata.value(QStringLiteral("xesam:length")).toULongLong(); + // notify + cerr << "Next song: " << m_title << endl; + if(!m_isPlaying) { + emit playbackStarted(); + } + emit nextSong(); + } else if(!m_isPlaying) { + emit playbackStarted(); + } + } else if(m_isPlaying) { + m_isPlaying = false; + cerr << "Playback stopped" << endl; + emit playbackStopped(); } } +void PlayerWatcher::seeked(qlonglong pos) +{ + cerr << "Seeked: " << pos << endl; +} + } diff --git a/playerwatcher.h b/playerwatcher.h index 38efaa5..3dbe3d8 100644 --- a/playerwatcher.h +++ b/playerwatcher.h @@ -16,33 +16,52 @@ class PlayerWatcher : public QObject public: explicit PlayerWatcher(const QString &appName, QObject *parent = nullptr); + void play(); + void stop(); + void pause(); + void playPause(); + + bool isPlaying() const; const QString &title() const; const QString &album() const; const QString &artist() const; const QString &year() const; const QString &genre() const; unsigned int trackNumber() const; + unsigned int diskNumber() const; + unsigned long long length() const; signals: void nextSong(); + void playbackStarted(); + void playbackStopped(); private slots: void serviceOwnerChanged(const QString &service, const QString &oldOwner, const QString &newOwner); - void propertiesChanged(const QString &, const QVariantMap &, const QStringList &); + void propertiesChanged(); + void seeked(qlonglong pos); private: QString m_service; QDBusServiceWatcher *m_serviceWatcher; OrgFreedesktopDBusPropertiesInterface *m_propertiesInterface; OrgMprisMediaPlayer2PlayerInterface *m_playerInterface; + bool m_isPlaying; QString m_title; QString m_album; QString m_artist; QString m_year; QString m_genre; unsigned int m_trackNumber; + unsigned int m_diskNumber; + unsigned long long m_length; }; +inline bool PlayerWatcher::isPlaying() const +{ + return m_isPlaying; +} + inline const QString &PlayerWatcher::title() const { return m_title; @@ -73,6 +92,16 @@ inline unsigned int PlayerWatcher::trackNumber() const return m_trackNumber; } +inline unsigned int PlayerWatcher::diskNumber() const +{ + return m_diskNumber; +} + +inline unsigned long long PlayerWatcher::length() const +{ + return m_length; +} + } #endif // PLAYERWATCHER_H