diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 35dd95f91..c5da674ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,7 @@ jobs: tools: 'tools_mingw1310' cache: true cache-key-prefix: install-qt-action - modules: 'qtimageformats qtwebsockets' + modules: 'qtimageformats qtwebsockets qtmultimedia' - name: Configure shell: bash @@ -86,7 +86,7 @@ jobs: arch: 'gcc_64' cache: true cache-key-prefix: install-qt-action - modules: 'qtimageformats qtwebsockets' + modules: 'qtimageformats qtwebsockets qtmultimedia' - name: Configure env: @@ -140,7 +140,7 @@ jobs: arch: 'linux_gcc_arm64' cache: true cache-key-prefix: install-qt-action - modules: 'qtimageformats qtwebsockets' + modules: 'qtimageformats qtwebsockets qtmultimedia' - name: Configure env: @@ -193,7 +193,7 @@ jobs: arch: 'clang_64' cache: true cache-key-prefix: install-qt-action - modules: 'qtimageformats qtwebsockets' + modules: 'qtimageformats qtwebsockets qtmultimedia' - name: Configure env: diff --git a/.gitignore b/.gitignore index c590ed0bd..1698e3da2 100644 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,8 @@ cmake_install.cmake test/CMakeFiles/ test/CTestTestfile.cmake test/cmake_install.cmake -test/test_aopacket_autogen/ -test/test_aopacket +test/*_autogen/ +test/test_* +!test/test_*.cpp cmake_cmd.txt build.env diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e39f8c43..c1e3b5849 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ option(AO_BUILD_TESTS "Build test programs" ON) option(AO_ENABLE_DISCORD_RPC "Enable Discord Rich Presence" ON) find_package(QT NAMES Qt6) -find_package(Qt6 REQUIRED COMPONENTS Core Gui Network Widgets Concurrent WebSockets UiTools) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Network Widgets Concurrent WebSockets UiTools Multimedia) qt_add_executable(AttorneyOnline src/aoapplication.cpp @@ -77,6 +77,8 @@ qt_add_executable(AttorneyOnline src/hardware_functions.h src/lobby.cpp src/lobby.h + src/loopmetadata.cpp + src/loopmetadata.h src/main.cpp src/network/websocketconnection.cpp src/network/websocketconnection.h @@ -131,8 +133,7 @@ target_link_libraries(AttorneyOnline PRIVATE Qt${QT_VERSION_MAJOR}::Concurrent Qt${QT_VERSION_MAJOR}::WebSockets Qt${QT_VERSION_MAJOR}::UiTools - bass - bassopus + Qt${QT_VERSION_MAJOR}::Multimedia ) if(AO_ENABLE_DISCORD_RPC) diff --git a/src/aoapplication.cpp b/src/aoapplication.cpp index c38eef7e9..a8d58fff9 100644 --- a/src/aoapplication.cpp +++ b/src/aoapplication.cpp @@ -6,6 +6,8 @@ #include "options.h" #include "widgets/aooptionsdialog.h" +#include + static QtMessageHandler original_message_handler; static AOApplication *message_handler_context; @@ -200,6 +202,7 @@ void AOApplication::call_settings_menu() if (is_courtroom_constructed()) { connect(l_dialog, &AOOptionsDialog::reloadThemeRequest, w_courtroom, &Courtroom::on_reload_theme_clicked); + connect(l_dialog, &AOOptionsDialog::audioDeviceChanged, w_courtroom, &Courtroom::on_audio_device_changed); } if (is_lobby_constructed()) @@ -214,24 +217,6 @@ void AOApplication::call_settings_menu() delete l_dialog; } -// Callback for when BASS device is lost -// Only actually used for music syncs -void CALLBACK AOApplication::BASSreset(HSTREAM handle, DWORD channel, DWORD data, void *user) -{ - Q_UNUSED(handle); - Q_UNUSED(channel); - Q_UNUSED(data); - Q_UNUSED(user); - doBASSreset(); -} - -void AOApplication::doBASSreset() -{ - BASS_Free(); - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); -} - void AOApplication::server_connected() { qInfo() << "Established connection to server."; @@ -242,36 +227,21 @@ void AOApplication::server_connected() courtroom_loaded = false; } -void AOApplication::initBASS() +QAudioDevice AOApplication::currentAudioDevice() const { - BASS_SetConfig(BASS_CONFIG_DEV_DEFAULT, 1); - BASS_Free(); - // Change the default audio output device to be the one the user has given - // in his config.ini file for now. - unsigned int a = 0; - BASS_DEVICEINFO info; - - if (Options::getInstance().audioOutputDevice() == "default") + QString pref = Options::getInstance().audioOutputDevice(); + if (pref != "default") { - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); - } - else - { - for (a = 0; BASS_GetDeviceInfo(a, &info); a++) + const QList devices = QMediaDevices::audioOutputs(); + for (const QAudioDevice &dev : devices) { - if (Options::getInstance().audioOutputDevice() == info.name) + if (dev.description() == pref) { - BASS_SetDevice(a); - BASS_Init(static_cast(a), 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); - qInfo() << info.name << "was set as the default audio output device."; - return; + return dev; } } - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); } + return QMediaDevices::defaultAudioOutput(); } bool AOApplication::pointExistsOnScreen(QPoint point) @@ -301,22 +271,3 @@ void AOApplication::centerOrMoveWidgetOnPrimaryScreen(QWidget *widget) widget->move(point->x(), point->y()); } } - -#if (defined(_WIN32) || defined(_WIN64)) -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("bassopus.dll", 0); -} -#elif defined __APPLE__ -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("libbassopus.dylib", 0); -} -#elif (defined(LINUX) || defined(__linux__)) -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("libbassopus.so", 0); -} -#else -#error This operating system is unsupported for BASS plugins. -#endif diff --git a/src/aoapplication.h b/src/aoapplication.h index 92611ec1a..d0fa705f6 100644 --- a/src/aoapplication.h +++ b/src/aoapplication.h @@ -7,8 +7,7 @@ #include "serverdata.h" #include "widgets/aooptionsdialog.h" -#include - +#include #include #include #include @@ -335,10 +334,9 @@ class AOApplication : public QObject bool pointExistsOnScreen(QPoint point); void centerOrMoveWidgetOnPrimaryScreen(QWidget *widget); - void initBASS(); - static void load_bass_plugins(); - static void CALLBACK BASSreset(HSTREAM handle, DWORD channel, DWORD data, void *user); - static void doBASSreset(); + // Resolve the user-selected audio output device. Returns the default + // output if the preference is "default" or no match is found. + QAudioDevice currentAudioDevice() const; QElapsedTimer demo_timer; DemoServer *demo_server = nullptr; diff --git a/src/aoblipplayer.cpp b/src/aoblipplayer.cpp index 3a13d7879..8b4bf1112 100644 --- a/src/aoblipplayer.cpp +++ b/src/aoblipplayer.cpp @@ -1,5 +1,14 @@ #include "aoblipplayer.h" +#include +#include +#include +#include +#include +#include +#include +#include + AOBlipPlayer::AOBlipPlayer(AOApplication *ao_app) : ao_app(ao_app) {} @@ -19,36 +28,133 @@ void AOBlipPlayer::setMuted(bool enabled) void AOBlipPlayer::setBlip(QString blip) { QString path = ao_app->get_sfx_suffix(ao_app->get_sounds_path(blip)); + QString playable = resolveToPlayablePath(path); + QUrl source = QUrl::fromLocalFile(playable); + QAudioDevice device = ao_app->currentAudioDevice(); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_StreamFree(m_stream[i]); - - if (path.endsWith(".opus")) - { - m_stream[i] = BASS_OPUS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_UNICODE | BASS_ASYNCFILE); - } - else - { - m_stream[i] = BASS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_UNICODE | BASS_ASYNCFILE); - } + m_stream[i].setAudioDevice(device); + m_stream[i].setSource(source); } - updateInternalVolume(); } void AOBlipPlayer::playBlip() { - HSTREAM stream = m_stream[m_cycle]; - BASS_ChannelSetDevice(stream, BASS_GetDevice()); - BASS_ChannelPlay(stream, false); - m_cycle = ++m_cycle % STREAM_COUNT; + m_stream[m_cycle].play(); + m_cycle = (m_cycle + 1) % STREAM_COUNT; } void AOBlipPlayer::updateInternalVolume() { - float volume = m_muted ? 0.0f : (m_volume * 0.01); + float volume = m_muted ? 0.0f : (m_volume * 0.01f); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelSetAttribute(m_stream[i], BASS_ATTRIB_VOL, volume); + m_stream[i].setVolume(volume); + } +} + +namespace +{ +// Standard 44-byte WAV header for PCM data. +void writeWavHeader(QFile &out, const QAudioFormat &fmt, qint32 pcmBytes) +{ + const quint32 sampleRate = static_cast(fmt.sampleRate()); + const quint16 channels = static_cast(fmt.channelCount()); + const quint16 bitsPerSample = static_cast(fmt.bytesPerSample() * 8); + const quint32 byteRate = sampleRate * channels * (bitsPerSample / 8); + const quint16 blockAlign = channels * (bitsPerSample / 8); + const quint32 riffSize = 36 + static_cast(pcmBytes); + + auto write32 = [&](quint32 v) { + char b[4] = {char(v & 0xff), char((v >> 8) & 0xff), char((v >> 16) & 0xff), char((v >> 24) & 0xff)}; + out.write(b, 4); + }; + auto write16 = [&](quint16 v) { + char b[2] = {char(v & 0xff), char((v >> 8) & 0xff)}; + out.write(b, 2); + }; + + out.write("RIFF", 4); + write32(riffSize); + out.write("WAVE", 4); + out.write("fmt ", 4); + write32(16); + write16(1); // PCM + write16(channels); + write32(sampleRate); + write32(byteRate); + write16(blockAlign); + write16(bitsPerSample); + out.write("data", 4); + write32(static_cast(pcmBytes)); +} +} // namespace + +QString AOBlipPlayer::resolveToPlayablePath(const QString &sourcePath) +{ + if (sourcePath.endsWith(".wav", Qt::CaseInsensitive)) + { + return sourcePath; } + + static QMap cache; + auto cached = cache.constFind(sourcePath); + if (cached != cache.constEnd()) + { + return *cached; + } + + QAudioDecoder decoder; + decoder.setSource(QUrl::fromLocalFile(sourcePath)); + + QByteArray pcm; + QAudioFormat format; + bool errored = false; + + QEventLoop loop; + QObject::connect(&decoder, &QAudioDecoder::bufferReady, &loop, [&]() { + while (decoder.bufferAvailable()) + { + QAudioBuffer buf = decoder.read(); + if (!format.isValid()) + { + format = buf.format(); + } + pcm.append(buf.constData(), buf.byteCount()); + } + }); + QObject::connect(&decoder, &QAudioDecoder::finished, &loop, &QEventLoop::quit); + QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, [&](QAudioDecoder::Error) { + errored = true; + loop.quit(); + }); + + decoder.start(); + loop.exec(); + + if (errored || pcm.isEmpty() || !format.isValid()) + { + // Decode failed; fall back to the original path so QSoundEffect at least logs a clear error. + cache.insert(sourcePath, sourcePath); + return sourcePath; + } + + QString hash = QString::number(qHash(sourcePath), 16); + QDir cacheDir(QDir::current().absoluteFilePath("tmp/blip-cache")); + cacheDir.mkpath("."); + QString outPath = cacheDir.absoluteFilePath(hash + ".wav"); + + QFile out(outPath); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + cache.insert(sourcePath, sourcePath); + return sourcePath; + } + writeWavHeader(out, format, pcm.size()); + out.write(pcm); + out.close(); + + cache.insert(sourcePath, outPath); + return outPath; } diff --git a/src/aoblipplayer.h b/src/aoblipplayer.h index 92b43d297..237697f4a 100644 --- a/src/aoblipplayer.h +++ b/src/aoblipplayer.h @@ -2,14 +2,7 @@ #include "aoapplication.h" -#include -#include - -#include -#include -#include - -#include +#include class AOBlipPlayer { @@ -30,8 +23,12 @@ class AOBlipPlayer int m_volume = 0; bool m_muted = false; - HSTREAM m_stream[STREAM_COUNT]{}; + QSoundEffect m_stream[STREAM_COUNT]; int m_cycle = 0; void updateInternalVolume(); + + // QSoundEffect only plays wav. Decode anything else to a temp wav and + // cache by source path. Returns the playable path (same as input for wav). + static QString resolveToPlayablePath(const QString &sourcePath); }; diff --git a/src/aomusicplayer.cpp b/src/aomusicplayer.cpp index 6ea8c83e9..8248c37e4 100644 --- a/src/aomusicplayer.cpp +++ b/src/aomusicplayer.cpp @@ -1,13 +1,61 @@ #include "aomusicplayer.h" +#include "datatypes.h" #include "file_functions.h" +#include "loopmetadata.h" #include "options.h" -#include - +#include +#include +#include #include -#include -#include +#include +#include +#include +#include + +namespace +{ +constexpr int FADE_OUT_MS = 4000; +constexpr int FADE_IN_MS = 1000; +constexpr int LOOP_POLL_INTERVAL_MS = 5; + +// Probe the sample rate of an audio file by reading one buffer via QAudioDecoder. +// Returns 0 on failure. Synchronous; only used by the legacy non-seconds loop metadata form. +int probeSampleRate(const QString &mediaPath) +{ + QAudioDecoder decoder; + decoder.setSource(QUrl::fromLocalFile(mediaPath)); + + int rate = 0; + bool errored = false; + QEventLoop loop; + + QObject::connect(&decoder, &QAudioDecoder::bufferReady, &loop, [&]() { + while (decoder.bufferAvailable()) + { + QAudioBuffer buf = decoder.read(); + if (buf.format().isValid()) + { + rate = buf.format().sampleRate(); + loop.quit(); + return; + } + } + }); + QObject::connect(&decoder, &QAudioDecoder::finished, &loop, &QEventLoop::quit); + QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, [&](QAudioDecoder::Error) { + errored = true; + loop.quit(); + }); + + decoder.start(); + loop.exec(); + decoder.stop(); + + return errored ? 0 : rate; +} +} // namespace AOMusicPlayer::AOMusicPlayer(AOApplication *ao_app) : ao_app(ao_app) @@ -15,175 +63,256 @@ AOMusicPlayer::AOMusicPlayer(AOApplication *ao_app) AOMusicPlayer::~AOMusicPlayer() { - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) + for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelStop(m_stream_list[n_stream]); + destroyStream(i); } } -QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, int effectFlags) +bool AOMusicPlayer::ensureValidStreamId(int streamId) +{ + return streamId >= 0 && streamId < STREAM_COUNT; +} + +void AOMusicPlayer::destroyStream(int streamId) { if (!ensureValidStreamId(streamId)) { - return "[ERROR] Invalid Channel"; + return; } - - bool isLooping = loopEnabled && !(effectFlags & NO_REPEAT); - - quint32 flags = BASS_STREAM_AUTOFREE; - if (isLooping) + Stream &s = m_streams[streamId]; + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + if (s.player) + { + s.player->stop(); + s.player->deleteLater(); + s.player = nullptr; + } + if (s.output) { - flags |= BASS_SAMPLE_LOOP; + s.output->deleteLater(); + s.output = nullptr; } + s.loop_start_ms = 0; + s.loop_end_ms = 0; +} - QString f_path = song; - HSTREAM newstream; - if (f_path.startsWith("http")) +void AOMusicPlayer::applyVolume(int streamId) +{ + Stream &s = m_streams[streamId]; + if (!s.output) { - if (!Options::getInstance().streamingEnabled()) + return; + } + float volume = m_muted ? 0.0f : (m_volume[streamId] / 100.0f); + s.output->setVolume(volume); +} + +void AOMusicPlayer::fadeOutAndDelete(QMediaPlayer *player, QAudioOutput *output, int durationMs) +{ + auto *anim = new QVariantAnimation(player); + anim->setStartValue(output->volume()); + anim->setEndValue(0.0f); + anim->setDuration(durationMs); + anim->setEasingCurve(QEasingCurve::InExpo); + QObject::connect(anim, &QVariantAnimation::valueChanged, output, [output](const QVariant &v) { output->setVolume(v.toFloat()); }); + QObject::connect(anim, &QVariantAnimation::finished, player, [player, output]() { + player->stop(); + player->deleteLater(); + output->deleteLater(); + }); + anim->start(QAbstractAnimation::DeleteWhenStopped); +} + +void AOMusicPlayer::parseLoopMetadata(int streamId, const QString &dataPath, const QString &mediaPath) +{ + Stream &s = m_streams[streamId]; + QString text = ao_app->read_file(dataPath); + LoopPoints lp = parseLoopMetadataText(text, [&]() { + int rate = probeSampleRate(mediaPath); + if (rate == 0) { - BASS_ChannelStop(m_stream_list[streamId]); - return QObject::tr("[MISSING] Streaming disabled."); + qWarning() << "Failed to probe sample rate for" << mediaPath << "— legacy byte-form loop points will be ignored."; } - QUrl l_url = QUrl(f_path); - newstream = BASS_StreamCreateURL(l_url.toEncoded().toStdString().c_str(), 0, flags, nullptr, 0); + return rate; + }); + s.loop_start_ms = lp.start_ms; + s.loop_end_ms = lp.end_ms; +} + +void AOMusicPlayer::armLoopWatcher(int streamId) +{ + Stream &s = m_streams[streamId]; + if (!s.player) + { + return; + } + + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + + if (s.loop_start_ms < s.loop_end_ms) + { + // Custom loop points: poll position; wrap on cross. + s.loopTimer = new QTimer(s.player); + s.loopTimer->setInterval(LOOP_POLL_INTERVAL_MS); + QMediaPlayer *p = s.player; + qint64 loopStart = s.loop_start_ms; + qint64 loopEnd = s.loop_end_ms; + QObject::connect(s.loopTimer, &QTimer::timeout, p, [p, loopStart, loopEnd]() { + if (p->position() >= loopEnd) + { + p->setPosition(loopStart); + } + }); + s.loopTimer->start(); + s.player->setLoops(1); } else { - flags |= BASS_STREAM_PRESCAN | BASS_UNICODE | BASS_ASYNCFILE; + // No custom points: let QMediaPlayer handle the loop natively. + s.player->setLoops(QMediaPlayer::Infinite); + } +} - f_path = ao_app->get_real_path(ao_app->get_music_path(song)); - newstream = BASS_StreamCreateFile(FALSE, f_path.utf16(), 0, 0, flags); +QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, int effectFlags) +{ + if (!ensureValidStreamId(streamId)) + { + return "[ERROR] Invalid Channel"; } - int error = BASS_ErrorGetCode(); - if (Options::getInstance().audioOutputDevice() != "default") + bool isLooping = loopEnabled && !(effectFlags & NO_REPEAT); + bool isHttp = song.startsWith("http"); + bool is_stop = (song == "~stop.mp3"); + + QString p_song_clear = QUrl(song).fileName(); + p_song_clear = p_song_clear.left(p_song_clear.lastIndexOf('.')); + + // Streaming disabled gate. + if (isHttp && !Options::getInstance().streamingEnabled()) { - BASS_ChannelSetDevice(m_stream_list[streamId], BASS_GetDevice()); + destroyStream(streamId); + return QObject::tr("[MISSING] Streaming disabled."); } - m_loop_start[streamId] = 0; - m_loop_end[streamId] = 0; + // "~stop.mp3" is a sentinel meaning "stop the current music." + if (is_stop) + { + destroyStream(streamId); + return streamId == 0 ? QObject::tr("None") : QString(); + } - QString d_path = f_path + ".txt"; - if (isLooping && file_exists(d_path)) // Contains loop/etc. information file + // Resolve local path and check existence. + QString resolvedPath = isHttp ? song : ao_app->get_real_path(ao_app->get_music_path(song)); + if (!isHttp && !file_exists(resolvedPath)) { - QStringList lines = ao_app->read_file(d_path).split("\n"); - bool seconds_mode = false; - foreach (QString line, lines) - { - QStringList args = line.split("="); - if (args.size() < 2) - { - continue; - } - QString arg = args[0].trimmed(); - if (arg == "seconds") - { - if (args[1].trimmed() == "true") - { - seconds_mode = true; // Use new epic behavior - continue; - } + destroyStream(streamId); + return QObject::tr("[MISSING] %1").arg(p_song_clear); + } - continue; - } + Stream &s = m_streams[streamId]; + QMediaPlayer *oldPlayer = s.player; + QAudioOutput *oldOutput = s.output; + qint64 oldPositionMs = (oldPlayer && oldPlayer->playbackState() == QMediaPlayer::PlayingState) ? oldPlayer->position() : -1; - float sample_rate; - BASS_ChannelGetAttribute(newstream, BASS_ATTRIB_FREQ, &sample_rate); + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } - // Grab number of bytes for sample size - int sample_size = 16 / 8; + // Build the new player. + auto *player = new QMediaPlayer(); + auto *output = new QAudioOutput(); + output->setDevice(ao_app->currentAudioDevice()); + player->setAudioOutput(output); - // number of channels (stereo/mono) - int num_channels = 2; + s.player = player; + s.output = output; + s.loop_start_ms = 0; + s.loop_end_ms = 0; - // Calculate the bytes for loop_start/loop_end to use with the sync proc - QWORD bytes; - if (seconds_mode) - { - bytes = BASS_ChannelSeconds2Bytes(newstream, args[1].trimmed().toDouble()); - } - else - { - bytes = static_cast(args[1].trimmed().toUInt() * sample_size * num_channels); - } - if (arg == "loop_start") - { - m_loop_start[streamId] = bytes; - } - else if (arg == "loop_length") - { - m_loop_end[streamId] = m_loop_start[streamId] + bytes; - } - else if (arg == "loop_end") + // Parse loop metadata for local files. + QString metadata = resolvedPath + ".txt"; + if (isLooping && !isHttp && file_exists(metadata)) + { + parseLoopMetadata(streamId, metadata, resolvedPath); + } + + player->setSource(isHttp ? QUrl(song) : QUrl::fromLocalFile(resolvedPath)); + + // SYNC_POS: seek the new player to the old player's position once it's loaded. + if (oldPositionMs >= 0 && (effectFlags & SYNC_POS)) + { + QObject::connect(player, &QMediaPlayer::mediaStatusChanged, player, [player, oldPositionMs](QMediaPlayer::MediaStatus status) { + if (status == QMediaPlayer::LoadedMedia || status == QMediaPlayer::BufferedMedia) { - m_loop_end[streamId] = bytes; + player->setPosition(oldPositionMs); } - } - qDebug() << "Found data file for song" << song << "length" << BASS_ChannelGetLength(newstream, BASS_POS_BYTE) << "loop start" << m_loop_start[streamId] << "loop end" << m_loop_end[streamId]; + }); } - if (BASS_ChannelIsActive(m_stream_list[streamId]) == BASS_ACTIVE_PLAYING) + // Configure looping for the new stream. + if (isLooping) { - DWORD oldstream = m_stream_list[streamId]; - - if (effectFlags & SYNC_POS) - { - BASS_ChannelLock(oldstream, true); - // Sync it with the new sample - BASS_ChannelSetPosition(newstream, BASS_ChannelGetPosition(oldstream, BASS_POS_BYTE), BASS_POS_BYTE); - BASS_ChannelLock(oldstream, false); - } - - if ((effectFlags & FADE_OUT) && m_volume[streamId] > 0) - { - // Fade out the other sample and stop it (due to -1) - BASS_ChannelSlideAttribute(oldstream, BASS_ATTRIB_VOL | BASS_SLIDE_LOG, -1, 4000); - } - else - { - BASS_ChannelStop(oldstream); // Stop the sample since we don't need it anymore - } + armLoopWatcher(streamId); } else { - BASS_ChannelStop(m_stream_list[streamId]); + player->setLoops(1); } - m_stream_list[streamId] = newstream; - BASS_ChannelPlay(newstream, false); + // FADE_IN: ramp volume from 0 to target over FADE_IN_MS. if (effectFlags & FADE_IN) { - // Fade in our sample - BASS_ChannelSetAttribute(newstream, BASS_ATTRIB_VOL, 0); - BASS_ChannelSlideAttribute(newstream, BASS_ATTRIB_VOL, static_cast(m_volume[streamId] / 100.0f), 1000); + output->setVolume(0.0f); + auto *anim = new QVariantAnimation(player); + anim->setStartValue(0.0f); + float target = m_muted ? 0.0f : (m_volume[streamId] / 100.0f); + anim->setEndValue(target); + anim->setDuration(FADE_IN_MS); + anim->setEasingCurve(QEasingCurve::OutExpo); + QObject::connect(anim, &QVariantAnimation::valueChanged, output, [output](const QVariant &v) { output->setVolume(v.toFloat()); }); + anim->start(QAbstractAnimation::DeleteWhenStopped); } else { - this->setStreamVolume(m_volume[streamId], streamId); + applyVolume(streamId); } - BASS_ChannelSetSync(newstream, BASS_SYNC_DEV_FAIL, 0, ao_app->BASSreset, 0); - - this->setStreamLooping(isLooping, streamId); // Have to do this here due to any - // crossfading-related changes, etc. + player->play(); - bool is_stop = (song == "~stop.mp3"); - QString p_song_clear = QUrl(song).fileName(); - p_song_clear = p_song_clear.left(p_song_clear.lastIndexOf('.')); - - if (is_stop && streamId == 0) - { // don't send text on channels besides 0 - return QObject::tr("None"); - } - - if (error == BASS_ERROR_HANDLE) - { // Cheap hack to see if file missing - return QObject::tr("[MISSING] %1").arg(p_song_clear); + // Dispose of the old player. Detach from the Stream struct first so destroyStream + // can't trample our new player if it runs later. + if (oldPlayer) + { + if ((effectFlags & FADE_OUT) && m_volume[streamId] > 0) + { + fadeOutAndDelete(oldPlayer, oldOutput, FADE_OUT_MS); + } + else + { + oldPlayer->stop(); + oldPlayer->deleteLater(); + if (oldOutput) + { + oldOutput->deleteLater(); + } + } } - if (song.startsWith("http") && streamId == 0) + if (isHttp && streamId == 0) { return QObject::tr("[STREAM] %1").arg(p_song_clear); } @@ -193,16 +322,15 @@ QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, return p_song_clear; } - return ""; + return QString(); } void AOMusicPlayer::setMuted(bool enabled) { m_muted = enabled; - // Update all volume based on the mute setting - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) + for (int i = 0; i < STREAM_COUNT; ++i) { - setStreamVolume(m_volume[n_stream], n_stream); + applyVolume(i); } } @@ -213,31 +341,19 @@ void AOMusicPlayer::setStreamVolume(int value, int streamId) qWarning().noquote() << QObject::tr("Invalid stream ID '%2'").arg(streamId); return; } - m_volume[streamId] = value; - // If muted, volume will always be 0 - float volume = (m_volume[streamId] / 100.0f) * !m_muted; - if (streamId < 0) + applyVolume(streamId); +} + +void AOMusicPlayer::setAudioDevice(const QAudioDevice &device) +{ + for (Stream &s : m_streams) { - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) + if (s.output && s.output->device() != device) { - BASS_ChannelSetAttribute(m_stream_list[n_stream], BASS_ATTRIB_VOL, volume); + s.output->setDevice(device); } } - else - { - BASS_ChannelSetAttribute(m_stream_list[streamId], BASS_ATTRIB_VOL, volume); - } -} - -void CALLBACK loopProc(HSYNC handle, DWORD channel, DWORD data, void *user) -{ - Q_UNUSED(handle); - Q_UNUSED(data); - QWORD loop_start = *(static_cast(user)); - BASS_ChannelLock(channel, true); - BASS_ChannelSetPosition(channel, loop_start, BASS_POS_BYTE); - BASS_ChannelLock(channel, false); } void AOMusicPlayer::setStreamLooping(bool enabled, int streamId) @@ -248,40 +364,23 @@ void AOMusicPlayer::setStreamLooping(bool enabled, int streamId) return; } - if (!enabled) + Stream &s = m_streams[streamId]; + if (!s.player) { - if (BASS_ChannelFlags(m_stream_list[streamId], 0, 0) & BASS_SAMPLE_LOOP) - { - BASS_ChannelFlags(m_stream_list[streamId], 0, - BASS_SAMPLE_LOOP); // remove the LOOP flag - } - BASS_ChannelRemoveSync(m_stream_list[streamId], m_loop_sync[streamId]); - m_loop_sync[streamId] = 0; return; } - BASS_ChannelFlags(m_stream_list[streamId], BASS_SAMPLE_LOOP, - BASS_SAMPLE_LOOP); // set the LOOP flag - if (m_loop_sync[streamId] != 0) - { - BASS_ChannelRemoveSync(m_stream_list[streamId], - m_loop_sync[streamId]); // remove the sync - m_loop_sync[streamId] = 0; - } - - if (m_loop_start[streamId] < m_loop_end[streamId]) - { - // Loop when the endpoint is reached. - m_loop_sync[streamId] = BASS_ChannelSetSync(m_stream_list[streamId], BASS_SYNC_POS | BASS_SYNC_MIXTIME, m_loop_end[streamId], loopProc, &m_loop_start[streamId]); - } - else + if (!enabled) { - // Loop when the end of the file is reached. - m_loop_sync[streamId] = BASS_ChannelSetSync(m_stream_list[streamId], BASS_SYNC_END | BASS_SYNC_MIXTIME, 0, loopProc, &m_loop_start[streamId]); + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + s.player->setLoops(1); + return; } -} -bool AOMusicPlayer::ensureValidStreamId(int streamId) -{ - return (streamId >= 0 && streamId < STREAM_COUNT); + armLoopWatcher(streamId); } diff --git a/src/aomusicplayer.h b/src/aomusicplayer.h index 707d64ad3..83a1eb43c 100644 --- a/src/aomusicplayer.h +++ b/src/aomusicplayer.h @@ -2,7 +2,9 @@ #include "aoapplication.h" -#include +#include +#include +#include class AOMusicPlayer { @@ -20,19 +22,28 @@ class AOMusicPlayer void setStreamVolume(int value, int streamId); void setStreamLooping(bool enabled, int streamId); - - QFutureWatcher m_watcher; + void setAudioDevice(const QAudioDevice &device); private: AOApplication *ao_app; bool m_muted = false; - int m_volume[STREAM_COUNT]{}; - HSTREAM m_stream_list[STREAM_COUNT]{}; - HSYNC m_loop_sync[STREAM_COUNT]{}; - quint32 m_loop_start[STREAM_COUNT]{}; - quint32 m_loop_end[STREAM_COUNT]{}; + struct Stream + { + QMediaPlayer *player = nullptr; + QAudioOutput *output = nullptr; + QTimer *loopTimer = nullptr; + qint64 loop_start_ms = 0; + qint64 loop_end_ms = 0; + }; + Stream m_streams[STREAM_COUNT]; + + void destroyStream(int streamId); + void parseLoopMetadata(int streamId, const QString &dataPath, const QString &mediaPath); + void armLoopWatcher(int streamId); + void applyVolume(int streamId); + void fadeOutAndDelete(QMediaPlayer *player, QAudioOutput *output, int durationMs); bool ensureValidStreamId(int streamId); }; diff --git a/src/aosfxplayer.cpp b/src/aosfxplayer.cpp index ac3b8513d..2f6599d14 100644 --- a/src/aosfxplayer.cpp +++ b/src/aosfxplayer.cpp @@ -2,6 +2,9 @@ #include "file_functions.h" +#include +#include + AOSfxPlayer::AOSfxPlayer(AOApplication *ao_app) : ao_app(ao_app) {} @@ -21,7 +24,7 @@ void AOSfxPlayer::play(QString path) { for (int i = 0; i < STREAM_COUNT; ++i) { - if (BASS_ChannelIsActive(m_stream[i]) == BASS_ACTIVE_PLAYING) + if (m_stream[i].isPlaying()) { m_current_stream_id = (i + 1) % STREAM_COUNT; } @@ -32,20 +35,12 @@ void AOSfxPlayer::play(QString path) } } - if (path.endsWith(".opus")) - { - m_stream[m_current_stream_id] = BASS_OPUS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_STREAM_AUTOFREE | BASS_UNICODE | BASS_ASYNCFILE); - } - else - { - m_stream[m_current_stream_id] = BASS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_STREAM_AUTOFREE | BASS_UNICODE | BASS_ASYNCFILE); - } - + QSoundEffect &stream = m_stream[m_current_stream_id]; + stream.setAudioDevice(ao_app->currentAudioDevice()); + stream.setLoopCount(1); + stream.setSource(QUrl::fromLocalFile(path)); updateInternalVolume(); - - BASS_ChannelSetDevice(m_stream[m_current_stream_id], BASS_GetDevice()); - BASS_ChannelPlay(m_stream[m_current_stream_id], false); - BASS_ChannelSetSync(m_stream[m_current_stream_id], BASS_SYNC_DEV_FAIL, 0, ao_app->BASSreset, 0); + stream.play(); } void AOSfxPlayer::findAndPlaySfx(QString sfx) @@ -81,7 +76,7 @@ void AOSfxPlayer::stopAllLoopingStream() { for (int i = 0; i < STREAM_COUNT; ++i) { - if (BASS_ChannelFlags(m_stream[i], 0, 0) & BASS_SAMPLE_LOOP) + if (m_stream[i].loopCount() == QSoundEffect::Infinite) { stop(i); } @@ -97,22 +92,21 @@ void AOSfxPlayer::stop(int streamId) return; } - BASS_ChannelStop(m_stream[streamId]); + m_stream[streamId].stop(); } void AOSfxPlayer::setMuted(bool toggle) { m_muted = toggle; - // Update the audio volume updateInternalVolume(); } void AOSfxPlayer::updateInternalVolume() { - float volume = m_muted ? 0.0f : (m_volume * 0.01); + float volume = m_muted ? 0.0f : (m_volume * 0.01f); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelSetAttribute(m_stream[i], BASS_ATTRIB_VOL, volume); + m_stream[i].setVolume(volume); } } @@ -126,22 +120,7 @@ void AOSfxPlayer::setLooping(bool toggle, int streamId) } m_looping = toggle; - if (BASS_ChannelFlags(m_stream[streamId], 0, 0) & BASS_SAMPLE_LOOP) - { - if (m_looping == false) - { - BASS_ChannelFlags(m_stream[streamId], 0, - BASS_SAMPLE_LOOP); // remove the LOOP flag - } - } - else - { - if (m_looping == true) - { - BASS_ChannelFlags(m_stream[streamId], BASS_SAMPLE_LOOP, - BASS_SAMPLE_LOOP); // set the LOOP flag - } - } + m_stream[streamId].setLoopCount(m_looping ? QSoundEffect::Infinite : 1); } int AOSfxPlayer::maybeFetchCurrentStreamId(int streamId) diff --git a/src/aosfxplayer.h b/src/aosfxplayer.h index 7669f0de9..faae90deb 100644 --- a/src/aosfxplayer.h +++ b/src/aosfxplayer.h @@ -2,11 +2,7 @@ #include "aoapplication.h" -#include -#include - -#include -#include +#include class AOSfxPlayer { @@ -36,7 +32,7 @@ class AOSfxPlayer int m_volume = 0; bool m_muted = false; bool m_looping = true; - HSTREAM m_stream[STREAM_COUNT]{}; + QSoundEffect m_stream[STREAM_COUNT]; int m_current_stream_id = 0; int maybeFetchCurrentStreamId(int streamId); diff --git a/src/courtroom.cpp b/src/courtroom.cpp index 80903f3c3..7a5f82158 100644 --- a/src/courtroom.cpp +++ b/src/courtroom.cpp @@ -16,7 +16,6 @@ Courtroom::Courtroom(AOApplication *p_ao_app) setWindowFlags((this->windowFlags() | Qt::CustomizeWindowHint) & ~Qt::WindowMaximizeButtonHint); setObjectName("courtroom"); - ao_app->initBASS(); keepalive_timer = new QTimer(this); keepalive_timer->start(45000); @@ -33,7 +32,6 @@ Courtroom::Courtroom(AOApplication *p_ao_app) music_player = new AOMusicPlayer(ao_app); music_player->setMuted(true); - connect(&music_player->m_watcher, &QFutureWatcher::finished, this, &Courtroom::update_ui_music_name, Qt::QueuedConnection); sfx_player = new AOSfxPlayer(ao_app); sfx_player->setMuted(true); @@ -4839,28 +4837,11 @@ void Courtroom::handle_song(QStringList *p_contents) } } - if (channel == 0) + QString result = music_player->playStream(f_song, channel, looping, effect_flags); + if (channel == 0 && !result.isEmpty()) { - // Current song UI only displays the song playing, not other channels. - // Any other music playing is irrelevant. - if (music_player->m_watcher.isRunning()) - { - music_player->m_watcher.cancel(); - } - ui_music_name->setText(tr("[LOADING] %1").arg(f_song_clear)); - } - - music_player->m_watcher.setFuture(QtConcurrent::run([=, this]() -> QString { return music_player->playStream(f_song, channel, looping, effect_flags); })); -} - -void Courtroom::update_ui_music_name() -{ - QString result = music_player->m_watcher.result(); - if (result.isEmpty()) - { - return; + ui_music_name->setText(result); } - ui_music_name->setText(result); } void Courtroom::handle_wtce(QString p_wtce, int variant) @@ -6519,6 +6500,11 @@ void Courtroom::on_change_character_clicked() ui_char_select_background->show(); } +void Courtroom::on_audio_device_changed() +{ + music_player->setAudioDevice(ao_app->currentAudioDevice()); +} + void Courtroom::on_reload_theme_clicked() { set_courtroom_size(); diff --git a/src/courtroom.h b/src/courtroom.h index 61e3138ae..0d3097426 100644 --- a/src/courtroom.h +++ b/src/courtroom.h @@ -819,8 +819,7 @@ public Q_SLOTS: void mod_called(QString p_ip); void on_reload_theme_clicked(); - - void update_ui_music_name(); + void on_audio_device_changed(); private Q_SLOTS: void start_chat_ticking(); diff --git a/src/lobby.cpp b/src/lobby.cpp index 2074dd840..09a74c0b3 100644 --- a/src/lobby.cpp +++ b/src/lobby.cpp @@ -304,7 +304,7 @@ void Lobby::on_about_clicked() "is copyright (c) 2016-2022 Attorney Online developers. Open-source " "licenses apply. All other assets are the property of their " "respective owners." - "

Running on Qt version %2 with the BASS audio engine.
" + "

Running on Qt version %2 with the QtMultimedia audio backend.
" "APNG plugin loaded: %3" "

Built on %4" "

Base folder: %5") diff --git a/src/loopmetadata.cpp b/src/loopmetadata.cpp new file mode 100644 index 000000000..8e6ad5e13 --- /dev/null +++ b/src/loopmetadata.cpp @@ -0,0 +1,64 @@ +#include "loopmetadata.h" + +#include + +LoopPoints parseLoopMetadataText(const QString &metadata_text, const std::function &sample_rate_provider) +{ + LoopPoints out; + bool seconds_mode = false; + int sample_rate = 0; + constexpr int sample_size = 2; // 16-bit + constexpr int num_channels = 2; + + const QStringList lines = metadata_text.split("\n"); + for (const QString &line : lines) + { + QStringList args = line.split("="); + if (args.size() < 2) + { + continue; + } + QString arg = args[0].trimmed(); + QString val = args[1].trimmed(); + + if (arg == "seconds") + { + seconds_mode = (val == "true"); + continue; + } + + qint64 ms = 0; + if (seconds_mode) + { + ms = static_cast(val.toDouble() * 1000.0); + } + else + { + if (sample_rate == 0) + { + sample_rate = sample_rate_provider ? sample_rate_provider() : 0; + if (sample_rate == 0) + { + continue; + } + } + quint64 bytes = static_cast(val.toUInt()) * sample_size * num_channels; + quint64 frame_bytes = static_cast(sample_rate) * sample_size * num_channels; + ms = static_cast(bytes * 1000 / frame_bytes); + } + + if (arg == "loop_start") + { + out.start_ms = ms; + } + else if (arg == "loop_length") + { + out.end_ms = out.start_ms + ms; + } + else if (arg == "loop_end") + { + out.end_ms = ms; + } + } + return out; +} diff --git a/src/loopmetadata.h b/src/loopmetadata.h new file mode 100644 index 000000000..23ed3c315 --- /dev/null +++ b/src/loopmetadata.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include + +struct LoopPoints +{ + qint64 start_ms = 0; + qint64 end_ms = 0; +}; + +// Parse loop-point metadata from an AO music metadata file (the `.txt` that +// lives next to a music file). Recognises `seconds=(true|false)`, +// `loop_start=N`, `loop_end=N`, `loop_length=N`. +// +// In the legacy form (`seconds=false` or absent) N is a sample count and +// must be divided by the file's sample rate to get a ms timestamp. +// `sample_rate_provider` is invoked lazily — at most once, only if the +// metadata actually contains a non-seconds entry — and should return the +// file's sample rate, or 0 to signal probe failure. When 0, legacy entries +// are silently ignored. +LoopPoints parseLoopMetadataText(const QString &metadata_text, const std::function &sample_rate_provider); diff --git a/src/widgets/aooptionsdialog.cpp b/src/widgets/aooptionsdialog.cpp index c04acc755..88ee43ec3 100644 --- a/src/widgets/aooptionsdialog.cpp +++ b/src/widgets/aooptionsdialog.cpp @@ -7,11 +7,11 @@ #include "networkmanager.h" #include "options.h" -#include - +#include #include #include #include +#include #include #include #include @@ -31,10 +31,10 @@ void AOOptionsDialog::populateAudioDevices() ui_audio_device_combobox->addItem("default", "default"); } - BASS_DEVICEINFO info; - for (int a = 0; BASS_GetDeviceInfo(a, &info); a++) + const QList devices = QMediaDevices::audioOutputs(); + for (const QAudioDevice &dev : devices) { - ui_audio_device_combobox->addItem(info.name, info.name); + ui_audio_device_combobox->addItem(dev.description(), dev.description()); } } @@ -243,6 +243,7 @@ void AOOptionsDialog::updateValues() void AOOptionsDialog::savePressed() { bool l_reload_theme_required = (ui_theme_combobox->currentText() != Options::getInstance().theme()) || (ui_theme_scaling_factor_sb->value() != Options::getInstance().themeScalingFactor()); + bool l_audio_device_changed = (ui_audio_device_combobox->currentData().toString() != Options::getInstance().audioOutputDevice()); for (const OptionEntry &entry : std::as_const(optionEntries)) { entry.save(); @@ -252,6 +253,10 @@ void AOOptionsDialog::savePressed() { Q_EMIT reloadThemeRequest(); } + if (l_audio_device_changed) + { + Q_EMIT audioDeviceChanged(); + } close(); } diff --git a/src/widgets/aooptionsdialog.h b/src/widgets/aooptionsdialog.h index 5ad706f9d..746ebb730 100644 --- a/src/widgets/aooptionsdialog.h +++ b/src/widgets/aooptionsdialog.h @@ -144,6 +144,7 @@ class AOOptionsDialog : public QDialog Q_SIGNALS: void reloadThemeRequest(); + void audioDeviceChanged(); private Q_SLOTS: void savePressed(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c9aecd395..f1e066aa0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -17,3 +17,4 @@ function(ao_declare_test test_id) endfunction() ao_declare_test(test_aopacket test_aopacket.cpp ../src/aopacket.cpp) +ao_declare_test(test_loopmetadata test_loopmetadata.cpp ../src/loopmetadata.cpp) diff --git a/test/test_loopmetadata.cpp b/test/test_loopmetadata.cpp new file mode 100644 index 000000000..c988d0c9d --- /dev/null +++ b/test/test_loopmetadata.cpp @@ -0,0 +1,171 @@ +#include "loopmetadata.h" + +#include + +class test_LoopMetadata : public QObject +{ + Q_OBJECT + +private: + // 44100 Hz, 16-bit stereo: 1 second = 44100 samples = 176400 bytes. + // The parser's legacy form takes sample counts, so a value of 44100 → 1000 ms. + static constexpr int SAMPLE_RATE = 44100; + + static std::function fixedRate(int rate) + { + return [rate]() { + return rate; + }; + } + +private Q_SLOTS: + void emptyInput() + { + LoopPoints lp = parseLoopMetadataText("", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void onlyWhitespace() + { + LoopPoints lp = parseLoopMetadataText("\n\n \n", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void secondsFormStartEnd() + { + LoopPoints lp = parseLoopMetadataText("seconds=true\nloop_start=2.5\nloop_end=10", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void secondsFormStartLength() + { + LoopPoints lp = parseLoopMetadataText("seconds=true\nloop_start=2.5\nloop_length=7.5", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void legacyFormStartEnd() + { + // 44100 samples = 1 second at 44.1 kHz; 88200 samples = 2 seconds. + LoopPoints lp = parseLoopMetadataText("loop_start=44100\nloop_end=88200", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + } + + void legacyFormStartLength() + { + LoopPoints lp = parseLoopMetadataText("loop_start=22050\nloop_length=22050", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(500)); + QCOMPARE(lp.end_ms, qint64(1000)); + } + + void explicitSecondsFalseIsLegacy() + { + LoopPoints lp = parseLoopMetadataText("seconds=false\nloop_start=44100\nloop_end=88200", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + } + + void secondsFormSkipsProbe() + { + int probeCalls = 0; + auto provider = [&]() { + ++probeCalls; + return SAMPLE_RATE; + }; + LoopPoints lp = parseLoopMetadataText("seconds=true\nloop_start=1\nloop_end=2", provider); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + QCOMPARE(probeCalls, 0); + } + + void legacyFormProbesAtMostOnce() + { + int probeCalls = 0; + auto provider = [&]() { + ++probeCalls; + return SAMPLE_RATE; + }; + LoopPoints lp = parseLoopMetadataText("loop_start=44100\nloop_length=44100\nloop_end=132300", provider); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(3000)); // loop_end overrides the loop_length computation + QCOMPARE(probeCalls, 1); + } + + void probeFailureSilencesLegacyEntries() + { + LoopPoints lp = parseLoopMetadataText("loop_start=44100\nloop_end=88200", fixedRate(0)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void nullProviderTreatedAsFailure() + { + LoopPoints lp = parseLoopMetadataText("loop_start=44100\nloop_end=88200", nullptr); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void malformedLinesSkipped() + { + QString input = "this is not a kv line\n" + "seconds=true\n" + "loop_start=2.5\n" + "garbage_without_equals\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopMetadataText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void unknownKeysIgnored() + { + QString input = "seconds=true\n" + "loop_start=2.5\n" + "something_else=42\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopMetadataText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void whitespaceAroundKvIsTrimmed() + { + QString input = " seconds = true \n" + " loop_start = 2.5\n" + "loop_end= 10 \n"; + LoopPoints lp = parseLoopMetadataText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void lastLoopStartWins() + { + QString input = "seconds=true\n" + "loop_start=1\n" + "loop_start=5\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopMetadataText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(5000)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void secondsToggleMidStreamApplies() + { + // Mode change mid-file: loop_start uses legacy (sample count at 44.1k), + // then `seconds=true` flips the mode, and loop_end uses seconds. + QString input = "loop_start=44100\n" + "seconds=true\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopMetadataText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(10000)); + } +}; + +#include "test/test_loopmetadata.moc" + +QTEST_APPLESS_MAIN(test_LoopMetadata)