master
rekols 2 years ago
parent 401977b77b
commit 392a070a81
  1. 2
      .gitignore
  2. 17
      CMakeLists.txt
  3. 23
      README.md
  4. 18
      src/CMakeLists.txt
  5. 31
      src/audioplayer.cpp
  6. 66
      src/audioplayer.h
  7. 66
      src/audioprober.cpp
  8. 74
      src/audioprober.h
  9. 110
      src/audiorecorder.cpp
  10. 97
      src/audiorecorder.h
  11. 17
      src/images/delete.svg
  12. 18
      src/images/pause-dark.svg
  13. 18
      src/images/pause-light.svg
  14. 18
      src/images/recorder-dark.svg
  15. 18
      src/images/recorder-light.svg
  16. 18
      src/images/start-dark.svg
  17. 18
      src/images/start-light.svg
  18. 18
      src/images/stop-dark.svg
  19. 18
      src/images/stop-light.svg
  20. 42
      src/main.cpp
  21. 18
      src/qml.qrc
  22. 241
      src/qml/HomePage.qml
  23. 27
      src/qml/IconButton.qml
  24. 149
      src/qml/RecordPage.qml
  25. 63
      src/qml/Visualization.qml
  26. 45
      src/qml/main.qml
  27. 202
      src/recordingmodel.cpp
  28. 110
      src/recordingmodel.h
  29. 87
      src/settingsmodel.cpp
  30. 77
      src/settingsmodel.h
  31. 25
      src/utils.cpp
  32. 23
      src/utils.h

2
.gitignore vendored

