Merge branch 'feat/sound-effects' — efectes d'àudio (eco/reverb) i reverb d'INTRO
This commit is contained in:
commit
7d725f99f4
18 files changed
+635
-2
No files matched your search
@@ -55,7 +55,9 @@ set(APP_SOURCES
|
||||
source/core/system/director.cpp
|
||||
source/core/audio/audio.cpp
|
||||
source/core/audio/audio_adapter.cpp
|
||||
source/core/audio/audio_effects.cpp
|
||||
source/core/audio/jail_audio.cpp
|
||||
source/core/audio/sound_effects_config.cpp
|
||||
source/core/locale/locale.cpp
|
||||
source/core/ui/ui_config.cpp
|
||||
source/core/input/key_config.cpp
|
||||
|
||||
@@ -13,6 +13,11 @@ room_intro:
|
||||
line_gap_px: -4 # px extra entre les dues línies (piràmide / sala)
|
||||
player_spawn_delay_ms: 2400 # ms des de l'inici d'INTRO al spawn aeri.
|
||||
player2_spawn_delay_ms: 2700 # només mode BOTH. Trenca la simultaneïtat.
|
||||
# Preset de reverb (catàleg a `data/sounds.yaml`) aplicat al primer
|
||||
# LAND de cada jugador durant la cinemàtica d'entrada. La sala dona
|
||||
# "més detall" sonor a l'INTRO; els salts del joc i els respawns
|
||||
# post-mort cauen al so sec. Buit o absent = sense reverb.
|
||||
land_reverb: stone_hall
|
||||
|
||||
room_outro:
|
||||
text_enter_ms: 400
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Presets d'efectes per a sons puntuals. Les crides
|
||||
# Audio::playSoundWithEcho(name, preset, group) i
|
||||
# Audio::playSoundWithReverb(name, preset, group) referencien aquests
|
||||
# presets pel seu nom (camp `preset`). Si el nom no existeix, el so es
|
||||
# reprodueix sec amb un avís a stderr.
|
||||
#
|
||||
# Camps:
|
||||
# echo:
|
||||
# delay_ms → temps fins al primer rebot, en mil·lisegons.
|
||||
# feedback → reinjecció (0..0.95). >0.7 sona "metàl·lic".
|
||||
# wet → mescla del senyal humit (0..1). 1 = només eco.
|
||||
# reverb:
|
||||
# room_size → mida percebuda de la sala (0..1).
|
||||
# damping → atenuació de freqüències altes en cada rebot (0..1).
|
||||
# wet → mescla humida (0..1).
|
||||
#
|
||||
# Els processadors limiten la cua a 800 ms (echo) i 1500 ms (reverb).
|
||||
|
||||
echo:
|
||||
cave:
|
||||
delay_ms: 220
|
||||
feedback: 0.45
|
||||
wet: 0.35
|
||||
short:
|
||||
delay_ms: 80
|
||||
feedback: 0.30
|
||||
wet: 0.25
|
||||
|
||||
reverb:
|
||||
stone_hall:
|
||||
room_size: 0.78
|
||||
damping: 0.50
|
||||
wet: 0.40
|
||||
small_room:
|
||||
room_size: 0.40
|
||||
damping: 0.70
|
||||
wet: 0.30
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "core/audio/audio_adapter.hpp" // Per a AudioResource::getMusic/getSound
|
||||
#include "core/audio/jail_audio.hpp" // Per a Ja::* (motor jailgames)
|
||||
#include "core/audio/sound_effects_config.hpp" // Per a SoundEffectsConfig
|
||||
#include "utils/defaults.hpp" // Per a Defaults::Audio::FREQUENCY
|
||||
|
||||
// Invariant compile-time: tots els valors d'Audio::Group han de cabre als slots
|
||||
@@ -128,6 +129,47 @@ void Audio::playSound(Ja::Sound* sound, Group group) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reprodueix un so processat per un eco definit a sounds.yaml. Si el preset no
|
||||
// existeix o l'engine retorna -1 (cap de canals d'efecte plé), cau a playSound
|
||||
// sec — l'usuari sent el so encara que la cua no s'apliqui.
|
||||
// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_
|
||||
void Audio::playSoundWithEcho(const std::string& name, const std::string& preset_name, Group group) {
|
||||
if (!sound_enabled_) { return; }
|
||||
auto* sound = AudioResource::getSound(name);
|
||||
if (sound == nullptr) { return; }
|
||||
|
||||
const auto* params = SoundEffectsConfig::get().findEcho(preset_name);
|
||||
if (params == nullptr) {
|
||||
std::fprintf(stderr, "Audio: preset d'eco '%s' desconegut — so '%s' es reprodueix sec\n", preset_name.c_str(), name.c_str());
|
||||
engine_->playSound(sound, 0, static_cast<int>(group));
|
||||
return;
|
||||
}
|
||||
|
||||
if (engine_->playSoundWithEcho(sound, *params, static_cast<int>(group)) < 0) {
|
||||
engine_->playSound(sound, 0, static_cast<int>(group));
|
||||
}
|
||||
}
|
||||
|
||||
// Reprodueix un so processat per un reverb definit a sounds.yaml. Mateix
|
||||
// fallback que playSoundWithEcho.
|
||||
// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_
|
||||
void Audio::playSoundWithReverb(const std::string& name, const std::string& preset_name, Group group) {
|
||||
if (!sound_enabled_) { return; }
|
||||
auto* sound = AudioResource::getSound(name);
|
||||
if (sound == nullptr) { return; }
|
||||
|
||||
const auto* params = SoundEffectsConfig::get().findReverb(preset_name);
|
||||
if (params == nullptr) {
|
||||
std::fprintf(stderr, "Audio: preset de reverb '%s' desconegut — so '%s' es reprodueix sec\n", preset_name.c_str(), name.c_str());
|
||||
engine_->playSound(sound, 0, static_cast<int>(group));
|
||||
return;
|
||||
}
|
||||
|
||||
if (engine_->playSoundWithReverb(sound, *params, static_cast<int>(group)) < 0) {
|
||||
engine_->playSound(sound, 0, static_cast<int>(group));
|
||||
}
|
||||
}
|
||||
|
||||
// Atura tots els sons
|
||||
// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const
|
||||
void Audio::stopAllSounds() {
|
||||
|
||||
@@ -80,6 +80,12 @@ class Audio {
|
||||
// --- Control de sons ---
|
||||
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja)
|
||||
void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja)
|
||||
// Reprodueix un so processat per un efecte definit a data/sounds.yaml
|
||||
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix
|
||||
// o el motor està al cap de canals amb efecte, fa fallback a playSound
|
||||
// sec — l'usuari sent el so igualment, sense la cua.
|
||||
void playSoundWithEcho(const std::string& name, const std::string& preset_name, Group group = Group::GAME);
|
||||
void playSoundWithReverb(const std::string& name, const std::string& preset_name, Group group = Group::GAME);
|
||||
void stopAllSounds(); // Aturar tots els sons (muta globals de Ja)
|
||||
|
||||
// --- Control de volum (API interna: float 0.0..1.0) ---
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#include "core/audio/audio_effects.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/jail_audio.hpp"
|
||||
|
||||
namespace AudioEffects {
|
||||
|
||||
namespace {
|
||||
|
||||
// --- Caps de cua ---
|
||||
constexpr float ECHO_TAIL_MS = 800.0F;
|
||||
constexpr float REVERB_TAIL_MS = 1500.0F;
|
||||
|
||||
// --- Constants Freeverb ---
|
||||
// Delays de comb i allpass tunats per a 44.1 kHz; els reescalem per
|
||||
// freqüència real de la font.
|
||||
constexpr int COMB_REFERENCE_RATE = 44100;
|
||||
constexpr std::array<int, 8> COMB_DELAYS_L = {1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617};
|
||||
constexpr std::array<int, 4> ALLPASS_DELAYS_L = {556, 441, 341, 225};
|
||||
constexpr int STEREO_SPREAD = 23;
|
||||
|
||||
// Mapeig de Schroeder/Dattorro/Freeverb estàndard.
|
||||
constexpr float FIXED_GAIN = 0.015F;
|
||||
constexpr float SCALE_ROOM = 0.28F;
|
||||
constexpr float OFFSET_ROOM = 0.7F;
|
||||
constexpr float SCALE_DAMP = 0.4F;
|
||||
|
||||
// --- Decodificació a float -1..1 ---
|
||||
// Suporta U8/S16, mono/estèreo. Mono es duplica a L i R (la cadena
|
||||
// d'efectes treballa sempre amb dos canals per simplicitat).
|
||||
auto decodeToStereoFloat(const Ja::Sound& src, std::vector<float>& left, std::vector<float>& right) -> bool {
|
||||
const auto& spec = src.spec;
|
||||
const Uint8* buf = src.buffer.get();
|
||||
if (buf == nullptr || src.length == 0) { return false; }
|
||||
|
||||
int bytes_per_sample = 0;
|
||||
if (spec.format == SDL_AUDIO_S16) {
|
||||
bytes_per_sample = 2;
|
||||
} else if (spec.format == SDL_AUDIO_U8) {
|
||||
bytes_per_sample = 1;
|
||||
} else {
|
||||
std::cerr << "AudioEffects: format de so no suportat (només U8 o S16)\n";
|
||||
return false;
|
||||
}
|
||||
if (spec.channels < 1 || spec.channels > 2) {
|
||||
std::cerr << "AudioEffects: el so ha de ser mono o estèreo\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::size_t TOTAL_FRAMES = src.length / static_cast<std::size_t>(bytes_per_sample * spec.channels);
|
||||
left.resize(TOTAL_FRAMES);
|
||||
right.resize(TOTAL_FRAMES);
|
||||
|
||||
for (std::size_t i = 0; i < TOTAL_FRAMES; ++i) {
|
||||
float sample_l = 0.0F;
|
||||
float sample_r = 0.0F;
|
||||
if (spec.format == SDL_AUDIO_S16) {
|
||||
const auto* p = reinterpret_cast<const std::int16_t*>(buf + (i * spec.channels * 2));
|
||||
sample_l = static_cast<float>(p[0]) / 32768.0F;
|
||||
sample_r = (spec.channels == 2) ? static_cast<float>(p[1]) / 32768.0F : sample_l;
|
||||
} else { // U8
|
||||
const Uint8* p = buf + (i * spec.channels);
|
||||
sample_l = (static_cast<float>(p[0]) - 128.0F) / 128.0F;
|
||||
sample_r = (spec.channels == 2) ? (static_cast<float>(p[1]) - 128.0F) / 128.0F : sample_l;
|
||||
}
|
||||
left[i] = sample_l;
|
||||
right[i] = sample_r;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Empaqueta dos canals float (-1..1) a S16 entrellaçat.
|
||||
void encodeStereoS16(const std::vector<float>& left, const std::vector<float>& right, std::vector<std::uint8_t>& out) {
|
||||
const std::size_t LEN = left.size();
|
||||
out.resize(LEN * 2 * sizeof(std::int16_t));
|
||||
auto* dst = reinterpret_cast<std::int16_t*>(out.data());
|
||||
for (std::size_t i = 0; i < LEN; ++i) {
|
||||
const float L = std::clamp(left[i], -1.0F, 1.0F);
|
||||
const float R = std::clamp(right[i], -1.0F, 1.0F);
|
||||
dst[(i * 2) + 0] = static_cast<std::int16_t>(std::lround(L * 32767.0F));
|
||||
dst[(i * 2) + 1] = static_cast<std::int16_t>(std::lround(R * 32767.0F));
|
||||
}
|
||||
}
|
||||
|
||||
// Reescala un delay de la taula de Freeverb per a la freqüència real.
|
||||
auto scaledDelay(int reference_delay, int rate) -> int {
|
||||
const long SCALED = std::lround(static_cast<double>(reference_delay) * static_cast<double>(rate) / static_cast<double>(COMB_REFERENCE_RATE));
|
||||
return std::max(1, static_cast<int>(SCALED));
|
||||
}
|
||||
|
||||
// --- Filtres bàsics ---
|
||||
struct Comb {
|
||||
std::vector<float> buf;
|
||||
std::size_t idx{0};
|
||||
float feedback{0.0F};
|
||||
float damp1{0.0F};
|
||||
float damp2{1.0F};
|
||||
float store{0.0F};
|
||||
|
||||
void init(int delay, float fb, float damping) {
|
||||
buf.assign(static_cast<std::size_t>(delay), 0.0F);
|
||||
idx = 0;
|
||||
feedback = fb;
|
||||
damp1 = damping;
|
||||
damp2 = 1.0F - damping;
|
||||
store = 0.0F;
|
||||
}
|
||||
auto tick(float in) -> float {
|
||||
const float OUT = buf[idx];
|
||||
store = (OUT * damp2) + (store * damp1);
|
||||
buf[idx] = in + (store * feedback);
|
||||
idx = (idx + 1) % buf.size();
|
||||
return OUT;
|
||||
}
|
||||
};
|
||||
|
||||
struct Allpass {
|
||||
std::vector<float> buf;
|
||||
std::size_t idx{0};
|
||||
|
||||
void init(int delay) {
|
||||
buf.assign(static_cast<std::size_t>(delay), 0.0F);
|
||||
idx = 0;
|
||||
}
|
||||
auto tick(float in) -> float {
|
||||
const float BUFOUT = buf[idx];
|
||||
const float OUT = -in + BUFOUT;
|
||||
buf[idx] = in + (BUFOUT * 0.5F);
|
||||
idx = (idx + 1) % buf.size();
|
||||
return OUT;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
auto applyEcho(const Ja::Sound& src, const Ja::EchoParams& params) -> std::optional<ProcessedSound> {
|
||||
std::vector<float> left;
|
||||
std::vector<float> right;
|
||||
if (!decodeToStereoFloat(src, left, right)) { return std::nullopt; }
|
||||
|
||||
const int RATE = src.spec.freq;
|
||||
const int DELAY_SAMPLES = std::max(1, static_cast<int>(std::lround(params.delay_ms * 0.001F * static_cast<float>(RATE))));
|
||||
const auto TAIL_SAMPLES = static_cast<std::size_t>(std::lround(ECHO_TAIL_MS * 0.001F * static_cast<float>(RATE)));
|
||||
|
||||
const float FEEDBACK = std::clamp(params.feedback, 0.0F, 0.95F);
|
||||
const float WET = std::clamp(params.wet, 0.0F, 1.0F);
|
||||
const float DRY = 1.0F - WET;
|
||||
|
||||
const std::size_t INPUT_LEN = left.size();
|
||||
const std::size_t TOTAL_LEN = INPUT_LEN + TAIL_SAMPLES;
|
||||
|
||||
std::vector<float> ring_l(static_cast<std::size_t>(DELAY_SAMPLES), 0.0F);
|
||||
std::vector<float> ring_r(static_cast<std::size_t>(DELAY_SAMPLES), 0.0F);
|
||||
std::size_t cursor = 0;
|
||||
|
||||
std::vector<float> out_l(TOTAL_LEN);
|
||||
std::vector<float> out_r(TOTAL_LEN);
|
||||
|
||||
for (std::size_t i = 0; i < TOTAL_LEN; ++i) {
|
||||
const float IN_L = (i < INPUT_LEN) ? left[i] : 0.0F;
|
||||
const float IN_R = (i < INPUT_LEN) ? right[i] : 0.0F;
|
||||
|
||||
const float DELAYED_L = ring_l[cursor];
|
||||
const float DELAYED_R = ring_r[cursor];
|
||||
|
||||
out_l[i] = (DRY * IN_L) + (WET * DELAYED_L);
|
||||
out_r[i] = (DRY * IN_R) + (WET * DELAYED_R);
|
||||
|
||||
ring_l[cursor] = IN_L + (DELAYED_L * FEEDBACK);
|
||||
ring_r[cursor] = IN_R + (DELAYED_R * FEEDBACK);
|
||||
cursor = (cursor + 1) % static_cast<std::size_t>(DELAY_SAMPLES);
|
||||
}
|
||||
|
||||
ProcessedSound result;
|
||||
result.spec = SDL_AudioSpec{.format = SDL_AUDIO_S16, .channels = 2, .freq = RATE};
|
||||
encodeStereoS16(out_l, out_r, result.bytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
auto applyReverb(const Ja::Sound& src, const Ja::ReverbParams& params) -> std::optional<ProcessedSound> {
|
||||
std::vector<float> left;
|
||||
std::vector<float> right;
|
||||
if (!decodeToStereoFloat(src, left, right)) { return std::nullopt; }
|
||||
|
||||
const int RATE = src.spec.freq;
|
||||
const auto TAIL_SAMPLES = static_cast<std::size_t>(std::lround(REVERB_TAIL_MS * 0.001F * static_cast<float>(RATE)));
|
||||
|
||||
const float ROOM_SIZE = std::clamp(params.room_size, 0.0F, 1.0F);
|
||||
const float DAMPING = std::clamp(params.damping, 0.0F, 1.0F);
|
||||
const float WET = std::clamp(params.wet, 0.0F, 1.0F);
|
||||
const float DRY = 1.0F - WET;
|
||||
|
||||
const float FEEDBACK = (ROOM_SIZE * SCALE_ROOM) + OFFSET_ROOM; // 0.7..0.98
|
||||
const float DAMP1 = DAMPING * SCALE_DAMP; // 0..0.4
|
||||
|
||||
// Inicialitza els 8 comb filters per cada canal i els 4 allpass.
|
||||
std::array<Comb, 8> comb_l;
|
||||
std::array<Comb, 8> comb_r;
|
||||
for (std::size_t i = 0; i < COMB_DELAYS_L.size(); ++i) {
|
||||
comb_l[i].init(scaledDelay(COMB_DELAYS_L[i], RATE), FEEDBACK, DAMP1);
|
||||
comb_r[i].init(scaledDelay(COMB_DELAYS_L[i] + STEREO_SPREAD, RATE), FEEDBACK, DAMP1);
|
||||
}
|
||||
std::array<Allpass, 4> allpass_l;
|
||||
std::array<Allpass, 4> allpass_r;
|
||||
for (std::size_t i = 0; i < ALLPASS_DELAYS_L.size(); ++i) {
|
||||
allpass_l[i].init(scaledDelay(ALLPASS_DELAYS_L[i], RATE));
|
||||
allpass_r[i].init(scaledDelay(ALLPASS_DELAYS_L[i] + STEREO_SPREAD, RATE));
|
||||
}
|
||||
|
||||
const std::size_t INPUT_LEN = left.size();
|
||||
const std::size_t TOTAL_LEN = INPUT_LEN + TAIL_SAMPLES;
|
||||
std::vector<float> out_l(TOTAL_LEN);
|
||||
std::vector<float> out_r(TOTAL_LEN);
|
||||
|
||||
for (std::size_t i = 0; i < TOTAL_LEN; ++i) {
|
||||
const float IN_L = (i < INPUT_LEN) ? left[i] : 0.0F;
|
||||
const float IN_R = (i < INPUT_LEN) ? right[i] : 0.0F;
|
||||
const float MONO_INPUT = (IN_L + IN_R) * FIXED_GAIN;
|
||||
|
||||
// 8 comb filters en paral·lel, sumats.
|
||||
float wet_l = 0.0F;
|
||||
float wet_r = 0.0F;
|
||||
for (std::size_t k = 0; k < comb_l.size(); ++k) {
|
||||
wet_l += comb_l[k].tick(MONO_INPUT);
|
||||
wet_r += comb_r[k].tick(MONO_INPUT);
|
||||
}
|
||||
// 4 allpass en sèrie.
|
||||
for (std::size_t k = 0; k < allpass_l.size(); ++k) {
|
||||
wet_l = allpass_l[k].tick(wet_l);
|
||||
wet_r = allpass_r[k].tick(wet_r);
|
||||
}
|
||||
|
||||
out_l[i] = (DRY * IN_L) + (WET * wet_l);
|
||||
out_r[i] = (DRY * IN_R) + (WET * wet_r);
|
||||
}
|
||||
|
||||
ProcessedSound result;
|
||||
result.spec = SDL_AudioSpec{.format = SDL_AUDIO_S16, .channels = 2, .freq = RATE};
|
||||
encodeStereoS16(out_l, out_r, result.bytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace AudioEffects
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
// Forward-declaració per no incloure jail_audio.hpp (cicle d'inclusió: aquest
|
||||
// header viu sota els params declarats a jail_audio.hpp, i alhora jail_audio
|
||||
// usa applyEcho/applyReverb).
|
||||
namespace Ja {
|
||||
struct Sound;
|
||||
struct EchoParams;
|
||||
struct ReverbParams;
|
||||
} // namespace Ja
|
||||
|
||||
// Processadors d'efectes per a sons puntuals. Reben un Ja::Sound (qualsevol
|
||||
// format suportat pel decodificador WAV: U8/S16, mono o estèreo) i tornen un
|
||||
// buffer PCM en S16 + el seu spec, llest per empenyer a un SDL_AudioStream.
|
||||
//
|
||||
// El buffer de sortida inclou la cua (decay) generada per l'efecte: per al
|
||||
// reverb, fins a 1500 ms; per a l'eco, fins a 800 ms. Aquests caps eviten
|
||||
// allargar indefinidament la reproducció quan els paràmetres reinjecten molt.
|
||||
//
|
||||
// Si el format del so d'origen no es pot processar, retornen std::nullopt
|
||||
// (el caller ha de fer fallback a reproducció seca).
|
||||
namespace AudioEffects {
|
||||
|
||||
struct ProcessedSound {
|
||||
std::vector<std::uint8_t> bytes; // PCM S16 entrellaçat (LRLRLR... si stereo)
|
||||
SDL_AudioSpec spec; // Format/canals/freqüència del buffer
|
||||
};
|
||||
|
||||
[[nodiscard]] auto applyEcho(const Ja::Sound& src, const Ja::EchoParams& params) -> std::optional<ProcessedSound>;
|
||||
[[nodiscard]] auto applyReverb(const Ja::Sound& src, const Ja::ReverbParams& params) -> std::optional<ProcessedSound>;
|
||||
|
||||
} // namespace AudioEffects
|
||||
@@ -4,6 +4,10 @@
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/audio_effects.hpp"
|
||||
|
||||
// Només declaracions de stb_vorbis: STB_VORBIS_HEADER_ONLY omet el bloc
|
||||
// d'implementació. Les definicions les aporta source/external/stb_vorbis_impl.cpp
|
||||
@@ -397,13 +401,17 @@ namespace Ja {
|
||||
}
|
||||
|
||||
void Engine::stopChannel(const int channel) {
|
||||
forEachTargetChannel(channel, [](Channel& ch) {
|
||||
forEachTargetChannel(channel, [this](Channel& ch) {
|
||||
if (ch.state != ChannelState::FREE) {
|
||||
if (ch.stream != nullptr) { SDL_DestroyAudioStream(ch.stream); }
|
||||
ch.stream = nullptr;
|
||||
ch.state = ChannelState::FREE;
|
||||
ch.pos = 0;
|
||||
ch.sound = nullptr;
|
||||
if (ch.has_effect) {
|
||||
ch.has_effect = false;
|
||||
if (effect_channels_active_ > 0) { --effect_channels_active_; }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -438,6 +446,55 @@ namespace Ja {
|
||||
}
|
||||
}
|
||||
|
||||
auto Engine::playProcessedOnFreeChannel(const std::vector<std::uint8_t>& bytes, const SDL_AudioSpec& spec, const int group) -> int {
|
||||
// El cap de canals amb efecte es valida abans de reservar slot —
|
||||
// així evitem crear i destruir un stream només per descartar el play.
|
||||
if (effect_channels_active_ >= MAX_EFFECT_CHANNELS) { return -1; }
|
||||
|
||||
int channel = 0;
|
||||
while (channel < MAX_SIMULTANEOUS_CHANNELS && channels_[channel].state != ChannelState::FREE) { ++channel; }
|
||||
if (channel == MAX_SIMULTANEOUS_CHANNELS) { channel = 0; }
|
||||
|
||||
stopChannel(channel);
|
||||
|
||||
// El stream es crea contra l'spec del buffer processat (S16, ...)
|
||||
// perquè SDL faci el resampling cap a audio_spec_ del device.
|
||||
channels_[channel].stream = SDL_CreateAudioStream(&spec, &audio_spec_);
|
||||
if (channels_[channel].stream == nullptr) {
|
||||
std::fprintf(stderr, "Ja::Engine::playProcessedOnFreeChannel: Failed to create audio stream!\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
channels_[channel].sound = nullptr; // El buffer no és propietat de cap Ja::Sound.
|
||||
channels_[channel].times = 0;
|
||||
channels_[channel].pos = 0;
|
||||
const int CLAMPED_GROUP = (group >= 0 && group < MAX_GROUPS) ? group : 0;
|
||||
channels_[channel].group = CLAMPED_GROUP;
|
||||
channels_[channel].state = ChannelState::PLAYING;
|
||||
channels_[channel].has_effect = true;
|
||||
++effect_channels_active_;
|
||||
|
||||
SDL_PutAudioStreamData(channels_[channel].stream, bytes.data(), static_cast<int>(bytes.size()));
|
||||
SDL_SetAudioStreamGain(channels_[channel].stream, sound_volume_[CLAMPED_GROUP]);
|
||||
SDL_BindAudioStream(sdl_audio_device_, channels_[channel].stream);
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
auto Engine::playSoundWithEcho(const Sound* sound, const EchoParams& params, const int group) -> int {
|
||||
if (sound == nullptr) { return -1; }
|
||||
auto processed = AudioEffects::applyEcho(*sound, params);
|
||||
if (!processed) { return -1; }
|
||||
return playProcessedOnFreeChannel(processed->bytes, processed->spec, group);
|
||||
}
|
||||
|
||||
auto Engine::playSoundWithReverb(const Sound* sound, const ReverbParams& params, const int group) -> int {
|
||||
if (sound == nullptr) { return -1; }
|
||||
auto processed = AudioEffects::applyReverb(*sound, params);
|
||||
if (!processed) { return -1; }
|
||||
return playProcessedOnFreeChannel(processed->bytes, processed->spec, group);
|
||||
}
|
||||
|
||||
// --- Factories i destructors (permanents) ---
|
||||
|
||||
auto loadMusic(const Uint8* buffer, Uint32 length) -> Music* {
|
||||
|
||||
@@ -48,6 +48,26 @@ namespace Ja {
|
||||
// --- Constants ---
|
||||
inline constexpr int MAX_SIMULTANEOUS_CHANNELS = 20;
|
||||
inline constexpr int MAX_GROUPS = 2;
|
||||
// Cap superior de canals que poden estar simultàniament reproduint un so
|
||||
// amb efecte (eco/reverb). Si està al límit, les noves crides amb efecte
|
||||
// cauen al camí sec — l'usuari sent el so igualment, sense la cua.
|
||||
inline constexpr int MAX_EFFECT_CHANNELS = 4;
|
||||
|
||||
// --- Paràmetres d'efectes ---
|
||||
// Els camps els fixa el caller (Audio) llegint sounds.yaml; el motor només
|
||||
// els passa a AudioEffects::applyEcho/applyReverb. Els defaults són
|
||||
// sensats però els presets els sobreescriuen.
|
||||
struct EchoParams {
|
||||
float delay_ms{220.0F}; // Temps fins al primer rebot.
|
||||
float feedback{0.45F}; // Reinjecció (0..0.95).
|
||||
float wet{0.35F}; // Mescla humida (0..1).
|
||||
};
|
||||
|
||||
struct ReverbParams {
|
||||
float room_size{0.7F}; // Mida percebuda (0..1).
|
||||
float damping{0.5F}; // Atenuació d'aguts per rebot (0..1).
|
||||
float wet{0.4F}; // Mescla humida (0..1).
|
||||
};
|
||||
|
||||
// Spec de fallback del dispositiu. S'aplica abans que l'Engine s'iniciï i
|
||||
// com a valor inicial de Sound/Music. L'spec real d'ús l'imposa el ctor
|
||||
@@ -72,6 +92,10 @@ namespace Ja {
|
||||
int times{0};
|
||||
int group{0};
|
||||
ChannelState state{ChannelState::FREE};
|
||||
// Marca si aquest canal va arrencar amb so processat per un efecte.
|
||||
// El motor compta canals actius amb efecte per fer complir
|
||||
// MAX_EFFECT_CHANNELS i alliberar el comptador en parar.
|
||||
bool has_effect{false};
|
||||
};
|
||||
|
||||
struct Music {
|
||||
@@ -147,6 +171,12 @@ namespace Ja {
|
||||
// --- So ---
|
||||
auto playSound(Sound* sound, int loop = 0, int group = 0) -> int;
|
||||
auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int;
|
||||
// Reproducció amb so processat per un efecte. Retorna el canal
|
||||
// assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS).
|
||||
// El sound original només s'usa per consultar el spec/buffer; el
|
||||
// canal manipula el buffer ja processat (no reapunta a `sound`).
|
||||
auto playSoundWithEcho(const Sound* sound, const EchoParams& params, int group = 0) -> int;
|
||||
auto playSoundWithReverb(const Sound* sound, const ReverbParams& params, int group = 0) -> int;
|
||||
void pauseChannel(int channel);
|
||||
void resumeChannel(int channel);
|
||||
void stopChannel(int channel);
|
||||
@@ -161,6 +191,10 @@ namespace Ja {
|
||||
void updateIncomingFade();
|
||||
void updateCurrentMusic();
|
||||
void updateSoundChannels();
|
||||
// Empenta un buffer ja processat (S16) a un canal lliure i el deixa
|
||||
// sonar sense bucle. Camí comú dels dos overloads playSoundWith*.
|
||||
// Retorna el canal o -1 si no queden slots.
|
||||
auto playProcessedOnFreeChannel(const std::vector<std::uint8_t>& bytes, const SDL_AudioSpec& spec, int group) -> int;
|
||||
|
||||
template <typename Fn>
|
||||
void forEachTargetChannel(int channel, Fn&& fn);
|
||||
@@ -174,6 +208,9 @@ namespace Ja {
|
||||
OutgoingMusic outgoing_music_;
|
||||
FadeState incoming_fade_;
|
||||
std::function<void()> on_music_ended_;
|
||||
// Comptador derivat de Channel::has_effect — evita haver-lo de
|
||||
// recalcular cada vegada que algú demana un play amb efecte.
|
||||
int effect_channels_active_{0};
|
||||
|
||||
// NOLINTNEXTLINE(readability-identifier-naming) — convenció projecte: private static amb sufix _
|
||||
static Engine* active_;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "core/audio/sound_effects_config.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
#include "core/resources/resource_helper.hpp"
|
||||
#include "external/fkyaml_node.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Lector de camp amb fallback: deixa el destí intacte si la clau no
|
||||
// existeix (els defaults dels Ja::*Params s'inicialitzen al ctor del
|
||||
// struct, així que el comportament és "preset parcial = preset complet
|
||||
// amb defaults per als camps que falten").
|
||||
template <typename T>
|
||||
void readField(const fkyaml::node& node, const char* key, T& dst) {
|
||||
if (node.contains(key)) { dst = node[key].get_value<T>(); }
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto SoundEffectsConfig::get() -> SoundEffectsConfig& {
|
||||
static SoundEffectsConfig instance_;
|
||||
return instance_;
|
||||
}
|
||||
|
||||
void SoundEffectsConfig::load(const std::string& file_path) {
|
||||
auto bytes = Resource::Helper::loadFile(file_path);
|
||||
if (bytes.empty()) {
|
||||
std::cerr << "SoundEffectsConfig: no s'ha pogut obrir " << file_path << " — cap preset d'efecte disponible\n";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||||
const auto* end = begin + bytes.size();
|
||||
auto yaml = fkyaml::node::deserialize(begin, end);
|
||||
|
||||
if (yaml.contains("echo") && yaml["echo"].is_mapping()) {
|
||||
for (auto it = yaml["echo"].begin(); it != yaml["echo"].end(); ++it) {
|
||||
const auto NAME = it.key().get_value<std::string>();
|
||||
const auto& node = it.value();
|
||||
Ja::EchoParams params{};
|
||||
readField(node, "delay_ms", params.delay_ms);
|
||||
readField(node, "feedback", params.feedback);
|
||||
readField(node, "wet", params.wet);
|
||||
echoes_[NAME] = params;
|
||||
}
|
||||
}
|
||||
|
||||
if (yaml.contains("reverb") && yaml["reverb"].is_mapping()) {
|
||||
for (auto it = yaml["reverb"].begin(); it != yaml["reverb"].end(); ++it) {
|
||||
const auto NAME = it.key().get_value<std::string>();
|
||||
const auto& node = it.value();
|
||||
Ja::ReverbParams params{};
|
||||
readField(node, "room_size", params.room_size);
|
||||
readField(node, "damping", params.damping);
|
||||
readField(node, "wet", params.wet);
|
||||
reverbs_[NAME] = params;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "SoundEffectsConfig: " << echoes_.size() << " preset(s) d'eco i " << reverbs_.size() << " de reverb des de " << file_path << '\n';
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "SoundEffectsConfig: error parsejant " << file_path << ": " << e.what() << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
auto SoundEffectsConfig::findEcho(const std::string& name) const -> const Ja::EchoParams* {
|
||||
const auto IT = echoes_.find(name);
|
||||
return (IT == echoes_.end()) ? nullptr : &IT->second;
|
||||
}
|
||||
|
||||
auto SoundEffectsConfig::findReverb(const std::string& name) const -> const Ja::ReverbParams* {
|
||||
const auto IT = reverbs_.find(name);
|
||||
return (IT == reverbs_.end()) ? nullptr : &IT->second;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "core/audio/jail_audio.hpp" // Per a Ja::EchoParams / Ja::ReverbParams
|
||||
|
||||
// Catàleg de presets d'efectes carregat des de data/sounds.yaml. La capa
|
||||
// Audio (playSoundWithEcho/playSoundWithReverb) hi accedeix per nom: si el
|
||||
// preset no existeix, el so es reprodueix sec amb un avís a stderr.
|
||||
//
|
||||
// Patró Meyers idèntic a UiConfig/Locale: un sol load() a l'arrencada, sense
|
||||
// hot-reload. Si el fitxer no existeix, el catàleg queda buit (cap preset
|
||||
// disponible) i tots els playSoundWith* es comporten com playSound dry.
|
||||
class SoundEffectsConfig {
|
||||
public:
|
||||
static auto get() -> SoundEffectsConfig&;
|
||||
|
||||
SoundEffectsConfig(const SoundEffectsConfig&) = delete;
|
||||
SoundEffectsConfig(SoundEffectsConfig&&) = delete;
|
||||
auto operator=(const SoundEffectsConfig&) -> SoundEffectsConfig& = delete;
|
||||
auto operator=(SoundEffectsConfig&&) -> SoundEffectsConfig& = delete;
|
||||
|
||||
void load(const std::string& file_path);
|
||||
|
||||
// Retorna nullptr si el preset no existeix.
|
||||
[[nodiscard]] auto findEcho(const std::string& name) const -> const Ja::EchoParams*;
|
||||
[[nodiscard]] auto findReverb(const std::string& name) const -> const Ja::ReverbParams*;
|
||||
|
||||
private:
|
||||
SoundEffectsConfig() = default;
|
||||
~SoundEffectsConfig() = default;
|
||||
|
||||
std::unordered_map<std::string, Ja::EchoParams> echoes_;
|
||||
std::unordered_map<std::string, Ja::ReverbParams> reverbs_;
|
||||
};
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <utility>
|
||||
|
||||
#include "core/audio/audio.hpp"
|
||||
#include "core/audio/sound_effects_config.hpp"
|
||||
#include "core/input/gamepad_manager.hpp"
|
||||
#include "core/input/key_config.hpp"
|
||||
#include "core/input/mouse.hpp"
|
||||
@@ -135,6 +136,11 @@ Director::Director() {
|
||||
// perquè forma part del preload d'assets, com la font i la paleta.
|
||||
Resource::Cache::get().loadMusic("pyramid_1.ogg", "data/music/pyramid_1.ogg");
|
||||
|
||||
// Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio
|
||||
// perquè els primers playSoundWith* (al començament del joc) ja
|
||||
// troben els presets disponibles.
|
||||
SoundEffectsConfig::get().load("data/sounds.yaml");
|
||||
|
||||
// Audio: singleton manual propietari d'un SDL audio device. Es destrueix
|
||||
// explícitament a ~Director abans que Resource::Cache (Meyers) buide els
|
||||
// JA_Music_t que continga, així que no queden streams orfes.
|
||||
|
||||
@@ -697,7 +697,15 @@ auto Player::integrateJump(float delta_time) -> bool {
|
||||
state_ = State::LANDING_STUN;
|
||||
landing_stun_remaining_ms_ = static_cast<float>(jump_config.landing_stun_ms);
|
||||
if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) {
|
||||
// Consumeix el preset armat per GameScene al spawn aeri
|
||||
// d'INTRO (single-shot): el primer LAND el sona reverberat,
|
||||
// els salts següents cauen al camí sec.
|
||||
if (next_land_reverb_preset_.empty()) {
|
||||
Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME);
|
||||
} else {
|
||||
Audio::get()->playSoundWithReverb(jump_config.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME);
|
||||
next_land_reverb_preset_.clear();
|
||||
}
|
||||
}
|
||||
if (bus_ != nullptr) {
|
||||
bus_->publish(Event{
|
||||
|
||||
@@ -259,6 +259,15 @@ class Player : public AnimatedSprite {
|
||||
// al respawn post-mort: mateixa entrada cinemàtica.
|
||||
void spawnFromAir(float x, float y);
|
||||
|
||||
// Arma un preset de reverb (catàleg a `data/sounds.yaml`) per al
|
||||
// pròxim aterratge d'aquest jugador. Pensat per a la cinemàtica
|
||||
// d'INTRO de sala: GameScene crida aquesta funció just després de
|
||||
// `spawnFromAir`, i el primer LAND consumeix el preset (queda buit
|
||||
// immediatament). Els salts posteriors del joc, els respawns
|
||||
// post-mort i el reset general no rearmen res. `preset` buit
|
||||
// desactiva el reverb del proper LAND (no afecta cap LAND posterior).
|
||||
void armNextLandReverb(std::string preset) { next_land_reverb_preset_ = std::move(preset); }
|
||||
|
||||
// Alçada actual del salt (píxels visuals per damunt del sòl).
|
||||
// 0 quan està a terra. L'usa el render i el sistema de col·lisions
|
||||
// per altura.
|
||||
@@ -390,6 +399,14 @@ class Player : public AnimatedSprite {
|
||||
// gestiona `intended_vx_/vy_` escalats pel resolver.
|
||||
bool being_pushed_{false};
|
||||
|
||||
// Preset de reverb (catàleg de `data/sounds.yaml`) que el pròxim
|
||||
// LAND ha de fer servir; buit = so sec. L'arma `armNextLandReverb`
|
||||
// (cridada per GameScene al spawn aeri d'INTRO) i el consumeix
|
||||
// `integrateJump` quan el jugador toca terra. Cap altre punt
|
||||
// (respawn, exit, disable) el toca: així només sona reverberat el
|
||||
// primer LAND de la cinemàtica d'entrada.
|
||||
std::string next_land_reverb_preset_;
|
||||
|
||||
// Escala d'empenta usada per `snapToLane` quan descobreix un blocker
|
||||
// (mateixa del resolver). 1.0 = sense escalar (comportament si
|
||||
// GameScene no ha cridat setPushFactor).
|
||||
|
||||
@@ -556,6 +556,11 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
|
||||
float x = SINGLE_X;
|
||||
if (BOTH_ACTIVE) { x = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
|
||||
players_[slot_index]->spawnFromAir(x, SPAWN.y);
|
||||
// Arma el reverb del primer LAND com a part de la cinemàtica
|
||||
// d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort
|
||||
// (que no passa per ací) cau al camí sec. Si el preset està buit
|
||||
// al YAML, l'arma queda buida i el LAND és sec igualment.
|
||||
players_[slot_index]->armNextLandReverb(transition_config_.intro_land_reverb_preset);
|
||||
|
||||
std::cout << "[ROOM] spawn cinematic P" << (slot_index + 1) << ": airborne entry\n";
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ namespace RoomFsm {
|
||||
// El YAML pot trencar la simultaneïtat fixant un valor propi.
|
||||
cfg.player2_spawn_delay_ms = readChildInt(n, "player2_spawn_delay_ms", cfg.player_spawn_delay_ms);
|
||||
cfg.intro_text_line_gap_px = readChildInt(n, "line_gap_px", cfg.intro_text_line_gap_px);
|
||||
if (n.contains("land_reverb")) {
|
||||
cfg.intro_land_reverb_preset = n["land_reverb"].get_value<std::string>();
|
||||
}
|
||||
}
|
||||
if (yaml.contains("room_outro")) {
|
||||
const auto& n = yaml["room_outro"];
|
||||
|
||||
@@ -80,6 +80,13 @@ namespace RoomFsm {
|
||||
UiConfig::CurveFn text_enter_curve{nullptr};
|
||||
UiConfig::CurveFn text_exit_curve{nullptr};
|
||||
|
||||
// Preset de reverb (catàleg a `data/sounds.yaml`) que GameScene
|
||||
// arma al jugador al spawn aeri d'INTRO. Es consumeix al primer
|
||||
// aterratge cinemàtic; els salts posteriors del joc i els
|
||||
// respawns post-mort cauen al so sec. Buit = sense reverb (la
|
||||
// cinemàtica d'entrada sona igual que un salt qualsevol).
|
||||
std::string intro_land_reverb_preset;
|
||||
|
||||
// Panel translúcid sota el rètol de transició (INTRO i OUTRO).
|
||||
// Centrat verticalment al centre del playfield, ocupa tota
|
||||
// l'amplada del canvas. Creix d'altura 0 → height_px durant la
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace Texts {
|
||||
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
|
||||
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
||||
constexpr const char* VERSION = "0.27.0"; // Single source of truth — Makefile el grep
|
||||
constexpr const char* VERSION = "0.28.0"; // Single source of truth — Makefile el grep
|
||||
} // namespace Texts
|
||||
|
||||
namespace GameCanvas {
|
||||
|
||||
Reference in new issue
Block a user