feat(voice): modalità dialogo — voce continua hands-free (v0.2.0)

- Nuovo VoiceRecorder (QAudioInput, PCM 16 bit mono): analisi del livello
  in tempo reale e stop automatico dopo il silenzio (pausa e soglia
  configurabili dalle impostazioni); produce WAV per /api/transcribe;
  emette failed("no-speech") se non rileva voce (cattura scartata)
- ChatPage: modalità dialogo (menu "Voice mode"): ascolto -> trascrizione
  -> invio -> lettura della risposta -> riascolto in ciclo; tocco sul
  testo di stato per uscire; limite di 3 errori consecutivi poi esce
- Dettatura manuale: invio automatico dopo la trascrizione (opzione,
  default attivo, disattivabile)
- Settings: sezione "Voice dialog" (auto-invio, pausa auto-stop,
  sensibilità microfono)
- Il percorso di dettatura manuale (Recorder OGG) resta invariato
This commit is contained in:
2026-09-12 09:14:25 +02:00
parent 479edb0f99
commit 9deeedf5b3
12 changed files with 620 additions and 18 deletions
+5 -2
View File
@@ -9,6 +9,7 @@
#include "player.h"
#include "recorder.h"
#include "settings.h"
#include "voicerecorder.h"
Q_DECL_EXPORT int main(int argc, char *argv[])
{
@@ -18,15 +19,16 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
// AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox).
app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("hermes"));
app->setApplicationVersion(QStringLiteral("0.1.0"));
app->setApplicationVersion(QStringLiteral("0.2.0"));
qDebug() << "harbour-hermes v0.1.0 build" << __DATE__ << __TIME__;
qDebug() << "harbour-hermes v0.2.0 build" << __DATE__ << __TIME__;
Settings settings;
ApiClient api(&settings);
ChatModel chat(&api);
Recorder recorder;
Player player;
VoiceRecorder voiceRec(&settings);
// Flusso voce: registrazione -> trascrizione; TTS pronto -> riproduzione.
QObject::connect(&recorder, &Recorder::finished, &api, &ApiClient::transcribeFile);
@@ -38,6 +40,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
view->rootContext()->setContextProperty(QStringLiteral("chat"), &chat);
view->rootContext()->setContextProperty(QStringLiteral("recorder"), &recorder);
view->rootContext()->setContextProperty(QStringLiteral("player"), &player);
view->rootContext()->setContextProperty(QStringLiteral("voiceRec"), &voiceRec);
view->rootContext()->setContextProperty(QStringLiteral("appVersion"),
QCoreApplication::applicationVersion());
+39
View File
@@ -70,6 +70,45 @@ void Settings::setTtsAutoRead(bool on)
emit ttsAutoReadChanged();
}
bool Settings::dictationAutoSend() const
{
return m_settings.value(QStringLiteral("dictationAutoSend"), true).toBool();
}
void Settings::setDictationAutoSend(bool on)
{
if (on == dictationAutoSend())
return;
m_settings.setValue(QStringLiteral("dictationAutoSend"), on);
emit dictationAutoSendChanged();
}
int Settings::vadSilenceMs() const
{
return m_settings.value(QStringLiteral("vadSilenceMs"), 1500).toInt();
}
void Settings::setVadSilenceMs(int ms)
{
if (ms == vadSilenceMs())
return;
m_settings.setValue(QStringLiteral("vadSilenceMs"), ms);
emit vadSilenceMsChanged();
}
int Settings::vadThreshold() const
{
return m_settings.value(QStringLiteral("vadThreshold"), 800).toInt();
}
void Settings::setVadThreshold(int threshold)
{
if (threshold == vadThreshold())
return;
m_settings.setValue(QStringLiteral("vadThreshold"), threshold);
emit vadThresholdChanged();
}
QString Settings::defaultWorkspace() const
{
return m_settings.value(QStringLiteral("defaultWorkspace"), QString()).toString();
+18
View File
@@ -19,6 +19,12 @@ class Settings : public QObject
Q_PROPERTY(QString ttsVoice READ ttsVoice WRITE setTtsVoice NOTIFY ttsVoiceChanged)
Q_PROPERTY(QString ttsEngine READ ttsEngine WRITE setTtsEngine NOTIFY ttsEngineChanged)
Q_PROPERTY(bool ttsAutoRead READ ttsAutoRead WRITE setTtsAutoRead NOTIFY ttsAutoReadChanged)
Q_PROPERTY(bool dictationAutoSend READ dictationAutoSend WRITE setDictationAutoSend
NOTIFY dictationAutoSendChanged)
Q_PROPERTY(int vadSilenceMs READ vadSilenceMs WRITE setVadSilenceMs
NOTIFY vadSilenceMsChanged)
Q_PROPERTY(int vadThreshold READ vadThreshold WRITE setVadThreshold
NOTIFY vadThresholdChanged)
Q_PROPERTY(QString defaultWorkspace READ defaultWorkspace WRITE setDefaultWorkspace
NOTIFY defaultWorkspaceChanged)
@@ -37,6 +43,15 @@ public:
bool ttsAutoRead() const;
void setTtsAutoRead(bool on);
bool dictationAutoSend() const;
void setDictationAutoSend(bool on);
int vadSilenceMs() const;
void setVadSilenceMs(int ms);
int vadThreshold() const;
void setVadThreshold(int threshold);
QString defaultWorkspace() const;
void setDefaultWorkspace(const QString &ws);
@@ -45,6 +60,9 @@ signals:
void ttsVoiceChanged();
void ttsEngineChanged();
void ttsAutoReadChanged();
void dictationAutoSendChanged();
void vadSilenceMsChanged();
void vadThresholdChanged();
void defaultWorkspaceChanged();
private:
+264
View File
@@ -0,0 +1,264 @@
#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.
if (elapsed >= 60000) {
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;
}
+73
View File
@@ -0,0 +1,73 @@
#ifndef VOICERECORDER_H
#define VOICERECORDER_H
#include <QAudioFormat>
#include <QByteArray>
#include <QElapsedTimer>
#include <QObject>
#include <QString>
class QAudioInput;
class QIODevice;
class QTimer;
class Settings;
// Registratore per la modalità dialogo (voce continua).
//
// Differenze rispetto a Recorder (dettatura manuale, QAudioRecorder/OGG):
// - usa QAudioInput con PCM 16 bit mono per analizzare il livello del
// segnale in tempo reale;
// - ferma la registrazione DA SOLO dopo un periodo di silenzio (pausa e
// soglia configurabili nelle impostazioni) oppure al tetto massimo;
// - produce un WAV che il server trascrive come gli altri formati.
//
// finished(path) arriva per stop automatico o manuale; se non è stata
// rilevata voce emette failed("no-speech") (il WAV viene scartato).
// Il segnale listening() conferma l'avvio della cattura.
class VoiceRecorder : public QObject
{
Q_OBJECT
Q_PROPERTY(bool active READ active NOTIFY activeChanged)
Q_PROPERTY(qreal level READ level NOTIFY levelChanged)
public:
explicit VoiceRecorder(Settings *settings, QObject *parent = nullptr);
bool active() const { return m_active; }
qreal level() const { return m_level; }
Q_INVOKABLE void start();
Q_INVOKABLE void stop();
signals:
void activeChanged();
void levelChanged();
void listening();
void finished(const QString &wavPath);
void failed(const QString &error);
private slots:
void onReadyRead();
void onSilenceCheck();
private:
void finalize();
QString makeWavPath() const;
bool writeWav(const QString &path);
Settings *m_settings;
QAudioInput *m_input;
QIODevice *m_device;
QTimer *m_silenceTimer;
QAudioFormat m_format;
QByteArray m_pcm;
QElapsedTimer m_elapsed;
qint64 m_lastVoiceMs;
qint64 m_lastLevelEmitMs;
bool m_hasVoice;
bool m_active;
qreal m_level;
};
#endif // VOICERECORDER_H