@ -50,3 +50,5 @@ compile_commands.json
# QtCreator local machine specific files for imported projects
*creator.user*
build/*

@ -0,0 +1,17 @@
cmake_minimum_required(VERSION 3.14)
project(cyber-recorder LANGUAGES CXX)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt5 COMPONENTS Core Quick Multimedia REQUIRED)
find_package(MeuiKit)
add_subdirectory(src)

@ -1,2 +1,23 @@
# cyber-recorder
# Cyber Recorder
Voice recorder
## Dependencies
```shell
sudo pacman -S cmake qt5-base qt5-quickcontrols2 qt5-multimedia
```
## Build and Install
```shell
mkdir build
cd build
cmake ..
make
sudo make install
```
## License
This project has been licensed by GPLv3.

@ -0,0 +1,18 @@
add_executable(cyber-recorder
main.cpp
audiorecorder.cpp
audioplayer.cpp
recordingmodel.cpp
audioprober.cpp
utils.cpp
settingsmodel.cpp
qml.qrc
)
target_link_libraries(cyber-recorder
PRIVATE
Qt5::Core
Qt5::Quick
Qt5::Multimedia
MeuiKit
)

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2020 Devin Lin <espidev@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "audioplayer.h"
AudioPlayer::AudioPlayer(QObject *parent)
: QMediaPlayer(parent)
, m_mediaPath(QString())
{
m_audioProbe = new AudioProber(parent);
m_audioProbe->setSource(this);
QQmlEngine::setObjectOwnership(m_audioProbe, QQmlEngine::CppOwnership);
connect(this, &AudioPlayer::stateChanged, this, &AudioPlayer::handleStateChange);
}
void AudioPlayer::handleStateChange(QMediaPlayer::State state)
{
if (state == QMediaPlayer::StoppedState) {
wasStopped = true;
} else if (state == QMediaPlayer::PlayingState && wasStopped) {
wasStopped = false;
m_audioProbe->clearVolumesList();
}
}

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2020 Devin Lin <espidev@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#ifndef AUDIOPLAYER_H
#define AUDIOPLAYER_H
#include <QMediaPlayer>
#include <QAudioProbe>
#include <QQmlEngine>
#include <QUrl>
#include <QCoreApplication>
#include "audioprober.h"
class AudioPlayer;
static AudioPlayer *s_audioPlayer = nullptr;
class AudioPlayer : public QMediaPlayer
{
Q_OBJECT
Q_PROPERTY(AudioProber *prober READ prober CONSTANT)
Q_PROPERTY(QString mediaPath READ mediaPath WRITE setMediaPath NOTIFY mediaPathChanged)
public:
static AudioPlayer *instance()
{
if (!s_audioPlayer) {
s_audioPlayer = new AudioPlayer(qApp);
}
return s_audioPlayer;
}
void handleStateChange(QMediaPlayer::State state);
AudioProber *prober()
{
return m_audioProbe;
}
Q_INVOKABLE void setMediaPath(QString path)
{
m_mediaPath = path;
setMedia(QUrl::fromLocalFile(path));
}
QString mediaPath() const
{
return m_mediaPath;
}
signals:
void mediaPathChanged();
private:
explicit AudioPlayer(QObject *parent = nullptr);
AudioProber *m_audioProbe;
QString m_mediaPath;
bool wasStopped = false;
};
#endif

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb@kaidan.im>
* SPDX-FileCopyrightText: 2020 Devin Lin <espidev@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "audioprober.h"
constexpr int MAX_VOLUME = 1000;
AudioProber::AudioProber(QObject *parent)
: QAudioProbe(parent)
{
connect(this, &AudioProber::audioBufferProbed, this, &AudioProber::process);
m_volumesList.append(0);
// loop to add volume bars
volumeBarTimer = new QTimer(this);
connect(volumeBarTimer, &QTimer::timeout, this, &AudioProber::processVolumeBar);
volumeBarTimer->start(150);
}
void AudioProber::processVolumeBar()
{
if (m_audioLen != 0) {
const int val = m_audioSum / m_audioLen;
m_volumesList.append(val);
if (m_volumesList.count() > m_maxVolumes) {
m_volumesList.removeFirst();
}
// remove volume if it is zero
while (m_volumesList.size() > 0 && m_volumesList[0] == 0)
m_volumesList.removeFirst();
emit volumesListChanged();
// index of rectangle to animate
if (m_volumesList.count() != 0) {
m_animationIndex = m_volumesList.count();
emit animationIndexChanged();
}
m_audioSum = 0;
m_audioLen = 0;
}
}
void AudioProber::process(QAudioBuffer buffer)
{
int sum = 0;
for (int i = 0; i < buffer.sampleCount(); i++) {
sum += abs(static_cast<short *>(buffer.data())[i]);
}
sum /= buffer.sampleCount();
if (sum > MAX_VOLUME)
sum = MAX_VOLUME;
m_audioSum += sum;
m_audioLen++;
}

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb@kaidan.im>
* SPDX-FileCopyrightText: 2020 Devin Lin <espidev@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#ifndef AUDIOPROBER_H
#define AUDIOPROBER_H
#include <QAudioProbe>
#include <QObject>
#include <QTimer>
#include <QVariant>
#include <QDebug>
class AudioProber : public QAudioProbe
{
Q_OBJECT
Q_PROPERTY(QVariantList volumesList READ volumesList NOTIFY volumesListChanged)
Q_PROPERTY(int animationIndex READ animationIndex NOTIFY animationIndexChanged)
Q_PROPERTY(int maxVolumes READ maxVolumes WRITE setMaxVolumes NOTIFY maxVolumesChanged)
public:
explicit AudioProber(QObject *parent = nullptr);
void process(QAudioBuffer buffer);
void processVolumeBar();
QVariantList volumesList() const
{
return m_volumesList;
}
int maxVolumes()
{
return m_maxVolumes;
}
void setMaxVolumes(int m)
{
m_maxVolumes = m;
emit maxVolumesChanged();
}
int animationIndex()
{
return m_animationIndex;
}
void clearVolumesList()
{
while (!m_volumesList.empty())
m_volumesList.removeFirst();
emit volumesListChanged();
}
private:
int m_audioSum = 0; //
int m_audioLen = 0; // used for calculating the value of one volume bar from many
int m_animationIndex = 0; // which index rectangle is being expanded
int m_maxVolumes = 100; // based on width of screen
QVariantList m_volumesList;
QTimer* volumeBarTimer;
signals:
void volumesListChanged();
void animationIndexChanged();
void maxVolumesChanged();
};
#endif

@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb@kaidan.im>
* SPDX-FileCopyrightText: 2020 Devin Lin <espidev@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "audiorecorder.h"
#include <QDir>
AudioRecorder::AudioRecorder(QObject *parent)
: QAudioRecorder(parent)
{
m_audioProbe = new AudioProber(parent);
m_audioProbe->setSource(this);
checkFolder();
QQmlEngine::setObjectOwnership(m_audioProbe, QQmlEngine::CppOwnership);
// once the file is done writing, save recording to model
connect(this, &QAudioRecorder::stateChanged, this, &AudioRecorder::handleStateChange);
}
void AudioRecorder::setAudioCodec(const QString &codec)
{
m_encoderSettings.setCodec(codec);
setAudioSettings(m_encoderSettings);
emit audioCodecChanged();
}
void AudioRecorder::setAudioQuality(int quality)
{
m_encoderSettings.setQuality(QMultimedia::EncodingQuality(quality));
setAudioSettings(m_encoderSettings);
emit audioQualityChanged();
}
void AudioRecorder::handleStateChange(QAudioRecorder::State state)
{
if (state == QAudioRecorder::StoppedState) {
if (resetRequest) {
// reset
resetRequest = false;
QFile(actualLocation().toString()).remove();
qDebug() << "Discarded recording " << actualLocation().toString();
recordingName = "";
} else {
// rename file to desired file name
renameCurrentRecording();
// create recording
saveRecording();
}
// clear volumes list
m_audioProbe->clearVolumesList();
} else if (state == QAudioRecorder::PausedState) {
cachedDuration = duration();
}
}
void AudioRecorder::checkFolder()
{
QString path = QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + "/Recorder";
QDir dir(path);
if (!dir.exists())
dir.mkpath(path);
}
void AudioRecorder::renameCurrentRecording()
{
if (!recordingName.isEmpty()) {
// determine new file name
QStringList spl = actualLocation().fileName().split(".");
QString suffix = spl.size() > 0 ? "." + spl[spl.size() - 1] : "";
QString path = QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + "/Recorder/" + recordingName;
QString updatedPath = path + suffix;
// ignore if the file destination is the same as the one currently being written to
if (actualLocation().path() != (path + suffix)) {
// if the file already exists, add a number to the end
int cur = 1;
QFileInfo check(path + suffix);
while (check.exists()) {
updatedPath = QString("%1_%2%3").arg(path, QString::number(cur), suffix);
check = QFileInfo(updatedPath);
cur++;
}
QFile(actualLocation().path()).rename(updatedPath);
}
savedPath = updatedPath;
recordingName = "";
} else {
savedPath = actualLocation().path();
}
}
void AudioRecorder::saveRecording()
{
checkFolder();
// get file name from path
QStringList spl = savedPath.split("/");
QString fileName = spl.at(spl.size()-1).split(".")[0];
RecordingModel::instance()->insertRecording(savedPath, fileName, QDateTime::currentDateTime(), cachedDuration / 1000);
}

@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb@kaidan.im>
* SPDX-FileCopyrightText: 2020 Devin Lin <espidev@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#ifndef AUDIORECORDER_H
#define AUDIORECORDER_H
#include <QAudioRecorder>
#include <QAudioProbe>
#include <QAudioEncoderSettings>
#include <QStandardPaths>
#include <QUrl>
#include <QFileInfo>
#include <QTimer>
#include <QQmlEngine>
#include <QCoreApplication>
#include "audioprober.h"
#include "recordingmodel.h"
class AudioRecorder;
static AudioRecorder *s_audioRecorder = nullptr;
class AudioRecorder : public QAudioRecorder
{
Q_OBJECT
Q_PROPERTY(QStringList audioInputs READ audioInputs CONSTANT)
Q_PROPERTY(QStringList supportedAudioCodecs READ supportedAudioCodecs CONSTANT)
Q_PROPERTY(QStringList supportedContainers READ supportedContainers CONSTANT)
Q_PROPERTY(QString audioCodec READ audioCodec WRITE setAudioCodec NOTIFY audioCodecChanged)
Q_PROPERTY(int audioQuality READ audioQuality WRITE setAudioQuality NOTIFY audioQualityChanged)
Q_PROPERTY(QString containerFormat READ containerFormat WRITE setContainerFormat)
Q_PROPERTY(AudioProber* prober READ prober CONSTANT)
private:
explicit AudioRecorder(QObject *parent = nullptr);
void handleStateChange(QAudioRecorder::State state);
void checkFolder();
QAudioEncoderSettings m_encoderSettings {};
AudioProber *m_audioProbe;
QString recordingName = {}; // rename recording after recording finishes
QString savedPath = {}; // updated after the audio file is renamed
int cachedDuration = 0; // cache duration (since it is set to zero when the recorder is in StoppedState)
bool resetRequest = false;
public:
static AudioRecorder* instance()
{
if (!s_audioRecorder) {
s_audioRecorder = new AudioRecorder(qApp);
}
return s_audioRecorder;
}
AudioProber* prober()
{
return m_audioProbe;
}
QString audioCodec()
{
return m_encoderSettings.codec();
}
void setAudioCodec(const QString &codec);
int audioQuality()
{
return m_encoderSettings.quality();
}
void setAudioQuality(int quality);
Q_INVOKABLE void reset()
{
resetRequest = true;
stop();
}
Q_INVOKABLE void saveRecording();
void renameCurrentRecording();
Q_INVOKABLE void setRecordingName(const QString &rName) {
recordingName = rName;
}
signals:
void audioCodecChanged();
void audioQualityChanged();
};
#endif // AUDIORECORDER_H

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="32" height="32" version="1.1" viewBox="0 0 32 32" id="svg7" sodipodi:docname="delete.svg" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
<metadata id="metadata11">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview pagecolor="#ffffff" bordercolor="#666666" borderopacity="1" objecttolerance="10" gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="2160" inkscape:window-height="1277" id="namedview9" showgrid="false" inkscape:zoom="16.241359" inkscape:cx="-8.2644614" inkscape:cy="16.16354" inkscape:window-x="0" inkscape:window-y="30" inkscape:window-maximized="1" inkscape:current-layer="svg7" inkscape:document-rotation="0" />
<defs id="defs3">
<style id="current-color-scheme" type="text/css">.ColorScheme-Text { color:#363636; }</style>
</defs>
<path d="m 14.200019,6 c -0.664801,0 -1.200003,0.5574826 -1.200003,1.2499609 v 1.249961 H 7.6000016 C 7.2676007,8.4999219 7,8.7786632 7,9.1249023 7,9.4711415 7.2676007,9.7498828 7.6000016,9.7498828 H 8.1976632 L 8.853921,23.575701 C 8.9273612,24.845036 9.9413039,26 11.251527,26 h 9.496946 c 1.310283,0 2.324166,-1.154851 2.397606,-2.424299 L 23.802337,9.7498828 h 0.597662 C 24.732399,9.7498828 25,9.4711415 25,9.1249023 25,8.7786632 24.732399,8.4999219 24.399999,8.4999219 H 18.999984 V 7.2499609 C 18.999984,6.5574826 18.464782,6 17.799981,6 h -3.60001 z m 0,1.2499609 h 3.60001 v 1.249961 h -3.60001 z M 9.4000064,9.7498828 H 22.600042 L 21.948476,23.499453 c -0.04,0.691229 -0.535202,1.249961 -1.200003,1.249961 h -9.496946 c -0.664801,0 -1.160007,-0.558732 -1.200003,-1.249961 L 9.3999584,9.7498828 Z m 3.0000076,2.4999222 c -0.3324,0 -0.600001,0.278741 -0.600001,0.62498 v 8.749727 c 0,0.346239 0.267601,0.62498 0.600001,0.62498 0.332401,0 0.600002,-0.278741 0.600002,-0.62498 v -8.749727 c 0,-0.346239 -0.267601,-0.62498 -0.600002,-0.62498 z m 3.60001,0 c -0.332401,0 -0.600002,0.278741 -0.600002,0.62498 v 8.749727 c 0,0.346239 0.267601,0.62498 0.600002,0.62498 0.332401,0 0.600002,-0.278741 0.600002,-0.62498 v -8.749727 c 0,-0.346239 -0.267601,-0.62498 -0.600002,-0.62498 z m 3.600009,0 c -0.3324,0 -0.600001,0.278741 -0.600001,0.62498 v 8.749727 c 0,0.346239 0.267601,0.62498 0.600001,0.62498 0.332402,0 0.600002,-0.278741 0.600002,-0.62498 v -8.749727 c 0,-0.346239 -0.2676,-0.62498 -0.600002,-0.62498 z" style="fill:#2073e6;fill-opacity:1;stroke-width:1.22473" class="ColorScheme-Text" id="path5" />
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs
id="defs3051">
<style
type="text/css"
id="current-color-scheme">
.ColorScheme-Text {
color:#FFFFFF;
}
</style>
</defs>
<path
style="fill:currentColor;fill-opacity:1;stroke:none"
d="m 6 6 0 20 8 0 0 -20 z m 12 0 0 20 8 0 0 -20 z"
id="path8"
class="ColorScheme-Text"
/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs
id="defs3051">
<style
type="text/css"
id="current-color-scheme">
.ColorScheme-Text {
color:#000000;
}
</style>
</defs>
<path
style="fill:currentColor;fill-opacity:1;stroke:none"
d="m 6 6 0 20 8 0 0 -20 z m 12 0 0 20 8 0 0 -20 z"
id="path8"
class="ColorScheme-Text"
/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="64" height="64" viewBox="0 0 16.933333 16.933334" version="1.1" id="svg8" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" sodipodi:docname="recorder.svg">
<defs id="defs2" />
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="5.6" inkscape:cx="12.943427" inkscape:cy="49.245155" inkscape:document-units="mm" inkscape:current-layer="layer1" inkscape:document-rotation="0" showgrid="false" units="px" inkscape:window-width="1619" inkscape:window-height="997" inkscape:window-x="372" inkscape:window-y="236" inkscape:window-maximized="0" />
<metadata id="metadata5">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1">
<path style="fill:#fc1a1a;fill-opacity:1;fill-rule:evenodd;stroke:#9f0000;stroke-width:0" id="path833" sodipodi:type="arc" sodipodi:cx="8.4666662" sodipodi:cy="8.4666672" sodipodi:rx="5.2916546" sodipodi:ry="5.2916503" sodipodi:start="3.1533321" sodipodi:end="3.1530769" sodipodi:arc-type="slice" d="M 3.1753763,8.4045476 A 5.2916546,5.2916503 0 0 1 8.5284483,3.1753776 5.2916546,5.2916503 0 0 1 13.757964,8.5281117 5.2916546,5.2916503 0 0 1 8.4055592,13.757965 5.2916546,5.2916503 0 0 1 3.1753606,8.4058978 l 5.2913056,0.060769 z" />
<circle style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#FFFFFF;stroke-width:0.748822;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.8" id="path833-3" cx="8.4666662" cy="8.4666662" r="6.2401724" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="64" height="64" viewBox="0 0 16.933333 16.933334" version="1.1" id="svg8" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" sodipodi:docname="recorder.svg">
<defs id="defs2" />
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="5.6" inkscape:cx="12.943427" inkscape:cy="49.245155" inkscape:document-units="mm" inkscape:current-layer="layer1" inkscape:document-rotation="0" showgrid="false" units="px" inkscape:window-width="1619" inkscape:window-height="997" inkscape:window-x="372" inkscape:window-y="236" inkscape:window-maximized="0" />
<metadata id="metadata5">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1">
<path style="fill:#fc1a1a;fill-opacity:1;fill-rule:evenodd;stroke:#9f0000;stroke-width:0" id="path833" sodipodi:type="arc" sodipodi:cx="8.4666662" sodipodi:cy="8.4666672" sodipodi:rx="5.2916546" sodipodi:ry="5.2916503" sodipodi:start="3.1533321" sodipodi:end="3.1530769" sodipodi:arc-type="slice" d="M 3.1753763,8.4045476 A 5.2916546,5.2916503 0 0 1 8.5284483,3.1753776 5.2916546,5.2916503 0 0 1 13.757964,8.5281117 5.2916546,5.2916503 0 0 1 8.4055592,13.757965 5.2916546,5.2916503 0 0 1 3.1753606,8.4058978 l 5.2913056,0.060769 z" />
<circle style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.748822;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.5" id="path833-3" cx="8.4666662" cy="8.4666662" r="6.2401724" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs
id="defs3051">
<style
type="text/css"
id="current-color-scheme">
.ColorScheme-Text {
color:#FFFFFF;
}
</style>
</defs>
<path
style="fill:currentColor;fill-opacity:1;stroke:none"
d="m 6 6 0 20 20 -10 z"
id="path105"
class="ColorScheme-Text"
/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs
id="defs3051">
<style
type="text/css"
id="current-color-scheme">
.ColorScheme-Text {
color:#000000;
}
</style>
</defs>
<path
style="fill:currentColor;fill-opacity:1;stroke:none"
d="m 6 6 0 20 20 -10 z"
id="path105"
class="ColorScheme-Text"
/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="64" height="64" viewBox="0 0 16.933333 16.933334" version="1.1" id="svg8" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" sodipodi:docname="recorder-dark.svg">
<defs id="defs2" />
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="5.6" inkscape:cx="12.943427" inkscape:cy="49.245155" inkscape:document-units="mm" inkscape:current-layer="layer1" inkscape:document-rotation="0" showgrid="false" units="px" inkscape:window-width="2160" inkscape:window-height="1277" inkscape:window-x="0" inkscape:window-y="30" inkscape:window-maximized="1" />
<metadata id="metadata5">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1">
<circle style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:white;stroke-width:0.748822;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.5" id="path833-3" cx="8.4666662" cy="8.4666662" r="6.2401724" />
<rect style="fill:#fc1a1a;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.73228" id="rect832" width="5.2916665" height="5.2916665" x="5.8208332" y="5.8208332" rx="0.63872445" ry="0.83954406" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="64" height="64" viewBox="0 0 16.933333 16.933334" version="1.1" id="svg8" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" sodipodi:docname="recorder-light.svg">
<defs id="defs2" />
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="5.6" inkscape:cx="12.943427" inkscape:cy="49.245155" inkscape:document-units="mm" inkscape:current-layer="layer1" inkscape:document-rotation="0" showgrid="false" units="px" inkscape:window-width="2160" inkscape:window-height="1277" inkscape:window-x="0" inkscape:window-y="30" inkscape:window-maximized="1" />
<metadata id="metadata5">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1">
<circle style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:black;stroke-width:0.748822;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.5" id="path833-3" cx="8.4666662" cy="8.4666662" r="6.2401724" />
<rect style="fill:#fc1a1a;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.73228" id="rect832" width="5.2916665" height="5.2916665" x="5.8208332" y="5.8208332" rx="0.63872445" ry="0.83954406" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1,42 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include "recordingmodel.h"
#include "utils.h"
#include "audioplayer.h"
#include "audiorecorder.h"
#include "audioprober.h"
#include "settingsmodel.h"
int main(int argc, char *argv[])
{
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QGuiApplication app(argc, argv);
qmlRegisterType<Recording>("Recorder", 1, 0, "Recording");
qmlRegisterType<AudioProber>("Recorder", 1, 0, "AudioProber");
qmlRegisterSingletonType<Utils>("Recorder", 1, 0, "Utils", [] (QQmlEngine *, QJSEngine *) -> QObject* {
return new Utils;
});
qmlRegisterSingletonType<SettingsModel>("Recorder", 1, 0, "AudioPlayer", [] (QQmlEngine *, QJSEngine *) -> QObject* {
return AudioPlayer::instance();
});
qmlRegisterSingletonType<SettingsModel>("Recorder", 1, 0, "AudioRecorder", [] (QQmlEngine *, QJSEngine *) -> QObject* {
return AudioRecorder::instance();
});
qmlRegisterSingletonType<RecordingModel>("Recorder", 1, 0, "RecordingModel", [] (QQmlEngine *, QJSEngine *) -> QObject* {
return RecordingModel::instance();
});
qmlRegisterSingletonType<SettingsModel>("Recorder", 1, 0, "SettingsModel", [] (QQmlEngine *, QJSEngine *) -> QObject* {
return SettingsModel::instance();
});
QQmlApplicationEngine engine;
engine.load(QUrl(QStringLiteral("qrc:/qml/main.qml")));
if (engine.rootObjects().isEmpty()) {
return -1;
}
return app.exec();
}

@ -0,0 +1,18 @@
<RCC>
<qresource prefix="/">
<file>qml/main.qml</file>
<file>qml/RecordPage.qml</file>
<file>qml/Visualization.qml</file>
<file>qml/HomePage.qml</file>
<file>images/recorder-dark.svg</file>
<file>images/recorder-light.svg</file>
<file>images/stop-light.svg</file>
<file>images/stop-dark.svg</file>
<file>images/pause-dark.svg</file>
<file>images/pause-light.svg</file>
<file>images/start-dark.svg</file>
<file>images/start-light.svg</file>
<file>qml/IconButton.qml</file>
<file>images/delete.svg</file>
</qresource>
</RCC>

@ -0,0 +1,241 @@
import QtQuick 2.4
import QtQuick.Controls 2.4
import QtQuick.Layouts 1.3
import QtGraphicalEffects 1.0
import Recorder 1.0
import MeuiKit 1.0 as Meui
Item {
id: control
signal recordClicked()
ColumnLayout {
anchors.fill: parent
spacing: Meui.Units.largeSpacing
ListView {
id: recordingView
model: RecordingModel
leftMargin: Meui.Units.largeSpacing
rightMargin: Meui.Units.largeSpacing
topMargin: Meui.Units.largeSpacing
bottomMargin: Meui.Units.largeSpacing
spacing: Meui.Units.largeSpacing
clip: true
currentIndex: -1
Layout.fillHeight: true
Layout.fillWidth: true
ScrollBar.vertical: ScrollBar {}
onCurrentIndexChanged: {
AudioPlayer.stop()
AudioPlayer.setPosition(0)
}
Label {
anchors.centerIn: parent
text: qsTr("No recordings yet")
visible: recordingView.count === 0
}
delegate: MouseArea {
id: item
property Recording recording: modelData
property bool isSelected: recordingView.currentIndex === index
property bool isLast: index === recordingView.count - 1
property bool isPlaying: AudioPlayer.state === AudioPlayer.PlayingState
property var path: recording.filePath
height: childrenRect.height
width: parent ? parent.width : undefined
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: {
if (recordingView.currentIndex === index)
recordingView.currentIndex = -1
else
recordingView.currentIndex = index
}
Behavior on height {
NumberAnimation {
duration: 200
easing.type: Easing.InOutQuad
}
}
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
RowLayout {
Label {
text: recording.fileName
color: Meui.Theme.darkMode ? "#FFFFFF" : "#000000"
elide: Label.ElideLeft
Layout.fillWidth: true
}
Label {
text: recording.recordingLength
}
}
Label {
text: recording.recordDate
color: Meui.Theme.disabledTextColor
}
Item {
height: Meui.Units.largeSpacing
visible: isSelected
}
Slider {
id: playerSlider
from: 0
to: AudioPlayer.duration
value: AudioPlayer.position
onMoved: AudioPlayer.setPosition(value)
visible: isSelected
Layout.fillWidth: true
}
Item {
height: Meui.Units.smallSpacing
visible: isSelected
}
// Play control
RowLayout {
id: bottomLayout
visible: isSelected
spacing: Meui.Units.largeSpacing
Item {
width: 32
height: width
}
Item {
Layout.fillWidth: true
}
IconButton {
id: controlButton
source: isPlaying ? Meui.Theme.darkMode ? "qrc:/images/pause-dark.svg" : "qrc:/images/pause-light.svg"
: Meui.Theme.darkMode ? "qrc:/images/start-dark.svg" : "qrc:/images/start-light.svg"
width: 32
height: width
onClicked: {
var path = item.recording.filePath
console.log("Play: " + path)
if (path === AudioPlayer.mediaPath) {
AudioPlayer.state === AudioPlayer.PlayingState ? AudioPlayer.pause() : AudioPlayer.play()
} else {
AudioPlayer.setVolume(100);
AudioPlayer.setMediaPath(recording.filePath)
AudioPlayer.play()
}
}
}
Item {
Layout.fillWidth: true
}
IconButton {
id: deleteButton
source: "qrc:/images/delete.svg"
width: 32
height: width
onClicked: {
deletePromptDialog.deleteIndex = index
deletePromptDialog.open()
}
}
}
Rectangle {
width: item.width
height: 1
color: isLast ? "transparent" : Qt.rgba(Meui.Theme.textColor.r,
Meui.Theme.textColor.g,
Meui.Theme.textColor.b, 0.1)
visible: true
}
}
}
}
Item {
height: 100
Layout.fillWidth: true
Rectangle {
anchors.fill: parent
color: Meui.Theme.viewBackgroundColor
}
IconButton {
id: recordButton
Layout.alignment: Qt.AlignHCenter
anchors.centerIn: parent
onClicked: control.recordClicked()
source: Meui.Theme.darkMode ? "qrc:/images/recorder-dark.svg" : "qrc:/images/recorder-light.svg"
}
}
}
Dialog {
id: deletePromptDialog
modal: true
x: (control.width - deletePromptDialog.width) / 2
y: (control.height - deletePromptDialog.height) / 2
property var deleteIndex: -1
ColumnLayout {
anchors.fill: parent
Label {
text: qsTr("Are you sure you want to delete this recording?")
}
RowLayout {
Item {
Layout.fillWidth: true
}
Button {
text: qsTr("Delete")
onClicked: {
// Reset current index
recordingView.currentIndex = -1
// Delete file
RecordingModel.deleteRecording(deletePromptDialog.deleteIndex)
// Close dialog
deletePromptDialog.close()
}
}
Button {
text: qsTr("Canel")
onClicked: deletePromptDialog.close()
}
Item {
Layout.fillWidth: true
}
}
}
}
}

@ -0,0 +1,27 @@
import QtQuick 2.4
import QtQuick.Controls 2.4
import QtGraphicalEffects 1.0
MouseArea {
id: control
width: 64
height: width
property alias source: icon.source
property alias overlayColor: colorOverlay.color
Image {
id: icon
anchors.fill: parent
sourceSize: Qt.size(width, height)
ColorOverlay {
id: colorOverlay
anchors.fill: icon
source: icon
color: "#FFFFFF"
opacity: 0.3
visible: control.pressed
}
}
}

@ -0,0 +1,149 @@
import QtQuick 2.4
import QtQuick.Controls 2.4
import QtQuick.Layouts 1.3
import QtGraphicalEffects 1.0
import Recorder 1.0
import MeuiKit 1.0 as Meui
Page {
id: control
property bool isStopped: AudioRecorder.state === AudioRecorder.StoppedState
property bool isPaused: AudioRecorder.state === AudioRecorder.PausedState
signal finished()
function record() {
if (isStopped || isPaused) {
AudioRecorder.record()
} else {
AudioRecorder.pause()
}
}
function stop() {
AudioRecorder.pause()
}
Connections {
target: AudioRecorder
function onError(error) {
console.warn("Error on the recorder", error)
}
}
ColumnLayout {
anchors.fill: parent
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
anchors.fill: parent
anchors.margins: Meui.Units.largeSpacing
Label {
id: timeText
Layout.alignment: Qt.AlignHCenter
text: isStopped ? "00:00:00" : Utils.formatTime(AudioRecorder.duration)
}
Visualization {
Layout.fillWidth: true
Layout.fillHeight: true
showLine: true
height: Meui.Units.gridUnit * 15
maxBarHeight: Meui.Units.gridUnit * 5
animationIndex: AudioRecorder.prober.animationIndex
volumes: AudioRecorder.prober.volumesList
}
}
}
Item {
id: bottomBar
Layout.fillWidth: true
height: 100
Rectangle {
anchors.fill: parent
color: Meui.Theme.viewBackgroundColor
}
RowLayout {
anchors.fill: parent
IconButton {
source: Meui.Theme.darkMode ? "qrc:/images/stop-dark.svg" : "qrc:/images/stop-light.svg"
Layout.alignment: Qt.AlignCenter
onClicked: {
saveDialog.open()
control.stop()
saveDialog.open()
}
}
}
}
}
Dialog {
id: saveDialog
modal: true
x: (control.width - saveDialog.width) / 2
y: (control.height - saveDialog.height) / 2
onVisibleChanged: {
if (visible)
recordingName.text = RecordingModel.nextDefaultRecordingName()
}
ColumnLayout {
anchors.fill: parent
RowLayout {
Label {
Layout.alignment: Qt.AlignVCenter
text: qsTr("Name")
}
TextField {
id: recordingName
placeholderText: RecordingModel.nextDefaultRecordingName()
text: RecordingModel.nextDefaultRecordingName()
}
}
RowLayout {
Item {
Layout.fillWidth: true
}
Button {
text: qsTr("Done")
onClicked: {
AudioRecorder.setRecordingName(recordingName.text)
AudioRecorder.stop();
recordingName.text = ""
saveDialog.close()
control.finished()
}
}
Button {
text: qsTr("Delete")
onClicked: {
AudioRecorder.reset()
saveDialog.close()
control.finished()
}
}
Item {
Layout.fillWidth: true
}
}
}
}
}

@ -0,0 +1,63 @@
import QtQuick 2.4
import QtQuick.Controls 2.4
import QtQuick.Layouts 1.3
import Recorder 1.0
Item {
id: visualization
property int maxBarHeight
property int animationIndex // which index rectangle is being expanded
property var volumes: []
property bool showLine
Component.onCompleted: {
AudioRecorder.prober.maxVolumes = width / 4;
AudioPlayer.prober.maxVolumes = width / 4;
}
onWidthChanged: {
AudioRecorder.prober.maxVolumes = width / 4;
AudioPlayer.prober.maxVolumes = width / 4;
}
// central line
Rectangle {
visible: showLine
id: centralLine
width: parent.width
height: 3
anchors.verticalCenter: parent.verticalCenter
color: "#e0e0e0"
}
ListView {
id: list
model: visualization.volumes
orientation: Qt.Horizontal
interactive: false
anchors.verticalCenter: centralLine.verticalCenter
height: maxBarHeight
width: parent.width
delegate: Item {
width: 4
height: list.height
Rectangle {
color: "#616161"
width: 2
height: index === animationIndex ? 0 : 2 * maxBarHeight * modelData / 1000
antialiasing: true
anchors.verticalCenter: parent.verticalCenter
Behavior on height {
SmoothedAnimation {
duration: 500
}
}
}
}
}
}

@ -0,0 +1,45 @@
import QtQuick 2.4
import QtQuick.Controls 2.4
import QtQuick.Layouts 1.3
import Recorder 1.0
import MeuiKit 1.0 as Meui
ApplicationWindow {
id: root
// width: 500
// height: 600
minimumWidth: 400
minimumHeight: 550
visible: true
title: qsTr("Recorder")
background: Rectangle {
color: Meui.Theme.backgroundColor
}
HomePage {
id: homePage
onRecordClicked: {
AudioRecorder.record()
stackView.push(recordPage)
}
}
RecordPage {
id: recordPage
visible: false
onFinished: {
stackView.pop()
}
}
StackView {
id: stackView
anchors.fill: parent
initialItem: homePage
}
}

@ -0,0 +1,202 @@
/*
* SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb@kaidan.im>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "recordingmodel.h"
#include <QFile>
#include <QStandardPaths>
#include <QJsonObject>
#include <QDebug>
#include <QJsonDocument>
#include <QJsonArray>
#include "utils.h"
const QString DEF_RECORD_PREFIX = Recording::tr("New Recording");
/* ~ Recording ~ */
Recording::Recording(QObject* parent, const QString &filePath, const QString &fileName, QDateTime recordDate, int recordingLength)
: QObject(parent)
, m_filePath(filePath)
, m_fileName(fileName)
, m_recordDate(recordDate)
, m_recordingLength(recordingLength)
{}
Recording::Recording(const QJsonObject &obj)
: m_filePath(obj["filePath"].toString())
, m_fileName(obj["fileName"].toString())
, m_recordDate(QDateTime::fromString(obj["recordDate"].toString(), Qt::DateFormat::ISODate))
, m_recordingLength(obj["recordingLength"].toInt())
{
}
Recording::~Recording()
{
}
QJsonObject Recording::toJson() const
{
QJsonObject obj;
obj["filePath"] = m_filePath;
obj["fileName"] = m_fileName;
obj["recordDate"] = m_recordDate.toString(Qt::DateFormat::ISODate);
obj["recordingLength"] = m_recordingLength;
return obj;
}
QString Recording::recordingLengthPretty() const
{
const int hours = m_recordingLength / 60 / 60;
const int min = m_recordingLength / 60 - hours * 60;
const int sec = m_recordingLength - min * 60 - hours * 60 * 60;
return QStringLiteral("%1:%2:%3").arg(hours, 2, 10, QLatin1Char('0')).arg(min, 2, 10, QLatin1Char('0')).arg(sec, 2, 10, QLatin1Char('0'));
}
void Recording::setFilePath(const QString &filePath)
{
QFile(m_filePath).rename(filePath);
m_filePath = filePath;
QStringList spl = filePath.split("/");
m_fileName = spl[spl.size()-1].split(".")[0];
emit propertyChanged();
}
void Recording::setFileName(const QString &fileName)
{
QString oldPath = m_filePath;
m_filePath.replace(QRegExp(m_fileName + "(?!.*" + m_fileName + ")"), fileName);
QFile(oldPath).rename(m_filePath);
m_fileName = fileName;
emit propertyChanged();