Prima di pubblicare: eliminati dai sorgenti tutti i valori "cablati". - settings: default neutri (URL, voce, engine vuoti = "decide il server"); nuove chiavi ttsVoices/ttsEngines (preset dei selettori) e vadMaxMs (durata massima registrazione, prima fissa a 60 s nel codice) - ApiClient: guard su baseUrl vuoto (checkAuth/login si comportano senza rete), voce/engine omessi nella richiesta TTS se vuoti, Content-Type audio coerente col formato reale del file (ogg/wav/mp3/m4a/opus) - SettingsPage: selettori voce/engine costruiti dai preset configurabili (Repeater dentro ContextMenu = pattern provato su tide) + campi per modificare i preset; LoginPage richiede indirizzo server non vuoto - VoiceRecorder: tetto di registrazione da configurazione - README: sezione "Configuration" completa; docs neutralizzate (niente URL/valori personali: repo public-friendly)
265 lines
6.8 KiB
C++
265 lines
6.8 KiB
C++
#include "voicerecorder.h"
|
|
|
|
#include <QAudioDeviceInfo>
|
|
#include <QAudioInput>
|
|
#include <QDateTime>
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QIODevice>
|
|
#include <QStandardPaths>
|
|
#include <QTimer>
|
|
|
|
#include "settings.h"
|
|
|
|
namespace {
|
|
|
|
void append16(QByteArray &b, quint16 v)
|
|
{
|
|
b.append(char(v & 0xff));
|
|
b.append(char((v >> 8) & 0xff));
|
|
}
|
|
|
|
void append32(QByteArray &b, quint32 v)
|
|
{
|
|
b.append(char(v & 0xff));
|
|
b.append(char((v >> 8) & 0xff));
|
|
b.append(char((v >> 16) & 0xff));
|
|
b.append(char((v >> 24) & 0xff));
|
|
}
|
|
|
|
// Picco (valore assoluto massimo) dei campioni 16 bit little-endian del blocco.
|
|
int peakOf(const QByteArray &chunk)
|
|
{
|
|
int peak = 0;
|
|
const char *d = chunk.constData();
|
|
const int n = chunk.size() - (chunk.size() % 2);
|
|
for (int i = 0; i < n; i += 2) {
|
|
const qint16 s = qint16(quint8(d[i]) | (quint8(d[i + 1]) << 8));
|
|
const int v = qAbs(int(s));
|
|
if (v > peak)
|
|
peak = v;
|
|
}
|
|
return peak;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
VoiceRecorder::VoiceRecorder(Settings *settings, QObject *parent)
|
|
: QObject(parent)
|
|
, m_settings(settings)
|
|
, m_input(nullptr)
|
|
, m_device(nullptr)
|
|
, m_silenceTimer(new QTimer(this))
|
|
, m_lastVoiceMs(0)
|
|
, m_lastLevelEmitMs(0)
|
|
, m_hasVoice(false)
|
|
, m_active(false)
|
|
, m_level(0.0)
|
|
{
|
|
m_silenceTimer->setInterval(150);
|
|
connect(m_silenceTimer, &QTimer::timeout, this, &VoiceRecorder::onSilenceCheck);
|
|
}
|
|
|
|
void VoiceRecorder::start()
|
|
{
|
|
if (m_active)
|
|
return;
|
|
|
|
const QAudioDeviceInfo info = QAudioDeviceInfo::defaultInputDevice();
|
|
if (info.isNull()) {
|
|
emit failed(tr("No audio input device"));
|
|
return;
|
|
}
|
|
|
|
// PCM 16 bit mono: 16 kHz di solito basta per la voce; altrimenti si
|
|
// ripiega su frequenze piu' comuni mantenendo il 16 bit mono.
|
|
QAudioFormat format;
|
|
format.setChannelCount(1);
|
|
format.setSampleSize(16);
|
|
format.setCodec(QStringLiteral("audio/pcm"));
|
|
format.setByteOrder(QAudioFormat::LittleEndian);
|
|
format.setSampleType(QAudioFormat::SignedInt);
|
|
|
|
const int rates[] = { 16000, 48000, 44100, 32000, 8000 };
|
|
bool found = false;
|
|
for (unsigned i = 0; i < sizeof(rates) / sizeof(rates[0]); ++i) {
|
|
format.setSampleRate(rates[i]);
|
|
if (info.isFormatSupported(format)) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) {
|
|
emit failed(tr("Audio input format not supported"));
|
|
return;
|
|
}
|
|
|
|
m_format = format;
|
|
m_pcm.clear();
|
|
m_hasVoice = false;
|
|
m_level = 0.0;
|
|
m_lastVoiceMs = 0;
|
|
m_lastLevelEmitMs = 0;
|
|
|
|
m_input = new QAudioInput(info, m_format, this);
|
|
m_device = m_input->start();
|
|
if (!m_device) {
|
|
delete m_input;
|
|
m_input = nullptr;
|
|
emit failed(tr("Could not start audio capture"));
|
|
return;
|
|
}
|
|
connect(m_device, &QIODevice::readyRead, this, &VoiceRecorder::onReadyRead);
|
|
|
|
m_elapsed.start();
|
|
m_active = true;
|
|
emit activeChanged();
|
|
emit levelChanged();
|
|
emit listening();
|
|
qDebug() << "[voice] ascolto avviato:" << m_format.sampleRate() << "Hz mono 16 bit";
|
|
m_silenceTimer->start();
|
|
}
|
|
|
|
void VoiceRecorder::stop()
|
|
{
|
|
if (!m_active)
|
|
return;
|
|
finalize();
|
|
}
|
|
|
|
void VoiceRecorder::onReadyRead()
|
|
{
|
|
if (!m_device)
|
|
return;
|
|
const QByteArray chunk = m_device->readAll();
|
|
if (chunk.isEmpty())
|
|
return;
|
|
m_pcm.append(chunk);
|
|
|
|
const int peak = peakOf(chunk);
|
|
const qint64 elapsed = m_elapsed.elapsed();
|
|
if (peak > m_settings->vadThreshold()) {
|
|
m_lastVoiceMs = elapsed;
|
|
m_hasVoice = true;
|
|
}
|
|
|
|
if (elapsed - m_lastLevelEmitMs >= 150) {
|
|
m_lastLevelEmitMs = elapsed;
|
|
m_level = qreal(peak) / 32768.0;
|
|
emit levelChanged();
|
|
}
|
|
}
|
|
|
|
void VoiceRecorder::onSilenceCheck()
|
|
{
|
|
if (!m_active)
|
|
return;
|
|
const qint64 elapsed = m_elapsed.elapsed();
|
|
|
|
// Nessuna voce dopo 12 s: la cattura viene scartata (failed "no-speech").
|
|
if (!m_hasVoice && elapsed >= 12000) {
|
|
qDebug() << "[voice] nessuna voce rilevata, stop";
|
|
finalize();
|
|
return;
|
|
}
|
|
|
|
// Stop automatico: almeno un frammento di voce udito e pausa oltre soglia.
|
|
if (m_hasVoice
|
|
&& elapsed - m_lastVoiceMs >= qint64(m_settings->vadSilenceMs())
|
|
&& elapsed >= 800) {
|
|
qDebug() << "[voice] stop automatico: pausa di" << (elapsed - m_lastVoiceMs)
|
|
<< "ms dopo voce a" << m_lastVoiceMs << "ms";
|
|
finalize();
|
|
return;
|
|
}
|
|
|
|
// Tetto di sicurezza anti-registrazione infinita (configurabile: vadMaxMs).
|
|
if (elapsed >= qint64(m_settings->vadMaxMs())) {
|
|
qDebug() << "[voice] stop automatico: durata massima raggiunta";
|
|
finalize();
|
|
}
|
|
}
|
|
|
|
void VoiceRecorder::finalize()
|
|
{
|
|
m_silenceTimer->stop();
|
|
|
|
if (m_device) {
|
|
m_pcm.append(m_device->readAll());
|
|
m_device = nullptr;
|
|
}
|
|
if (m_input) {
|
|
m_input->stop();
|
|
m_input->deleteLater();
|
|
m_input = nullptr;
|
|
}
|
|
|
|
m_active = false;
|
|
m_level = 0.0;
|
|
emit activeChanged();
|
|
emit levelChanged();
|
|
|
|
if (!m_hasVoice) {
|
|
m_pcm.clear();
|
|
emit failed(QStringLiteral("no-speech"));
|
|
return;
|
|
}
|
|
|
|
const QString path = makeWavPath();
|
|
const int bytes = m_pcm.size();
|
|
if (!writeWav(path)) {
|
|
m_pcm.clear();
|
|
emit failed(tr("Could not write the audio file"));
|
|
return;
|
|
}
|
|
m_pcm.clear();
|
|
qDebug() << "[voice] WAV pronto:" << path << bytes << "byte";
|
|
emit finished(path);
|
|
}
|
|
|
|
QString VoiceRecorder::makeWavPath() const
|
|
{
|
|
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
|
|
+ QStringLiteral("/voice");
|
|
QDir().mkpath(dir);
|
|
return dir + QStringLiteral("/v_")
|
|
+ QString::number(QDateTime::currentMSecsSinceEpoch())
|
|
+ QStringLiteral(".wav");
|
|
}
|
|
|
|
bool VoiceRecorder::writeWav(const QString &path)
|
|
{
|
|
QFile f(path);
|
|
if (!f.open(QIODevice::WriteOnly))
|
|
return false;
|
|
|
|
const quint16 channels = quint16(m_format.channelCount());
|
|
const quint32 sampleRate = quint32(m_format.sampleRate());
|
|
const quint16 bits = quint16(m_format.sampleSize());
|
|
const quint32 dataSize = quint32(m_pcm.size());
|
|
const quint32 byteRate = sampleRate * channels * bits / 8;
|
|
const quint16 blockAlign = quint16(channels * bits / 8);
|
|
|
|
QByteArray header;
|
|
header.reserve(44);
|
|
header.append("RIFF");
|
|
append32(header, 36 + dataSize);
|
|
header.append("WAVE");
|
|
header.append("fmt ");
|
|
append32(header, 16);
|
|
append16(header, 1); // PCM
|
|
append16(header, channels);
|
|
append32(header, sampleRate);
|
|
append32(header, byteRate);
|
|
append16(header, blockAlign);
|
|
append16(header, bits);
|
|
header.append("data");
|
|
append32(header, dataSize);
|
|
|
|
f.write(header);
|
|
f.write(m_pcm);
|
|
f.close();
|
|
return true;
|
|
}
|