rbutilqt: Add the Qt TextToSpeech module as a new optional TTS engine

rockbox-gerrit-noreply--- via rockbox-cvs <[email protected]> Thu, 9 Jul 2026 15:56:53 -0400
Newsgroups gmane.comp.systems.archos.rockbox.cvs
Message-ID <[email protected]>
commit acba3fcb07f907b221f05ffd71a60e0aab31174a
Author: Vencislav Atanasov <[email protected]>
Date:   Wed Jul 8 01:36:19 2026 +0300

    rbutilqt: Add the Qt TextToSpeech module as a new optional TTS engine
    
    Co-authored-by: Qwen3.7-Plus
    Change-Id: Ib6ddce52eee30139952e6597504ab96517dc15bd

diff --git a/utils/CMakeLists.txt b/utils/CMakeLists.txt
index 806143502f..e1ebc57342 100644
--- a/utils/CMakeLists.txt
+++ b/utils/CMakeLists.txt
@@ -48,7 +48,7 @@ enable_testing()
 find_package(QT NAMES Qt6 REQUIRED)
     find_package(Qt6 REQUIRED COMPONENTS
                  Core Core5Compat Widgets Svg Network LinguistTools SvgWidgets
-                 OPTIONAL_COMPONENTS Multimedia Test)
+                 OPTIONAL_COMPONENTS Multimedia Test TextToSpeech)
 	       ## note Core5Compat is only needed for QuaZip
 get_target_property(_moc_executable Qt${QT_VERSION_MAJOR}::moc IMPORTED_LOCATION)
 get_filename_component(QT_BINDIR "${_moc_executable}" DIRECTORY)
@@ -58,6 +58,11 @@ if (TARGET Qt${QT_VERSION_MAJOR}::Multimedia)
 else()
   message("-- QT::Multimedia not found, TTS preview not available")
 endif()
+if (TARGET Qt${QT_VERSION_MAJOR}::TextToSpeech)
+  message("-- Found Qt::TextToSpeech")
+else()
+  message("-- QT::TextToSpeech not found, Qt TTS not available")
+endif()
 
 add_compile_definitions(QT_NO_USE_NODISCARD_FILE_OPEN)
 
diff --git a/utils/rbutilqt/CMakeLists.txt b/utils/rbutilqt/CMakeLists.txt
index aecfc2d004..b3b564510f 100644
--- a/utils/rbutilqt/CMakeLists.txt
+++ b/utils/rbutilqt/CMakeLists.txt
@@ -242,13 +242,22 @@ add_library(rbbase
     ${CMAKE_CURRENT_LIST_DIR}/base/ziputil.cpp
     ${CMAKE_CURRENT_LIST_DIR}/base/ziputil.h
     )
-
 if(APPLE)
     target_sources(rbbase PRIVATE
         ${CMAKE_CURRENT_LIST_DIR}/base/ttscarbon.cpp
         ${CMAKE_CURRENT_LIST_DIR}/base/ttscarbon.h)
 endif()
 
+if (TARGET Qt${QT_VERSION_MAJOR}::TextToSpeech AND TARGET Qt${QT_VERSION_MAJOR}::Multimedia)
+    target_sources(rbbase PRIVATE
+        ${CMAKE_CURRENT_LIST_DIR}/base/ttsqt.cpp
+        ${CMAKE_CURRENT_LIST_DIR}/base/ttsqt.h)
+    target_link_libraries(rbbase
+        Qt${QT_VERSION_MAJOR}::TextToSpeech)
+    target_link_libraries(rbbase
+        Qt${QT_VERSION_MAJOR}::Multimedia)
+endif()
+
 target_link_libraries(rbbase
     cutelogger ${QUAZIP_LIBRARY} mspack bspatch rbspeex
     voicefont sansapatcher ipodpatcher chinachippatcher
diff --git a/utils/rbutilqt/base/ttsbase.cpp b/utils/rbutilqt/base/ttsbase.cpp
index f2184c4a46..b0bba40bc8 100644
--- a/utils/rbutilqt/base/ttsbase.cpp
+++ b/utils/rbutilqt/base/ttsbase.cpp
@@ -31,6 +31,9 @@
 #if defined(Q_OS_MACOS)
 #include "ttscarbon.h"
 #endif
+#if defined(QT_TEXTTOSPEECH_LIB) && defined(QT_MULTIMEDIA_LIB)
+#include "ttsqt.h"
+#endif
 
 // list of tts names and identifiers
 QMap<QString,QString> TTSBase::ttsList;
@@ -59,6 +62,9 @@ void TTSBase::initTTSList()
 #if defined(Q_OS_MACOS)
     ttsList["carbon"] = tr("OS X System Engine");
 #endif
+#if defined(QT_TEXTTOSPEECH_LIB) && defined(QT_MULTIMEDIA_LIB)
+    ttsList["qt"] = tr("Qt TextToSpeech Engine");
+#endif
 }
 
 // function to get a specific encoder
