diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 421af2930..3f0903d31 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -41,4 +41,8 @@ if(SFML_BUILD_GRAPHICS) endif() if(SFML_BUILD_GRAPHICS AND SFML_BUILD_AUDIO) add_subdirectory(tennis) + + if(NOT SFML_OPENGL_ES) + add_subdirectory(sound_effects) + endif() endif() diff --git a/examples/sound_effects/CMakeLists.txt b/examples/sound_effects/CMakeLists.txt new file mode 100644 index 000000000..7af53daf8 --- /dev/null +++ b/examples/sound_effects/CMakeLists.txt @@ -0,0 +1,16 @@ +# all source files +set(SRC SoundEffects.cpp) +if(SFML_OS_IOS) + set(RESOURCES + resources/doodle_pop.ogg + resources/text-background.png + resources/tuffy.ttf) + set_source_files_properties(${RESOURCES} PROPERTIES MACOSX_PACKAGE_LOCATION Resources) +endif() + +# define the sound-effects target +sfml_add_example(sound-effects GUI_APP + SOURCES ${SRC} + BUNDLE_RESOURCES ${RESOURCES} + DEPENDS SFML::Audio SFML::Graphics + RESOURCES_DIR resources) diff --git a/examples/sound_effects/SoundEffects.cpp b/examples/sound_effects/SoundEffects.cpp new file mode 100644 index 000000000..949985729 --- /dev/null +++ b/examples/sound_effects/SoundEffects.cpp @@ -0,0 +1,745 @@ +//////////////////////////////////////////////////////////// +// Headers +//////////////////////////////////////////////////////////// +#include + +#include + +#include +#include +#include +#include + +#include + + +namespace +{ +constexpr auto windowWidth = 800u; +constexpr auto windowHeight = 600u; +constexpr auto pi = 3.14159265359f; + +std::filesystem::path resourcesDir() +{ +#ifdef SFML_SYSTEM_IOS + return ""; +#else + return "resources"; +#endif +} +} // namespace + + +//////////////////////////////////////////////////////////// +// Base class for effects +//////////////////////////////////////////////////////////// +class Effect : public sf::Drawable +{ +public: + static void setFont(const sf::Font& font) + { + s_font = &font; + } + + const std::string& getName() const + { + return m_name; + } + + void update(float time, float x, float y) + { + onUpdate(time, x, y); + } + + void draw(sf::RenderTarget& target, sf::RenderStates states) const override + { + onDraw(target, states); + } + + void start() + { + onStart(); + } + + void stop() + { + onStop(); + } + + void handleKey(sf::Keyboard::Key key) + { + onKey(key); + } + +protected: + Effect(std::string name) : m_name(std::move(name)) + { + } + + static const sf::Font& getFont() + { + assert(s_font != nullptr && "Cannot get font until setFont() is called"); + return *s_font; + } + +private: + // Virtual functions to be implemented in derived effects + virtual void onUpdate(float time, float x, float y) = 0; + virtual void onDraw(sf::RenderTarget& target, const sf::RenderStates& states) const = 0; + virtual void onStart() = 0; + virtual void onStop() = 0; + + virtual void onKey(sf::Keyboard::Key) + { + } + + std::string m_name; + + // NOLINTNEXTLINE(readability-identifier-naming) + static inline const sf::Font* s_font{nullptr}; +}; + + +//////////////////////////////////////////////////////////// +// Surround Sound / Positional Audio Effect / Attenuation +//////////////////////////////////////////////////////////// +class Surround : public Effect +{ +public: + Surround() : Effect("Surround / Attenuation") + { + m_listener.setPosition({(windowWidth - 20.f) / 2.f, (windowHeight - 20.f) / 2.f}); + m_listener.setFillColor(sf::Color::Red); + + // Load the music file + if (!m_music.openFromFile(resourcesDir() / "doodle_pop.ogg")) + sf::err() << "Failed to load " << (resourcesDir() / "doodle_pop.ogg").string() << std::endl; + + // Set the music to loop + m_music.setLoop(true); + + // Set attenuation to a nice value + m_music.setAttenuation(0.04f); + } + + void onUpdate(float /*time*/, float x, float y) override + { + m_position = {windowWidth * x - 10.f, windowHeight * y - 10.f}; + m_music.setPosition({m_position.x, m_position.y, 0.f}); + } + + void onDraw(sf::RenderTarget& target, const sf::RenderStates& states) const override + { + auto statesCopy(states); + statesCopy.transform = sf::Transform::Identity; + statesCopy.transform.translate(m_position); + + target.draw(m_listener, states); + target.draw(m_soundShape, statesCopy); + } + + void onStart() override + { + // Synchronize listener audio position with graphical position + sf::Listener::setPosition({m_listener.getPosition().x, m_listener.getPosition().y, 0.f}); + + m_music.play(); + } + + void onStop() override + { + m_music.stop(); + } + +private: + sf::CircleShape m_listener{20.f}; + sf::CircleShape m_soundShape{20.f}; + sf::Vector2f m_position; + sf::Music m_music; +}; + + +//////////////////////////////////////////////////////////// +// Pitch / Volume Effect +//////////////////////////////////////////////////////////// +class PitchVolume : public Effect +{ +public: + PitchVolume() : + Effect("Pitch / Volume"), + m_pitchText(getFont(), "Pitch: " + std::to_string(m_pitch)), + m_volumeText(getFont(), "Volume: " + std::to_string(m_volume)) + { + // Load the music file + if (!m_music.openFromFile(resourcesDir() / "doodle_pop.ogg")) + sf::err() << "Failed to load " << (resourcesDir() / "doodle_pop.ogg").string() << std::endl; + + // Set the music to loop + m_music.setLoop(true); + + // We don't care about attenuation in this effect + m_music.setAttenuation(0.f); + + // Set initial pitch + m_music.setPitch(m_pitch); + + // Set initial volume + m_music.setVolume(m_volume); + + m_pitchText.setPosition({windowWidth / 2.f - 120.f, windowHeight / 2.f - 80.f}); + m_volumeText.setPosition({windowWidth / 2.f - 120.f, windowHeight / 2.f - 30.f}); + } + + void onUpdate(float /*time*/, float x, float y) override + { + m_pitch = std::clamp(2.f * x, 0.f, 2.f); + m_volume = std::clamp(100.f * (1.f - y), 0.f, 100.f); + + m_music.setPitch(m_pitch); + m_music.setVolume(m_volume); + + m_pitchText.setString("Pitch: " + std::to_string(m_pitch)); + m_volumeText.setString("Volume: " + std::to_string(m_volume)); + } + + void onDraw(sf::RenderTarget& target, const sf::RenderStates& states) const override + { + target.draw(m_pitchText, states); + target.draw(m_volumeText, states); + } + + void onStart() override + { + // We set the listener position back to the default + // so that the music is right on top of the listener + sf::Listener::setPosition({0.f, 0.f, 0.f}); + + m_music.play(); + } + + void onStop() override + { + m_music.stop(); + } + +private: + float m_pitch{1.f}; + float m_volume{100.f}; + sf::Text m_pitchText; + sf::Text m_volumeText; + sf::Music m_music; +}; + + +//////////////////////////////////////////////////////////// +// Attenuation Effect +//////////////////////////////////////////////////////////// +class Attenuation : public Effect +{ +public: + Attenuation() : Effect("Attenuation"), m_text(getFont()) + { + m_listener.setPosition({(windowWidth - 20.f) / 2.f, (windowHeight - 20.f) / 2.f + 100.f}); + m_listener.setFillColor(sf::Color::Red); + + m_soundShape.setFillColor(sf::Color::Magenta); + + // Sound cone parameters + static constexpr auto coneHeight = windowHeight * 2.f; + static constexpr auto outerConeAngle = sf::degrees(120.f); + static constexpr auto innerConeAngle = sf::degrees(30.f); + + // Set common properties of both cones + for (auto* cone : {&m_soundConeOuter, &m_soundConeInner}) + { + cone->setPointCount(3); + cone->setPoint(0, {0.f, 0.f}); + cone->setPosition({20.f, 20.f}); + } + + m_soundConeOuter.setFillColor(sf::Color::Black); + m_soundConeInner.setFillColor(sf::Color::Cyan); + + // Make each cone based on their angle and height + static constexpr auto makeCone = [](auto& shape, const auto& angle) + { + const auto theta = sf::degrees(90.f) - (angle / 2); + const auto x = coneHeight / std::tan(theta.asRadians()); + + shape.setPoint(1, {-x, coneHeight}); + shape.setPoint(2, {x, coneHeight}); + }; + + makeCone(m_soundConeOuter, outerConeAngle); + makeCone(m_soundConeInner, innerConeAngle); + + // Load the music file + if (!m_music.openFromFile(resourcesDir() / "doodle_pop.ogg")) + sf::err() << "Failed to load " << (resourcesDir() / "doodle_pop.ogg").string() << std::endl; + + // Set the music to loop + m_music.setLoop(true); + + // Set attenuation factor + m_music.setAttenuation(m_attenuation); + + // Set direction to face "downwards" + m_music.setDirection({0.f, 1.f, 0.f}); + + // Set cone + m_music.setCone({innerConeAngle, outerConeAngle, 0.f}); + + m_text.setString( + "Attenuation factor dampens full volume of sound while within inner cone based on distance to " + "listener.\nCone outer gain determines " + "volume of sound while outside outer cone.\nWhen within outer cone, volume is linearly interpolated " + "between " + "inner and outer volumes."); + m_text.setCharacterSize(18); + m_text.setPosition({20.f, 20.f}); + } + + void onUpdate(float /*time*/, float x, float y) override + { + m_position = {windowWidth * x - 10.f, windowHeight * y - 10.f}; + m_music.setPosition({m_position.x, m_position.y, 0.f}); + } + + void onDraw(sf::RenderTarget& target, const sf::RenderStates& states) const override + { + auto statesCopy(states); + + statesCopy.transform = sf::Transform::Identity; + statesCopy.transform.translate(m_position); + + target.draw(m_soundConeOuter, statesCopy); + target.draw(m_soundConeInner, statesCopy); + target.draw(m_soundShape, statesCopy); + target.draw(m_listener, states); + target.draw(m_text, states); + } + + void onStart() override + { + // Synchronize listener audio position with graphical position + sf::Listener::setPosition({m_listener.getPosition().x, m_listener.getPosition().y, 0.f}); + + m_music.play(); + } + + void onStop() override + { + m_music.stop(); + } + +private: + sf::CircleShape m_listener{20.f}; + sf::CircleShape m_soundShape{20.f}; + sf::ConvexShape m_soundConeOuter; + sf::ConvexShape m_soundConeInner; + sf::Text m_text; + sf::Vector2f m_position; + sf::Music m_music; + + float m_attenuation{0.01f}; +}; + + +//////////////////////////////////////////////////////////// +// Tone Generator +//////////////////////////////////////////////////////////// +class Tone : public sf::SoundStream, public Effect +{ +public: + Tone() : + Effect("Tone Generator"), + m_instruction(getFont(), "Press up and down arrows to change the current wave type"), + m_currentType(getFont(), "Wave Type: Triangle"), + m_currentAmplitude(getFont(), "Amplitude: 0.05"), + m_currentFrequency(getFont(), "Frequency: 200 Hz") + { + m_instruction.setPosition({windowWidth / 2.f - 370.f, windowHeight / 2.f - 200.f}); + m_currentType.setPosition({windowWidth / 2.f - 150.f, windowHeight / 2.f - 100.f}); + m_currentAmplitude.setPosition({windowWidth / 2.f - 150.f, windowHeight / 2.f - 50.f}); + m_currentFrequency.setPosition({windowWidth / 2.f - 150.f, windowHeight / 2.f}); + + sf::SoundStream::initialize(1, sampleRate, {sf::SoundChannel::Mono}); + } + + void onUpdate(float /*time*/, float x, float y) override + { + m_amplitude = std::clamp(0.2f * (1.f - y), 0.f, 0.2f); + m_frequency = std::clamp(500.f * x, 0.f, 500.f); + + m_currentAmplitude.setString("Amplitude: " + std::to_string(m_amplitude)); + m_currentFrequency.setString("Frequency: " + std::to_string(m_frequency) + " Hz"); + } + + void onDraw(sf::RenderTarget& target, const sf::RenderStates& states) const override + { + target.draw(m_instruction, states); + target.draw(m_currentType, states); + target.draw(m_currentAmplitude, states); + target.draw(m_currentFrequency, states); + } + + void onStart() override + { + // We set the listener position back to the default + // so that the tone is right on top of the listener + sf::Listener::setPosition({0.f, 0.f, 0.f}); + + play(); + } + + void onStop() override + { + SoundStream::stop(); + } + + void onKey(sf::Keyboard::Key key) override + { + auto ticks = 0; + + if (key == sf::Keyboard::Key::Down) + ticks = 1; // Forward + else if (key == sf::Keyboard::Key::Up) + ticks = 3; // Reverse + + while (ticks--) + { + switch (m_type) + { + case Type::Sine: + m_type = Type::Square; + m_currentType.setString("Wave Type: Square"); + break; + case Type::Square: + m_type = Type::Triangle; + m_currentType.setString("Wave Type: Triangle"); + break; + case Type::Triangle: + m_type = Type::Sawtooth; + m_currentType.setString("Wave Type: Sawtooth"); + break; + case Type::Sawtooth: + m_type = Type::Sine; + m_currentType.setString("Wave Type: Sine"); + break; + } + } + } + +private: + bool onGetData(sf::SoundStream::Chunk& chunk) override + { + const auto period = 1.f / m_frequency; + + for (auto i = 0u; i < chunkSize; ++i) + { + auto value = 0.f; + + switch (m_type) + { + case Type::Sine: + { + value = m_amplitude * std::sin(2 * pi * m_frequency * m_time); + break; + } + case Type::Square: + { + value = m_amplitude * + (2 * (2 * std::floor(m_frequency * m_time) - std::floor(2 * m_frequency * m_time)) + 1); + break; + } + case Type::Triangle: + { + value = 4 * m_amplitude / period * + std::abs(std::fmod(((std::fmod((m_time - period / 4), period)) + period), period) - + period / 2) - + m_amplitude; + break; + } + case Type::Sawtooth: + { + value = m_amplitude * 2 * (m_time / period - std::floor(0.5f + m_time / period)); + break; + } + } + + m_sampleBuffer[i] = static_cast(std::lround(value * std::numeric_limits::max())); + m_time += timePerSample; + } + + chunk.sampleCount = chunkSize; + chunk.samples = m_sampleBuffer.data(); + + return true; + } + + void onSeek(sf::Time) override + { + // It doesn't make sense to seek in a tone generator + } + + enum class Type + { + Sine, + Square, + Triangle, + Sawtooth + }; + + static constexpr unsigned int sampleRate{44100}; + static constexpr std::size_t chunkSize{sampleRate / 100}; + static constexpr float timePerSample{1.f / static_cast(sampleRate)}; + + std::vector m_sampleBuffer = std::vector(chunkSize, 0); + Type m_type{Type::Triangle}; + float m_amplitude{0.05f}; + float m_frequency{220}; + float m_time{}; + + sf::Text m_instruction; + sf::Text m_currentType; + sf::Text m_currentAmplitude; + sf::Text m_currentFrequency; +}; + + +//////////////////////////////////////////////////////////// +// Dopper Shift Effect +//////////////////////////////////////////////////////////// +class Doppler : public sf::SoundStream, public Effect +{ +public: + Doppler() : + Effect("Doppler Shift"), + m_currentVelocity(getFont(), "Velocity: " + std::to_string(m_velocity)), + m_currentFactor(getFont(), "Doppler Factor: " + std::to_string(m_factor)) + { + m_listener.setPosition({(windowWidth - 20.f) / 2.f, (windowHeight - 20.f) / 2.f}); + m_listener.setFillColor(sf::Color::Red); + + m_position.y = (windowHeight - 20.f) / 2.f - 40.f; + + m_currentVelocity.setPosition({windowWidth / 2.f - 150.f, windowHeight * 3.f / 4.f - 50.f}); + m_currentFactor.setPosition({windowWidth / 2.f - 150.f, windowHeight * 3.f / 4.f}); + + // Set attenuation to a nice value + setAttenuation(0.05f); + + sf::SoundStream::initialize(1, sampleRate, {sf::SoundChannel::Mono}); + } + + void onUpdate(float time, float x, float y) override + { + m_velocity = std::clamp(150.f * (1.f - y), 0.f, 150.f); + m_factor = std::clamp(x, 0.f, 1.f); + + m_currentVelocity.setString("Velocity: " + std::to_string(m_velocity)); + m_currentFactor.setString("Doppler Factor: " + std::to_string(m_factor)); + + m_position.x = std::fmod(time, 8.f) * windowWidth / 8.f; + + setPosition({m_position.x, m_position.y, 0.f}); + setVelocity({m_velocity, 0.f, 0.f}); + setDopplerFactor(m_factor); + } + + void onDraw(sf::RenderTarget& target, const sf::RenderStates& states) const override + { + auto statesCopy(states); + statesCopy.transform = sf::Transform::Identity; + statesCopy.transform.translate(m_position - sf::Vector2f({20.f, 0.f})); + + target.draw(m_listener, states); + target.draw(m_soundShape, statesCopy); + target.draw(m_currentVelocity, states); + target.draw(m_currentFactor, states); + } + + void onStart() override + { + // Synchronize listener audio position with graphical position + sf::Listener::setPosition({m_listener.getPosition().x, m_listener.getPosition().y, 0.f}); + + play(); + } + + void onStop() override + { + SoundStream::stop(); + } + +private: + bool onGetData(sf::SoundStream::Chunk& chunk) override + { + const auto period = 1.f / m_frequency; + + for (auto i = 0u; i < chunkSize; ++i) + { + auto value = m_amplitude * 2 * (m_time / period - std::floor(0.5f + m_time / period)); + + m_sampleBuffer[i] = static_cast(std::lround(value * std::numeric_limits::max())); + m_time += timePerSample; + } + + chunk.sampleCount = chunkSize; + chunk.samples = m_sampleBuffer.data(); + + return true; + } + + void onSeek(sf::Time) override + { + // It doesn't make sense to seek in a tone generator + } + + static constexpr unsigned int sampleRate{44100}; + static constexpr std::size_t chunkSize{sampleRate / 100}; + static constexpr float timePerSample{1.f / static_cast(sampleRate)}; + + std::vector m_sampleBuffer = std::vector(chunkSize, 0); + float m_amplitude{0.05f}; + float m_frequency{220}; + float m_time{}; + + float m_velocity{0.f}; + float m_factor{1.f}; + sf::CircleShape m_listener{20.f}; + sf::CircleShape m_soundShape{20.f}; + sf::Vector2f m_position; + sf::Text m_currentVelocity; + sf::Text m_currentFactor; +}; + + +//////////////////////////////////////////////////////////// +/// Entry point of application +/// +/// \return Application exit code +/// +//////////////////////////////////////////////////////////// +int main() +{ + // Create the main window + sf::RenderWindow window(sf::VideoMode({windowWidth, windowHeight}), + "SFML Sound Effects", + sf::Style::Titlebar | sf::Style::Close); + window.setVerticalSyncEnabled(true); + + // Load the application font and pass it to the Effect class + sf::Font font; + if (!font.loadFromFile(resourcesDir() / "tuffy.ttf")) + return EXIT_FAILURE; + Effect::setFont(font); + + // Create the effects + Surround surroundEffect; + PitchVolume pitchVolumeEffect; + Attenuation attenuationEffect; + Tone toneEffect; + Doppler dopplerEffect; + + const std::array effects{ + &surroundEffect, + &pitchVolumeEffect, + &attenuationEffect, + &toneEffect, + &dopplerEffect, + }; + + std::size_t current = 0; + + effects[current]->start(); + + // Create the messages background + sf::Texture textBackgroundTexture; + if (!textBackgroundTexture.loadFromFile(resourcesDir() / "text-background.png")) + return EXIT_FAILURE; + sf::Sprite textBackground(textBackgroundTexture); + textBackground.setPosition({0.f, 520.f}); + textBackground.setColor(sf::Color(255, 255, 255, 200)); + + // Create the description text + sf::Text description(font, "Current effect: " + effects[current]->getName(), 20); + description.setPosition({10.f, 530.f}); + description.setFillColor(sf::Color(80, 80, 80)); + + // Create the instructions text + sf::Text instructions(font, "Press left and right arrows to change the current effect", 20); + instructions.setPosition({280.f, 555.f}); + instructions.setFillColor(sf::Color(80, 80, 80)); + + // Start the game loop + const sf::Clock clock; + while (window.isOpen()) + { + // Process events + for (sf::Event event; window.pollEvent(event);) + { + // Close window: exit + if (event.type == sf::Event::Closed) + window.close(); + + if (event.type == sf::Event::KeyPressed) + { + switch (event.key.code) + { + // Escape key: exit + case sf::Keyboard::Key::Escape: + window.close(); + break; + + // Left arrow key: previous effect + case sf::Keyboard::Key::Left: + effects[current]->stop(); + if (current == 0) + current = effects.size() - 1; + else + --current; + effects[current]->start(); + description.setString("Current effect: " + effects[current]->getName()); + break; + + // Right arrow key: next effect + case sf::Keyboard::Key::Right: + effects[current]->stop(); + if (current == effects.size() - 1) + current = 0; + else + ++current; + effects[current]->start(); + description.setString("Current effect: " + effects[current]->getName()); + break; + + default: + effects[current]->handleKey(event.key.code); + break; + } + } + } + + // Update the current example + const auto [x, y] = sf::Vector2f(sf::Mouse::getPosition(window)).cwiseDiv(sf::Vector2f(window.getSize())); + effects[current]->update(clock.getElapsedTime().asSeconds(), x, y); + + // Clear the window + window.clear(sf::Color(50, 50, 50)); + + // Draw the current example + window.draw(*effects[current]); + + // Draw the text + window.draw(textBackground); + window.draw(instructions); + window.draw(description); + + // Finally, display the rendered frame on screen + window.display(); + } + + // Stop effect so that tone generators don't have to keep generating data while being destroyed + effects[current]->stop(); +} diff --git a/examples/sound_effects/resources/doodle_pop.ogg b/examples/sound_effects/resources/doodle_pop.ogg new file mode 100644 index 000000000..555ea3480 Binary files /dev/null and b/examples/sound_effects/resources/doodle_pop.ogg differ diff --git a/examples/sound_effects/resources/text-background.png b/examples/sound_effects/resources/text-background.png new file mode 100644 index 000000000..c86e9b6c0 Binary files /dev/null and b/examples/sound_effects/resources/text-background.png differ diff --git a/examples/sound_effects/resources/tuffy.ttf b/examples/sound_effects/resources/tuffy.ttf new file mode 100644 index 000000000..8ea647090 Binary files /dev/null and b/examples/sound_effects/resources/tuffy.ttf differ