Browse Source

feat: voice compressor with RMS+peak leveling, sliding window envelope, rate-limited gain

topo_upd
Evgeny 2 months ago
parent
commit
f1b599f0c0
  1. 1
      tools/chatgui/CMakeLists.txt
  2. 147
      tools/chatgui/src/audiocompressor.cpp
  3. 59
      tools/chatgui/src/audiocompressor.h
  4. 115
      tools/chatgui/src/audiodevicesettingspage.cpp
  5. 24
      tools/chatgui/src/audiodevicesettingspage.h
  6. 40
      tools/chatgui/src/audiorecorder.cpp
  7. 10
      tools/chatgui/src/audiorecorder.h
  8. 19
      tools/chatgui/src/mainwindow.cpp

1
tools/chatgui/CMakeLists.txt

@ -77,6 +77,7 @@ add_executable(chatgui
src/soundsettingspage.cpp
src/sound_manager.cpp
src/audiorecorder.cpp
src/audiocompressor.cpp
src/voicemessageencoder.cpp
src/voiceplayback.cpp
src/audiodevicesettingspage.cpp

147
tools/chatgui/src/audiocompressor.cpp

@ -0,0 +1,147 @@
#include "audiocompressor.h"
#include <QDebug>
#include <cmath>
#include <algorithm>
#include <cstring>
void AudioCompressor::configure(const Config& cfg) {
m_cfg = cfg;
m_blockSamples = m_cfg.sampleRate * m_cfg.blockDurationMs / 1000 * m_cfg.channels;
m_lookbackBlocks = m_cfg.lookbackMs / m_cfg.blockDurationMs;
m_lookaheadBlocks = m_cfg.lookaheadMs / m_cfg.blockDurationMs;
m_riseFactorPerBlock = powf(m_cfg.riseRatePer500ms, (float)m_cfg.blockDurationMs / 500.0f);
m_maxGain = powf(10.0f, m_cfg.maxGainDb / 20.0f);
qDebug("AudioCompressor: config rate=%d ch=%d block=%dms window=%dms+%dms maxGain=%.0fdB riseRate=%.1fx/500ms target=%.0fdBFS",
m_cfg.sampleRate, m_cfg.channels, m_cfg.blockDurationMs,
m_cfg.lookbackMs, m_cfg.lookaheadMs, m_cfg.maxGainDb,
m_cfg.riseRatePer500ms, 20.0f * log10f(m_cfg.targetLevel));
}
void AudioCompressor::reset() {
m_blockLevels.clear();
m_pending.clear();
m_accumulator.clear();
m_output.clear();
m_gainSmoothed = 1.0f;
m_dbgCounter = 0;
}
void AudioCompressor::push(const int16_t* samples, size_t count) {
if (!m_enabled) {
m_output.insert(m_output.end(), samples, samples + count);
return;
}
size_t remaining = count;
const int16_t* src = samples;
while (remaining > 0) {
size_t need = (size_t)m_blockSamples - m_accumulator.size();
size_t take = std::min(remaining, need);
m_accumulator.insert(m_accumulator.end(), src, src + take);
src += take;
remaining -= take;
if (m_accumulator.size() == (size_t)m_blockSamples) {
float level = computeBlockLevel(m_accumulator.data(), m_accumulator.size());
BlockLevel bl;
bl.level = level;
m_blockLevels.push_back(bl);
PendingBlock pb;
pb.samples = std::move(m_accumulator);
pb.levelIndex = m_blockLevels.size() - 1;
m_pending.push_back(std::move(pb));
m_accumulator.clear();
}
}
while (m_pending.size() > (size_t)m_lookaheadBlocks) {
processPendingBlock();
}
}
float AudioCompressor::computeBlockLevel(const int16_t* samples, size_t count) const {
float sumSq = 0.0f;
float peak = 0.0f;
for (size_t i = 0; i < count; i++) {
float v = (float)samples[i] / 32768.0f;
sumSq += v * v;
float av = fabsf(v);
if (av > peak) peak = av;
}
float rms = sqrtf(sumSq / (float)count);
return (rms * 2.0f + peak) / 2.0f;
}
void AudioCompressor::processPendingBlock() {
PendingBlock& block = m_pending.front();
size_t idx = block.levelIndex;
size_t winStart = idx >= (size_t)m_lookbackBlocks ? idx - (size_t)m_lookbackBlocks : 0;
size_t winEnd = std::min(idx + (size_t)m_lookaheadBlocks, m_blockLevels.size() - 1);
float envelope = 0.0f;
for (size_t i = winStart; i <= winEnd; i++) {
if (m_blockLevels[i].level > envelope) envelope = m_blockLevels[i].level;
}
if (envelope < 0.0001f) envelope = 0.0001f;
float G_raw = m_cfg.targetLevel / envelope;
float G_prev = m_gainSmoothed;
if (G_raw > m_gainSmoothed) {
float maxRise = m_gainSmoothed * m_riseFactorPerBlock;
m_gainSmoothed = std::min(G_raw, maxRise);
} else {
m_gainSmoothed = G_raw;
}
m_gainSmoothed = std::min(m_gainSmoothed, m_maxGain);
static int dbgCounter = 0;
float gainDb = 20.0f * log10f(m_gainSmoothed);
float prevDb = 20.0f * log10f(G_prev > 0.0001f ? G_prev : 0.0001f);
if (m_dbgCounter % 25 == 0 || fabsf(gainDb - prevDb) > 3.0f) {
qDebug("AudioCompressor: block %zu level=%.4f envelope=%.4f gainRaw=%.1fdB gain=%.1fdB",
idx, m_blockLevels[idx].level, envelope, 20.0f * log10f(G_raw), gainDb);
}
m_dbgCounter++;
for (size_t i = 0; i < block.samples.size(); i++) {
float v = (float)block.samples[i] * m_gainSmoothed;
if (v > 32767.0f) v = 32767.0f;
if (v < -32768.0f) v = -32768.0f;
m_output.push_back((int16_t)(int)v);
}
m_pending.pop_front();
}
void AudioCompressor::flush() {
if (!m_enabled) return;
if (!m_accumulator.empty()) {
float level = computeBlockLevel(m_accumulator.data(), m_accumulator.size());
BlockLevel bl;
bl.level = level;
m_blockLevels.push_back(bl);
PendingBlock pb;
pb.samples = std::move(m_accumulator);
pb.levelIndex = m_blockLevels.size() - 1;
m_pending.push_back(std::move(pb));
m_accumulator.clear();
}
int remain = (int)m_pending.size();
qDebug("AudioCompressor: flush %d pending blocks (gainSmoothed=%.1fdB)", remain,
20.0f * log10f(m_gainSmoothed));
while (!m_pending.empty()) {
processPendingBlock();
}
}

59
tools/chatgui/src/audiocompressor.h

@ -0,0 +1,59 @@
#pragma once
#include <vector>
#include <deque>
#include <cstdint>
class AudioCompressor {
public:
struct Config {
int sampleRate = 48000;
int channels = 1;
int blockDurationMs = 20;
int lookbackMs = 200;
int lookaheadMs = 100;
float maxGainDb = 30.0f;
float riseRatePer500ms = 2.0f;
float targetLevel = 0.25f;
};
void configure(const Config& cfg);
void reset();
void setEnabled(bool enabled) { m_enabled = enabled; }
bool isEnabled() const { return m_enabled; }
void push(const int16_t* samples, size_t count);
const std::vector<int16_t>& outputBuffer() const { return m_output; }
bool hasPending() const { return !m_pending.empty() || !m_accumulator.empty(); }
void flush();
private:
Config m_cfg;
bool m_enabled = true;
struct BlockLevel {
float level;
};
std::deque<BlockLevel> m_blockLevels;
struct PendingBlock {
std::vector<int16_t> samples;
size_t levelIndex;
};
std::deque<PendingBlock> m_pending;
std::vector<int16_t> m_accumulator;
std::vector<int16_t> m_output;
int m_blockSamples = 960;
int m_lookbackBlocks = 10;
int m_lookaheadBlocks = 5;
float m_riseFactorPerBlock = 1.0f;
float m_maxGain = 31.62f;
float m_gainSmoothed = 1.0f;
int m_dbgCounter = 0;
float computeBlockLevel(const int16_t* samples, size_t count) const;
void processPendingBlock();
};

115
tools/chatgui/src/audiodevicesettingspage.cpp

@ -1,6 +1,7 @@
#include "audiodevicesettingspage.h"
#include "sound_manager.h"
#include "audiorecorder.h"
#include "audiocompressor.h"
#include "voicemessageencoder.h"
#include "../db/db_manager.h"
#include "../transport/gui_bridge.h"
@ -111,6 +112,14 @@ AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent)
m_codecPresetCombo->setStyleSheet(m_outputCombo->styleSheet());
layout->addWidget(m_codecPresetCombo);
/* --- Separator --- */
auto* sep4 = new QFrame(this);
sep4->setFrameShape(QFrame::HLine);
sep4->setStyleSheet("QFrame { color: palette(mid); }");
layout->addWidget(sep4);
buildCompressorUI(layout);
layout->addStretch();
m_levelTimer = new QTimer(this);
@ -148,6 +157,12 @@ void AudioDeviceSettingsPage::loadFromDb() {
m_savedOutputIdx = m_db->getUiStateInt("audio_output_device", -1);
m_savedInputIdx = m_db->getUiStateInt("audio_input_device", -1);
m_savedCodecPreset = m_db->getUiStateInt("opus_codec_preset", 1);
m_savedCompressorEnabled = m_db->getUiStateInt("compressor_enabled", 0);
m_savedCompressorMaxGainDb = m_db->getUiStateInt("compressor_max_gain_db", 30);
m_savedCompressorLookbackMs = m_db->getUiStateInt("compressor_lookback_ms", 200);
m_savedCompressorLookaheadMs = m_db->getUiStateInt("compressor_lookahead_ms", 100);
m_savedCompressorRiseRateTenths = m_db->getUiStateInt("compressor_rise_rate_tenths", 20);
m_savedCompressorTargetLevelDb = m_db->getUiStateInt("compressor_target_level_db", -12);
}
/* Select saved indices */
@ -163,6 +178,14 @@ void AudioDeviceSettingsPage::loadFromDb() {
if (m_codecPresetCombo->itemData(i).toInt() == m_savedCodecPreset)
{ m_codecPresetCombo->setCurrentIndex(i); break; }
}
m_compressorEnabledCb->setChecked(m_savedCompressorEnabled != 0);
m_compressorMaxGainSlider->setValue(m_savedCompressorMaxGainDb);
m_compressorLookbackSlider->setValue(m_savedCompressorLookbackMs);
m_compressorLookaheadSlider->setValue(m_savedCompressorLookaheadMs);
m_compressorRiseRateSlider->setValue(m_savedCompressorRiseRateTenths);
m_compressorTargetLevelSlider->setValue(m_savedCompressorTargetLevelDb);
updateCompressorLabels();
}
void AudioDeviceSettingsPage::applyAndSave() {
@ -186,10 +209,32 @@ void AudioDeviceSettingsPage::applyAndSave() {
VoiceEncoder::setPreset(codecPreset);
int compEnabled = m_compressorEnabledCb->isChecked() ? 1 : 0;
int compMaxGain = m_compressorMaxGainSlider->value();
int compLookback = m_compressorLookbackSlider->value();
int compLookahead = m_compressorLookaheadSlider->value();
int compRiseRateTenths = m_compressorRiseRateSlider->value();
int compTargetLevel = m_compressorTargetLevelSlider->value();
saveKey("compressor_enabled", compEnabled);
saveKey("compressor_max_gain_db", compMaxGain);
saveKey("compressor_lookback_ms", compLookback);
saveKey("compressor_lookahead_ms", compLookahead);
saveKey("compressor_rise_rate_tenths", compRiseRateTenths);
saveKey("compressor_target_level_db", compTargetLevel);
m_savedOutputIdx = outIdx;
m_savedInputIdx = inIdx;
m_savedCodecPreset = codecPreset;
qDebug("AudioDeviceSettings: saved output=%d input=%d codec=%d", outIdx, inIdx, codecPreset);
m_savedCompressorEnabled = compEnabled;
m_savedCompressorMaxGainDb = compMaxGain;
m_savedCompressorLookbackMs = compLookback;
m_savedCompressorLookaheadMs = compLookahead;
m_savedCompressorRiseRateTenths = compRiseRateTenths;
m_savedCompressorTargetLevelDb = compTargetLevel;
qDebug("AudioDeviceSettings: saved output=%d input=%d codec=%d compressor=%d gain=%ddB lookback=%d lookahead=%d rise=%.1fx target=%ddB",
outIdx, inIdx, codecPreset, compEnabled, compMaxGain, compLookback, compLookahead,
(float)compRiseRateTenths / 10.0f, compTargetLevel);
}
void AudioDeviceSettingsPage::onTestMicToggled() {
@ -244,3 +289,71 @@ void AudioDeviceSettingsPage::onTestSpeaker() {
pcm[i] = (int16_t)(12000.0 * sin(2.0 * M_PI * 440.0 * i / sampleRate));
SoundManager::instance()->playRawPcm(pcm.data(), samples, sampleRate, 1);
}
void AudioDeviceSettingsPage::buildCompressorUI(QVBoxLayout* layout) {
auto* compLabel = new QLabel("Voice Compressor", this);
compLabel->setStyleSheet("font-size: 13px; font-weight: bold;");
layout->addWidget(compLabel);
m_compressorEnabledCb = new QCheckBox("Enable compressor (leveler/normalizer)", this);
m_compressorEnabledCb->setStyleSheet("font-size: 12px;");
layout->addWidget(m_compressorEnabledCb);
auto makeSliderRow = [&](const QString& name, QSlider*& slider, QLabel*& lbl,
int min, int max, int step) {
auto* row = new QHBoxLayout();
row->setSpacing(6);
auto* textLabel = new QLabel(name, this);
textLabel->setFixedWidth(180);
textLabel->setStyleSheet("font-size: 12px;");
row->addWidget(textLabel);
slider = new QSlider(Qt::Horizontal, this);
slider->setRange(min, max);
slider->setSingleStep(step);
slider->setStyleSheet(
"QSlider::groove:horizontal { height: 4px; background: palette(mid); border-radius: 2px; }"
"QSlider::handle:horizontal { width: 12px; margin: -5px 0; background: palette(highlight); border-radius: 6px; }");
row->addWidget(slider, 1);
lbl = new QLabel(this);
lbl->setFixedWidth(60);
lbl->setStyleSheet("font-size: 12px;");
lbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
row->addWidget(lbl);
layout->addLayout(row);
};
makeSliderRow("Max gain:", m_compressorMaxGainSlider, m_compressorMaxGainLabel,
0, 30, 1);
makeSliderRow("Lookback time:", m_compressorLookbackSlider, m_compressorLookbackLabel,
50, 500, 10);
makeSliderRow("Lookahead time:", m_compressorLookaheadSlider, m_compressorLookaheadLabel,
20, 500, 10);
makeSliderRow("Rise rate (x/500ms):", m_compressorRiseRateSlider, m_compressorRiseRateLabel,
11, 100, 1);
makeSliderRow("Target level:", m_compressorTargetLevelSlider, m_compressorTargetLevelLabel,
-40, 0, 1);
auto connectSlider = [this](QSlider* slider) {
connect(slider, &QSlider::valueChanged, this, &AudioDeviceSettingsPage::updateCompressorLabels);
};
connectSlider(m_compressorMaxGainSlider);
connectSlider(m_compressorLookbackSlider);
connectSlider(m_compressorLookaheadSlider);
connectSlider(m_compressorRiseRateSlider);
connectSlider(m_compressorTargetLevelSlider);
}
void AudioDeviceSettingsPage::updateCompressorLabels() {
if (m_compressorMaxGainLabel)
m_compressorMaxGainLabel->setText(QString::number(m_compressorMaxGainSlider->value()) + " dB");
if (m_compressorLookbackLabel)
m_compressorLookbackLabel->setText(QString::number(m_compressorLookbackSlider->value()) + " ms");
if (m_compressorLookaheadLabel)
m_compressorLookaheadLabel->setText(QString::number(m_compressorLookaheadSlider->value()) + " ms");
if (m_compressorRiseRateLabel) {
float v = (float)m_compressorRiseRateSlider->value() / 10.0f;
m_compressorRiseRateLabel->setText(QString::number(v, 'f', 1) + "x");
}
if (m_compressorTargetLevelLabel)
m_compressorTargetLevelLabel->setText(QString::number(m_compressorTargetLevelSlider->value()) + " dBFS");
}

24
tools/chatgui/src/audiodevicesettingspage.h

@ -3,9 +3,13 @@
#include <QComboBox>
#include <QProgressBar>
#include <QPushButton>
#include <QCheckBox>
#include <QSlider>
#include <QLabel>
#include <QTimer>
class DbManager;
class AudioRecorder;
class AudioCompressor;
class AudioDeviceSettingsPage : public QWidget {
Q_OBJECT
@ -22,6 +26,8 @@ private slots:
private:
void stopMicTest();
void buildCompressorUI(class QVBoxLayout* layout);
void updateCompressorLabels();
DbManager* m_db;
AudioRecorder* m_testRecorder = nullptr;
@ -36,4 +42,22 @@ private:
int m_savedOutputIdx = -1;
int m_savedInputIdx = -1;
int m_savedCodecPreset = 1;
QCheckBox* m_compressorEnabledCb;
QSlider* m_compressorMaxGainSlider;
QSlider* m_compressorLookbackSlider;
QSlider* m_compressorLookaheadSlider;
QSlider* m_compressorRiseRateSlider;
QSlider* m_compressorTargetLevelSlider;
QLabel* m_compressorMaxGainLabel;
QLabel* m_compressorLookbackLabel;
QLabel* m_compressorLookaheadLabel;
QLabel* m_compressorRiseRateLabel;
QLabel* m_compressorTargetLevelLabel;
int m_savedCompressorEnabled = 0;
int m_savedCompressorMaxGainDb = 30;
int m_savedCompressorLookbackMs = 200;
int m_savedCompressorLookaheadMs = 100;
int m_savedCompressorRiseRateTenths = 20;
int m_savedCompressorTargetLevelDb = -12;
};

40
tools/chatgui/src/audiorecorder.cpp

@ -1,4 +1,5 @@
#include "audiorecorder.h"
#include "audiocompressor.h"
#include "miniaudio.h"
#include "sound_manager.h"
@ -16,10 +17,15 @@ void audioCaptureCallback(ma_device* pDevice, void* pOutput, const void* pInput,
if (!pInput) return;
const int16_t* src = (const int16_t*)pInput;
size_t cur = self->m_pcmBuffer.size();
size_t add = (size_t)frameCount * (size_t)self->m_channels;
self->m_pcmBuffer.resize(cur + add);
std::memcpy(self->m_pcmBuffer.data() + cur, src, add * sizeof(int16_t));
if (self->m_compressor && self->m_compressorEnabled) {
self->m_compressor->push(src, add);
} else {
size_t cur = self->m_pcmBuffer.size();
self->m_pcmBuffer.resize(cur + add);
std::memcpy(self->m_pcmBuffer.data() + cur, src, add * sizeof(int16_t));
}
float sumSq = 0.0f;
for (unsigned int i = 0; i < frameCount * (unsigned int)self->m_channels; i++) {
@ -42,6 +48,11 @@ void audioCaptureCallback(ma_device* pDevice, void* pOutput, const void* pInput,
AudioRecorder::AudioRecorder(QObject* parent)
: QObject(parent) {
g_recorder = this;
m_compressor = new AudioCompressor;
AudioCompressor::Config cfg;
cfg.sampleRate = m_sampleRate;
cfg.channels = m_channels;
m_compressor->configure(cfg);
m_durationTimer.setInterval(100);
connect(&m_durationTimer, &QTimer::timeout, this, [this]() {
if (m_recording) {
@ -53,6 +64,8 @@ AudioRecorder::AudioRecorder(QObject* parent)
AudioRecorder::~AudioRecorder() {
shutdown();
delete m_compressor;
m_compressor = nullptr;
if (g_recorder == this) g_recorder = nullptr;
}
@ -86,6 +99,8 @@ void AudioRecorder::startRecording() {
m_rmsIdx = 0;
m_elapsedMs = 0;
if (m_compressor) m_compressor->reset();
ma_context* ctx = SoundManager::instance()->context();
if (!ctx) { qWarning("AudioRecorder: no audio context"); return; }
@ -142,10 +157,14 @@ void AudioRecorder::stopRecording() {
ma_device_uninit(m_device);
m_deviceStarted = false;
}
m_recording = false;
if (m_compressor && m_compressorEnabled) m_compressor->flush();
int duration = m_elapsedMs;
qDebug("AudioRecorder: recording stopped, duration=%dms, pcmSamples=%zu", duration, m_pcmBuffer.size());
size_t bufSize = (m_compressor && m_compressorEnabled) ? m_compressor->outputBuffer().size() : m_pcmBuffer.size();
qDebug("AudioRecorder: recording stopped, duration=%dms, pcmSamples=%zu, compressor=%d",
duration, bufSize, m_compressorEnabled ? 1 : 0);
emit recordingStopped(duration);
emit durationChanged(0);
}
@ -159,3 +178,14 @@ float AudioRecorder::peakLevel() const {
m_peakLevel = 0.0f;
return v;
}
const std::vector<int16_t>& AudioRecorder::buffer() const {
if (m_compressor && m_compressorEnabled) return m_compressor->outputBuffer();
return m_pcmBuffer;
}
void AudioRecorder::setCompressorEnabled(bool enabled) {
m_compressorEnabled = enabled;
if (m_compressor) m_compressor->setEnabled(enabled);
qDebug("AudioRecorder: compressor %s", enabled ? "enabled" : "disabled");
}

10
tools/chatgui/src/audiorecorder.h

@ -7,6 +7,7 @@
#include <cstdint>
struct ma_device;
class AudioCompressor;
class AudioRecorder : public QObject {
Q_OBJECT
@ -27,10 +28,14 @@ public:
float peakLevel() const;
const std::vector<int16_t>& buffer() const { return m_pcmBuffer; }
const std::vector<int16_t>& buffer() const;
const std::vector<float>& waveformLevels() const { return m_waveformLevels; }
int durationMs() const;
AudioCompressor* compressor() const { return m_compressor; }
void setCompressorEnabled(bool enabled);
bool isCompressorEnabled() const { return m_compressorEnabled; }
signals:
void recordingStarted();
void recordingStopped(int durationMs);
@ -53,5 +58,8 @@ private:
QTimer m_durationTimer;
int m_elapsedMs = 0;
AudioCompressor* m_compressor = nullptr;
bool m_compressorEnabled = false;
friend void audioCaptureCallback(ma_device* pDevice, void* pOutput, const void* pInput, unsigned int frameCount);
};

19
tools/chatgui/src/mainwindow.cpp

@ -9,6 +9,7 @@
#include "invite_link.h"
#include "sound_manager.h"
#include "audiorecorder.h"
#include "audiocompressor.h"
#include "voicemessageencoder.h"
#include "animtimer.h"
#include "../db/db_manager.h"
@ -25,6 +26,7 @@
#include <QFile>
#include <QTextStream>
#include <QDir>
#include <cmath>
extern "C" {
#include "../transport/chat_core.h"
@ -207,9 +209,22 @@ void MainWindow::setupSoundFromConfig() {
if (m_db && m_db->isOpen()) {
m_recorder->setCaptureDeviceIndex(m_db->getUiStateInt("audio_input_device", -1));
VoiceEncoder::setPreset(m_db->getUiStateInt("opus_codec_preset", 1));
int compEnabled = m_db->getUiStateInt("compressor_enabled", 0);
m_recorder->setCompressorEnabled(compEnabled != 0);
if (m_recorder->compressor()) {
AudioCompressor::Config compCfg;
compCfg.maxGainDb = (float)m_db->getUiStateInt("compressor_max_gain_db", 30);
compCfg.lookbackMs = m_db->getUiStateInt("compressor_lookback_ms", 200);
compCfg.lookaheadMs = m_db->getUiStateInt("compressor_lookahead_ms", 100);
compCfg.riseRatePer500ms = (float)m_db->getUiStateInt("compressor_rise_rate_tenths", 20) / 10.0f;
compCfg.targetLevel = powf(10.0f, (float)m_db->getUiStateInt("compressor_target_level_db", -12) / 20.0f);
m_recorder->compressor()->configure(compCfg);
}
}
qDebug("MainWindow: AudioRecorder initialized (captureDevice=%d, opusPreset=%d)",
m_recorder->captureDeviceIndex(), VoiceEncoder::preset());
qDebug("MainWindow: AudioRecorder initialized (captureDevice=%d, opusPreset=%d, compressor=%d)",
m_recorder->captureDeviceIndex(), VoiceEncoder::preset(),
m_recorder->isCompressorEnabled() ? 1 : 0);
}
}

Loading…
Cancel
Save