SoundPlayer: Implement playlist shuffle mode

The shuffling algorithm uses a naïve bloom filter to provide random
uniformity, avoiding items that were recently played.  With 32 bits,
double hashing, and an error rate of ~10%, this bloom filter should
be able to hold around ~16 keys, which should be sufficient to give the
illusion of fairness to the shuffling algorithm.

This avoids having to shuffle the playlist itself (user might have
spent quite a bit of time to sort them, so it's not a good idea to mess
with it), or having to create a proxy model that shuffles (that could
potentially use quite a bit of memory).
This commit is contained in:
Leandro Pereira 2021-09-30 07:41:00 -07:00 committed by Andreas Kling
parent 0812965f50
commit 314b8a374b
Notes: sideshowbarker 2024-07-18 01:52:41 +09:00
7 changed files with 106 additions and 11 deletions

View file

@ -92,6 +92,15 @@ void Player::set_volume(double volume)
volume_changed(m_volume);
}
void Player::set_shuffle_mode(ShuffleMode mode)
{
if (m_shuffle_mode != mode) {
m_shuffle_mode = mode;
m_playlist.set_shuffling(mode == ShuffleMode::Shuffling);
shuffle_mode_changed(mode);
}
}
void Player::play()
{
m_playback_manager.play();

View file

@ -24,6 +24,10 @@ public:
File,
Playlist,
};
enum class ShuffleMode {
None,
Shuffling,
};
explicit Player(Audio::ClientConnection& audio_client_connection);
virtual ~Player() { }
@ -34,10 +38,13 @@ public:
StringView loaded_filename() const { return m_loaded_filename; }
PlayState play_state() const { return m_play_state; }
void set_play_state(PlayState state);
void set_play_state(PlayState);
LoopMode loop_mode() const { return m_loop_mode; }
void set_loop_mode(LoopMode mode);
void set_loop_mode(LoopMode);
ShuffleMode shuffle_mode() const { return m_shuffle_mode; }
void set_shuffle_mode(ShuffleMode);
double volume() const { return m_volume; }
void set_volume(double value);
@ -52,11 +59,12 @@ public:
virtual void loop_mode_changed(LoopMode) = 0;
virtual void time_elapsed(int) = 0;
virtual void file_name_changed(StringView) = 0;
virtual void playlist_loaded(StringView, bool) { }
virtual void audio_load_error(StringView, StringView) { }
virtual void volume_changed(double) { }
virtual void total_samples_changed(int) { }
virtual void sound_buffer_played(RefPtr<Audio::Buffer>, [[maybe_unused]] int sample_rate, [[maybe_unused]] int samples_played) { }
virtual void playlist_loaded(StringView, bool) = 0;
virtual void audio_load_error(StringView, StringView) = 0;
virtual void shuffle_mode_changed(ShuffleMode) = 0;
virtual void volume_changed(double) = 0;
virtual void total_samples_changed(int) = 0;
virtual void sound_buffer_played(RefPtr<Audio::Buffer>, [[maybe_unused]] int sample_rate, [[maybe_unused]] int samples_played) = 0;
protected:
void done_initializing()
@ -71,6 +79,7 @@ private:
Playlist m_playlist;
PlayState m_play_state;
LoopMode m_loop_mode;
ShuffleMode m_shuffle_mode;
Audio::ClientConnection& m_audio_client_connection;
PlaybackManager m_playback_manager;

View file