@@ -80,6 +86,11 @@ TTSBase* TTSBase::getTTS(QObject* parent,QString ttsName)
     if(ttsName == "carbon")
         tts = new TTSCarbon(parent);
     else
+#endif
+#if defined(QT_TEXTTOSPEECH_LIB) && defined(QT_MULTIMEDIA_LIB)
+    if(ttsName == "qt")
+        tts = new TTSQt(parent);
+    else
 #endif
     if(ttsName == "espeak")
         tts = new TTSEspeak(parent);
diff --git a/utils/rbutilqt/base/ttsqt.cpp b/utils/rbutilqt/base/ttsqt.cpp
new file mode 100644
index 0000000000..e7bcb110ac
--- /dev/null
+++ b/utils/rbutilqt/base/ttsqt.cpp
@@ -0,0 +1,214 @@
+/***************************************************************************
+*             __________               __   ___.
+ *   Open      \______   \ ____   ____ |  | _\_ |__   _______  ___
+ *   Source     |       _//  _ \_/ ___\|  |/ /| __ \ /  _ \  \/  /
+ *   Jukebox    |    |   (  <_> )  \___|    < | \_\ (  <_> > <  <
+ *   Firmware   |____|_  /\____/ \___  >__|_ \|___  /\____/__/\_ \
+ *                     \/            \/     \/    \/            \/
+ *
+ *   Copyright (C) 2026 by Vencislav Atanasov
+ *
+ * All files in this archive are subject to the GNU General Public License.
+ * See the file COPYING in the source tree root for full license agreement.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ ****************************************************************************/
+
+#include <QtCore>
+#include "ttsbase.h"
+#include "ttsqt.h"
+#include "encttssettings.h"
+#include "rbsettings.h"
+
+#include <QEventLoop>
+#include <QFile>
+#include <QDataStream>
+#include <QIODevice>
+#include <QtTextToSpeech/QTextToSpeech>
+#include <QtMultimedia/QAudioFormat>
+
+TTSQt::TTSQt(QObject *parent) : TTSBase(parent)
+{
+    m_tts = new QTextToSpeech(this);
+}
+
+TTSQt::~TTSQt()
+{
+}
+
+TTSStatus TTSQt::voice(const QString& text, const QString& wavfile, QString* errStr)
+{
+    if (!m_tts) {
+        if (errStr) *errStr = "TTS engine not initialized";
+        return FatalError;
+    }
+
+    if (text.isEmpty()) {
+        if (errStr) *errStr = "Input text is empty";
+        return Warning;
+    }
+
+    // Check if the current engine supports synthesis (requires Qt 6.6+)
+    if (!wavfile.isEmpty() && !(m_tts->engineCapabilities() & QTextToSpeech::Capability::Synthesize)) {
+        if (errStr) *errStr = "Current TTS engine does not support synthesis to file";
+        return FatalError;
+    }
+
+    QEventLoop loop;
+    bool success = true;
+    QString errorMsg;
+    bool hasStarted = false;
+
+    // Connect to state changes to know when the TTS engine finishes its task
+    QMetaObject::Connection conn = connect(m_tts, &QTextToSpeech::stateChanged, &loop, [&](QTextToSpeech::State state) {
+        if (state == QTextToSpeech::Error) {
+            success = false;
+            errorMsg = m_tts->errorString();
+            loop.quit();
+        } else if (state != QTextToSpeech::Ready) {
+            // Any state other than Ready (e.g., Speaking, Synthesizing) means it has started
+            hasStarted = true;
+        } else if (state == QTextToSpeech::Ready && hasStarted) {
+            // It has finished and returned to Ready state
+            loop.quit();
+        }
+    });
+
+    if (wavfile.isEmpty()) {
+        m_tts->say(text);
+    }
+    else {
+        m_audioData.clear();
+        m_format = QAudioFormat();
+
+        // Start synthesis. The functor is called with chunks of audio data.
+        m_tts->synthesize(text, [this](const QAudioFormat &format, const QByteArray &bytes) {
+            if (!m_format.isValid()) {
+                m_format = format; // Store the format from the first chunk
+            }
+            m_audioData.append(bytes);
+        });
+    }
+
+    // Wait for the asynchronous operation to finish.
+    // We check if it finished synchronously (edge case) to avoid deadlocking the QEventLoop.
+    if (m_tts->state() == QTextToSpeech::Ready && !hasStarted) {
+        // Already finished or didn't start
+    } else {
+        loop.exec();
+    }
+
+    disconnect(conn);
+
+    if (!success) {
+        if (errStr) *errStr = errorMsg;
+        return FatalError;
+    }
+
+    if (!wavfile.isEmpty()) {
+        if (m_audioData.isEmpty() || !m_format.isValid()) {
+            if (errStr) *errStr = "No audio data generated or invalid format";
+            return Warning;
+        }
+
+        if (!writeWavFile(wavfile, m_audioData, m_format)) {
+            if (errStr) *errStr = "Failed to write WAV file";
+            return FatalError;
+        }
+    }
+
+    return NoError;
+}
+
+bool TTSQt::start(QString *errStr)
+{
+    if (!m_tts) {
+        m_tts = new QTextToSpeech(this);
+    }
+    if (m_tts->availableEngines().isEmpty()) {
+        if (errStr) *errStr = "No TTS engines available on this system";
+        return false;
+    }
+    return true;
+}
+
+bool TTSQt::stop()
+{
+    if (m_tts) {
+        m_tts->stop();
+    }
+    return true;
+}
+
+QString TTSQt::voiceVendor(void)
+{
+    return "Qt TextToSpeech";
+}
+
+bool TTSQt::configOk()
+{
+    return m_tts != nullptr && m_tts->state() != QTextToSpeech::Error;
+}
+
+void TTSQt::generateSettings()
+{
+    // TODO
+}
+
+void TTSQt::saveSettings()
+{
+    // TODO
+}
+
+TTSBase::Capabilities TTSQt::capabilities()
+{
+    return TTSBase::CanSpeak | TTSBase::RunInParallel;
+}
+
+bool TTSQt::writeWavFile(const QString &filePath, const QByteArray &audioData, const QAudioFormat &format)
+{
+    QFile file(filePath);
+    if (!file.open(QIODevice::WriteOnly)) {
+        return false;
+    }
+
+    quint32 dataSize = audioData.size();
+    quint32 fileSize = 36 + dataSize;
+    quint32 fmtChunkSize = 16;
+
+    // 3 for IEEE Float, 1 for standard PCM
+    quint16 audioFormat = (format.sampleFormat() == QAudioFormat::Float) ? 3 : 1;
+    quint16 channels = format.channelCount();
+    quint32 sampleRate = format.sampleRate();
+    quint16 bitsPerSample = format.bytesPerSample() * 8;
+    quint16 blockAlign = channels * format.bytesPerSample();
+    quint32 byteRate = sampleRate * blockAlign;
+
+    QDataStream stream(&file);
+    stream.setByteOrder(QDataStream::LittleEndian);
+
+    // RIFF header
+    stream.writeRawData("RIFF", 4);
+    stream << fileSize;
+    stream.writeRawData("WAVE", 4);
+
+    // fmt chunk
+    stream.writeRawData("fmt ", 4);
+    stream << fmtChunkSize;
+    stream << audioFormat;
+    stream << channels;
+    stream << sampleRate;
+    stream << byteRate;
+    stream << blockAlign;
+    stream << bitsPerSample;
+
+    // data chunk
+    stream.writeRawData("data", 4);
+    stream << dataSize;
+    stream.writeRawData(audioData.constData(), dataSize);
+
+    file.close();
+    return true;
+}
diff --git a/utils/rbutilqt/base/ttsqt.h b/utils/rbutilqt/base/ttsqt.h
new file mode 100644
index 0000000000..2c19aac239
--- /dev/null
+++ b/utils/rbutilqt/base/ttsqt.h
@@ -0,0 +1,54 @@
+/***************************************************************************
+*             __________               __   ___.
+ *   Open      \______   \ ____   ____ |  | _\_ |__   _______  ___
+ *   Source     |       _//  _ \_/ ___\|  |/ /| __ \ /  _ \  \/  /
+ *   Jukebox    |    |   (  <_> )  \___|    < | \_\ (  <_> > <  <
+ *   Firmware   |____|_  /\____/ \___  >__|_ \|___  /\____/__/\_ \
+ *                     \/            \/     \/    \/            \/
+ *
+ *   Copyright (C) 2026 by Vencislav Atanasov
+ *
+ * All files in this archive are subject to the GNU General Public License.
+ * See the file COPYING in the source tree root for full license agreement.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ ****************************************************************************/
+
+#ifndef TTSQT_H
+#define TTSQT_H
+
+#include <QtCore>
+#include "ttsbase.h"
+
+#include <QtTextToSpeech/QTextToSpeech>
+#include <QtMultimedia/QAudioFormat>
+
+class TTSQt : public TTSBase
+{
+    Q_OBJECT
+public:
+    explicit TTSQt(QObject *parent = nullptr);
+    ~TTSQt() override;
+
+    TTSStatus voice(const QString& text, const QString& wavfile, QString* errStr) override;
+    bool start(QString *errStr) override;
+    bool stop() override;
+
+    QString voiceVendor(void) override;
+    bool configOk() override;
+    void generateSettings() override;
+    void saveSettings() override;
+    Capabilities capabilities() override;
+
+private:
+    QTextToSpeech *m_tts = nullptr;
+    QByteArray m_audioData;
+    QAudioFormat m_format;
+
+    // Helper to write raw PCM data to a WAV file
+    bool writeWavFile(const QString &filePath, const QByteArray &audioData, const QAudioFormat &format);
+};
+
+#endif // TTSQT_H
-- 
rockbox-cvs mailing list
[email protected]
https://lists.haxx.se/mailman/listinfo/rockbox-cvs