From acba3fcb07f907b221f05ffd71a60e0aab31174a Mon Sep 17 00:00:00 2001 From: Vencislav Atanasov Date: Wed, 8 Jul 2026 01:36:19 +0300 Subject: [PATCH] rbutilqt: Add the Qt TextToSpeech module as a new optional TTS engine Co-authored-by: Qwen3.7-Plus Change-Id: Ib6ddce52eee30139952e6597504ab96517dc15bd --- utils/CMakeLists.txt | 7 +- utils/rbutilqt/CMakeLists.txt | 11 +- utils/rbutilqt/base/ttsbase.cpp | 11 ++ utils/rbutilqt/base/ttsqt.cpp | 214 ++++++++++++++++++++++++++++++++ utils/rbutilqt/base/ttsqt.h | 54 ++++++++ 5 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 utils/rbutilqt/base/ttsqt.cpp create mode 100644 utils/rbutilqt/base/ttsqt.h 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 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 +#include "ttsbase.h" +#include "ttsqt.h" +#include "encttssettings.h" +#include "rbsettings.h" + +#include +#include +#include +#include +#include +#include + +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 +#include "ttsbase.h" + +#include +#include + +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