@ -8,6 +8,7 @@
#include "Playlist.h"
#include <AK/LexicalPath.h>
#include <AK/Random.h>
#include <LibAudio/Loader.h>
#include <LibGUI/MessageBox.h>
@ -33,7 +34,7 @@ void Playlist::try_fill_missing_info(Vector<M3UEntry>& entries, StringView path)
Vector<M3UEntry*> to_delete;
for (auto& entry : entries) {
if (!LexicalPath(entry.path).is_absolute())
if (!LexicalPath { entry.path }.is_absolute())
entry.path = String::formatted("{}/{}", playlist_path.dirname(), entry.path);
if (!entry.extended_info->file_size_in_bytes.has_value()) {
@ -69,8 +70,32 @@ StringView Playlist::next()
return {};
m_next_index_to_play = 0;
}
auto next = m_model->items().at(m_next_index_to_play).path;
m_next_index_to_play++;
if (!shuffling()) {
m_next_index_to_play++;
return next;
}
// Try a few times getting an item to play that has not been
// recently played. But do not try too hard, as we don't want
// to wait forever.
int shuffle_try;
int const max_times_to_try = min(4, size());
for (shuffle_try = 0; shuffle_try < max_times_to_try; shuffle_try++) {
if (!m_previously_played_paths.maybe_contains(next))
break;
m_next_index_to_play = get_random_uniform(size());
next = m_model->items().at(m_next_index_to_play).path;
}
if (shuffle_try == max_times_to_try) {
// If we tried too much, maybe it's time to try resetting
// the bloom filter and start over.
m_previously_played_paths.reset();
}
m_previously_played_paths.add(next);
return next;
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021, the SerenityOS developers.
* Copyright (c) 2021, Leandro A. F. Pereira <leandro@tia.mat.br>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -8,6 +9,7 @@
#include "M3UParser.h"
#include "PlaylistWidget.h"
#include <AK/StringHash.h>
#include <AK/StringView.h>
#include <AK/Vector.h>
@ -29,11 +31,40 @@ public:
void set_looping(bool looping) { m_looping = looping; }
bool looping() const { return m_looping; }
void set_shuffling(bool shuffling) { m_shuffling = shuffling; }
bool shuffling() const { return m_shuffling; }
private:
// This naïve bloom filter is used in the shuffling algorithm to
// provide random uniformity, avoiding playing items that were recently
// played.
class BloomFilter {
public:
void reset() { m_bitmap = 0; }
void add(const StringView key) { m_bitmap |= mask_for_key(key); }
bool maybe_contains(const StringView key) const
{
auto mask = mask_for_key(key);
return (m_bitmap & mask) == mask;
}
private:
u64 mask_for_key(StringView key) const
{
auto key_chars = key.characters_without_null_termination();
auto hash1 = string_hash(key_chars, key.length(), 0xdeadbeef);
auto hash2 = string_hash(key_chars, key.length(), 0xbebacafe);
return 1ULL << (hash1 & 63) | 1ULL << (hash2 & 63);
}
u64 m_bitmap { 0 };
};
void try_fill_missing_info(Vector<M3UEntry>&, StringView);
RefPtr<PlaylistModel> m_model;
bool m_looping { false };
bool m_shuffling { false };
BloomFilter m_previously_played_paths;
int m_next_index_to_play { 0 };
};

View file

@ -157,8 +157,7 @@ void SoundPlayerWidgetAdvancedView::set_playlist_visible(bool visible)
void SoundPlayerWidgetAdvancedView::play_state_changed(Player::PlayState state)
{
m_back_button->set_enabled(playlist().size() > 1);
m_next_button->set_enabled(playlist().size() > 1);
sync_previous_next_buttons();
m_play_button->set_enabled(state != PlayState::NoFileLoaded);
m_play_button->set_icon(state == PlayState::Playing ? *m_pause_icon : *m_play_icon);
@ -172,6 +171,17 @@ void SoundPlayerWidgetAdvancedView::loop_mode_changed(Player::LoopMode)
{
}
void SoundPlayerWidgetAdvancedView::sync_previous_next_buttons()
{
m_back_button->set_enabled(playlist().size() > 1 && !playlist().shuffling());
m_next_button->set_enabled(playlist().size() > 1);
}
void SoundPlayerWidgetAdvancedView::shuffle_mode_changed(Player::ShuffleMode)
{
sync_previous_next_buttons();
}
void SoundPlayerWidgetAdvancedView::time_elapsed(int seconds)
{
m_timestamp_label->set_text(String::formatted("Elapsed: {:02}:{:02}:{:02}", seconds / 3600, seconds / 60, seconds % 60));

View file

@ -38,6 +38,7 @@ public:
virtual void play_state_changed(PlayState) override;
virtual void loop_mode_changed(LoopMode) override;
virtual void shuffle_mode_changed(ShuffleMode) override;
virtual void time_elapsed(int) override;
virtual void file_name_changed(StringView) override;
virtual void playlist_loaded(StringView, bool) override;
@ -50,6 +51,8 @@ protected:
void keydown_event(GUI::KeyEvent&) override;
private:
void sync_previous_next_buttons();
void drop_event(GUI::DropEvent& event) override;
GUI::Window& m_window;

View file

@ -98,6 +98,14 @@ int main(int argc, char** argv)
playlist_toggle->set_checked(true);
playback_menu.add_action(playlist_toggle);
auto shuffle_mode = GUI::Action::create_checkable("S&huffle Playlist", [&](auto& action) {
if (action.is_checked())
player->set_shuffle_mode(Player::ShuffleMode::Shuffling);
else
player->set_shuffle_mode(Player::ShuffleMode::None);
});
playback_menu.add_action(shuffle_mode);
auto& visualization_menu = window->add_menu("&Visualization");
GUI::ActionGroup visualization_actions;
visualization_actions.set_exclusive(true);