feat: Add Sound/Music classes, keyboard state, version (#66, #160, #164)

Replace module-level audio functions with proper OOP API:
- mcrfpy.Sound: Wraps sf::SoundBuffer + sf::Sound for short effects
- mcrfpy.Music: Wraps sf::Music for streaming long tracks
- Both support: volume, loop, playing, duration, play/pause/stop
- Music adds position property for seeking

Add mcrfpy.keyboard singleton for real-time modifier state:
- shift, ctrl, alt, system properties (bool, read-only)
- Queries sf::Keyboard::isKeyPressed() directly

Add mcrfpy.__version__ = "1.0.0" for version identity

Remove old audio API entirely (no deprecation - unused in codebase):
- createSoundBuffer, loadMusic, playSound
- setMusicVolume, getMusicVolume, setSoundVolume, getSoundVolume

closes #66, closes #160, closes #164

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-12-29 16:24:27 -05:00
commit c025cd7da3
11 changed files with 1110 additions and 201 deletions

View file

@ -9,6 +9,10 @@
#include "PyWindow.h" #include "PyWindow.h"
#include "PySceneObject.h" #include "PySceneObject.h"
#include "PyFOV.h" #include "PyFOV.h"
#include "PySound.h"
#include "PyMusic.h"
#include "PyKeyboard.h"
#include "McRogueFaceVersion.h"
#include "GameEngine.h" #include "GameEngine.h"
#include "ImGuiConsole.h" #include "ImGuiConsole.h"
#include "BenchmarkLogger.h" #include "BenchmarkLogger.h"
@ -23,10 +27,6 @@
#include <cstring> #include <cstring>
#include <libtcod.h> #include <libtcod.h>
std::vector<sf::SoundBuffer>* McRFPy_API::soundbuffers = nullptr;
sf::Music* McRFPy_API::music = nullptr;
sf::Sound* McRFPy_API::sfx = nullptr;
std::shared_ptr<PyFont> McRFPy_API::default_font; std::shared_ptr<PyFont> McRFPy_API::default_font;
std::shared_ptr<PyTexture> McRFPy_API::default_texture; std::shared_ptr<PyTexture> McRFPy_API::default_texture;
PyObject* McRFPy_API::mcrf_module; PyObject* McRFPy_API::mcrf_module;
@ -102,62 +102,6 @@ static PyTypeObject McRFPyModuleType = {
static PyMethodDef mcrfpyMethods[] = { static PyMethodDef mcrfpyMethods[] = {
{"createSoundBuffer", McRFPy_API::_createSoundBuffer, METH_VARARGS,
MCRF_FUNCTION(createSoundBuffer,
MCRF_SIG("(filename: str)", "int"),
MCRF_DESC("Load a sound effect from a file and return its buffer ID."),
MCRF_ARGS_START
MCRF_ARG("filename", "Path to the sound file (WAV, OGG, FLAC)")
MCRF_RETURNS("int: Buffer ID for use with playSound()")
MCRF_RAISES("RuntimeError", "If the file cannot be loaded")
)},
{"loadMusic", McRFPy_API::_loadMusic, METH_VARARGS,
MCRF_FUNCTION(loadMusic,
MCRF_SIG("(filename: str)", "None"),
MCRF_DESC("Load and immediately play background music from a file."),
MCRF_ARGS_START
MCRF_ARG("filename", "Path to the music file (WAV, OGG, FLAC)")
MCRF_RETURNS("None")
MCRF_NOTE("Only one music track can play at a time. Loading new music stops the current track.")
)},
{"setMusicVolume", McRFPy_API::_setMusicVolume, METH_VARARGS,
MCRF_FUNCTION(setMusicVolume,
MCRF_SIG("(volume: int)", "None"),
MCRF_DESC("Set the global music volume."),
MCRF_ARGS_START
MCRF_ARG("volume", "Volume level from 0 (silent) to 100 (full volume)")
MCRF_RETURNS("None")
)},
{"setSoundVolume", McRFPy_API::_setSoundVolume, METH_VARARGS,
MCRF_FUNCTION(setSoundVolume,
MCRF_SIG("(volume: int)", "None"),
MCRF_DESC("Set the global sound effects volume."),
MCRF_ARGS_START
MCRF_ARG("volume", "Volume level from 0 (silent) to 100 (full volume)")
MCRF_RETURNS("None")
)},
{"playSound", McRFPy_API::_playSound, METH_VARARGS,
MCRF_FUNCTION(playSound,
MCRF_SIG("(buffer_id: int)", "None"),
MCRF_DESC("Play a sound effect using a previously loaded buffer."),
MCRF_ARGS_START
MCRF_ARG("buffer_id", "Sound buffer ID returned by createSoundBuffer()")
MCRF_RETURNS("None")
MCRF_RAISES("RuntimeError", "If the buffer ID is invalid")
)},
{"getMusicVolume", McRFPy_API::_getMusicVolume, METH_NOARGS,
MCRF_FUNCTION(getMusicVolume,
MCRF_SIG("()", "int"),
MCRF_DESC("Get the current music volume level."),
MCRF_RETURNS("int: Current volume (0-100)")
)},
{"getSoundVolume", McRFPy_API::_getSoundVolume, METH_NOARGS,
MCRF_FUNCTION(getSoundVolume,
MCRF_SIG("()", "int"),
MCRF_DESC("Get the current sound effects volume level."),
MCRF_RETURNS("int: Current volume (0-100)")
)},
{"sceneUI", McRFPy_API::_sceneUI, METH_VARARGS, {"sceneUI", McRFPy_API::_sceneUI, METH_VARARGS,
MCRF_FUNCTION(sceneUI, MCRF_FUNCTION(sceneUI,
MCRF_SIG("(scene: str = None)", "list"), MCRF_SIG("(scene: str = None)", "list"),
@ -412,6 +356,13 @@ PyObject* PyInit_mcrfpy()
/*scene class*/ /*scene class*/
&PySceneType, &PySceneType,
/*audio (#66)*/
&PySoundType,
&PyMusicType,
/*keyboard state (#160)*/
&PyKeyboardType,
nullptr}; nullptr};
// Set up PyWindowType methods and getsetters before PyType_Ready // Set up PyWindowType methods and getsetters before PyType_Ready
@ -455,6 +406,15 @@ PyObject* PyInit_mcrfpy()
PyModule_AddObject(m, "default_font", Py_None); PyModule_AddObject(m, "default_font", Py_None);
PyModule_AddObject(m, "default_texture", Py_None); PyModule_AddObject(m, "default_texture", Py_None);
// Add keyboard singleton (#160)
PyObject* keyboard_instance = PyObject_CallObject((PyObject*)&PyKeyboardType, NULL);
if (keyboard_instance) {
PyModule_AddObject(m, "keyboard", keyboard_instance);
}
// Add version string (#164)
PyModule_AddStringConstant(m, "__version__", MCRFPY_VERSION);
// Add FOV enum class (uses Python's IntEnum) (#114) // Add FOV enum class (uses Python's IntEnum) (#114)
PyObject* fov_class = PyFOV::create_enum_class(m); PyObject* fov_class = PyFOV::create_enum_class(m);
if (!fov_class) { if (!fov_class) {
@ -821,23 +781,7 @@ void McRFPy_API::executeScript(std::string filename)
void McRFPy_API::api_shutdown() void McRFPy_API::api_shutdown()
{ {
// Clean up audio resources in correct order // Audio cleanup now handled by Python garbage collection (Sound/Music classes)
if (sfx) {
sfx->stop();
delete sfx;
sfx = nullptr;
}
if (music) {
music->stop();
delete music;
music = nullptr;
}
if (soundbuffers) {
soundbuffers->clear();
delete soundbuffers;
soundbuffers = nullptr;
}
Py_Finalize(); Py_Finalize();
} }
@ -869,86 +813,7 @@ PyObject* McRFPy_API::_refreshFov(PyObject* self, PyObject* args) {
} }
*/ */
PyObject* McRFPy_API::_createSoundBuffer(PyObject* self, PyObject* args) { // Removed deprecated audio functions - use mcrfpy.Sound and mcrfpy.Music classes instead (#66)
const char *fn_cstr;
if (!PyArg_ParseTuple(args, "s", &fn_cstr)) return NULL;
// Initialize soundbuffers if needed
if (!McRFPy_API::soundbuffers) {
McRFPy_API::soundbuffers = new std::vector<sf::SoundBuffer>();
}
auto b = sf::SoundBuffer();
b.loadFromFile(fn_cstr);
McRFPy_API::soundbuffers->push_back(b);
Py_INCREF(Py_None);
return Py_None;
}
PyObject* McRFPy_API::_loadMusic(PyObject* self, PyObject* args) {
const char *fn_cstr;
PyObject* loop_obj = Py_False;
if (!PyArg_ParseTuple(args, "s|O", &fn_cstr, &loop_obj)) return NULL;
// Initialize music if needed
if (!McRFPy_API::music) {
McRFPy_API::music = new sf::Music();
}
McRFPy_API::music->stop();
McRFPy_API::music->openFromFile(fn_cstr);
McRFPy_API::music->setLoop(PyObject_IsTrue(loop_obj));
McRFPy_API::music->play();
Py_INCREF(Py_None);
return Py_None;
}
PyObject* McRFPy_API::_setMusicVolume(PyObject* self, PyObject* args) {
int vol;
if (!PyArg_ParseTuple(args, "i", &vol)) return NULL;
if (!McRFPy_API::music) {
McRFPy_API::music = new sf::Music();
}
McRFPy_API::music->setVolume(vol);
Py_INCREF(Py_None);
return Py_None;
}
PyObject* McRFPy_API::_setSoundVolume(PyObject* self, PyObject* args) {
float vol;
if (!PyArg_ParseTuple(args, "f", &vol)) return NULL;
if (!McRFPy_API::sfx) {
McRFPy_API::sfx = new sf::Sound();
}
McRFPy_API::sfx->setVolume(vol);
Py_INCREF(Py_None);
return Py_None;
}
PyObject* McRFPy_API::_playSound(PyObject* self, PyObject* args) {
float index;
if (!PyArg_ParseTuple(args, "f", &index)) return NULL;
if (!McRFPy_API::soundbuffers || index >= McRFPy_API::soundbuffers->size()) return NULL;
if (!McRFPy_API::sfx) {
McRFPy_API::sfx = new sf::Sound();
}
McRFPy_API::sfx->stop();
McRFPy_API::sfx->setBuffer((*McRFPy_API::soundbuffers)[index]);
McRFPy_API::sfx->play();
Py_INCREF(Py_None);
return Py_None;
}
PyObject* McRFPy_API::_getMusicVolume(PyObject* self, PyObject* args) {
if (!McRFPy_API::music) {
return Py_BuildValue("f", 0.0f);
}
return Py_BuildValue("f", McRFPy_API::music->getVolume());
}
PyObject* McRFPy_API::_getSoundVolume(PyObject* self, PyObject* args) {
if (!McRFPy_API::sfx) {
return Py_BuildValue("f", 0.0f);
}
return Py_BuildValue("f", McRFPy_API::sfx->getVolume());
}
// Removed deprecated player_input, computerTurn, playerTurn functions // Removed deprecated player_input, computerTurn, playerTurn functions
// These were part of the old turn-based system that is no longer used // These were part of the old turn-based system that is no longer used

View file

@ -37,19 +37,6 @@ public:
static void REPL_device(FILE * fp, const char *filename); static void REPL_device(FILE * fp, const char *filename);
static void REPL(); static void REPL();
static std::vector<sf::SoundBuffer>* soundbuffers;
static sf::Music* music;
static sf::Sound* sfx;
static PyObject* _createSoundBuffer(PyObject*, PyObject*);
static PyObject* _loadMusic(PyObject*, PyObject*);
static PyObject* _setMusicVolume(PyObject*, PyObject*);
static PyObject* _setSoundVolume(PyObject*, PyObject*);
static PyObject* _playSound(PyObject*, PyObject*);
static PyObject* _getMusicVolume(PyObject*, PyObject*);
static PyObject* _getSoundVolume(PyObject*, PyObject*);
static PyObject* _sceneUI(PyObject*, PyObject*); static PyObject* _sceneUI(PyObject*, PyObject*);
// scene control // scene control

4
src/McRogueFaceVersion.h Normal file
View file

@ -0,0 +1,4 @@
#pragma once
// McRogueFace version string (#164)
#define MCRFPY_VERSION "1.0.0"

64
src/PyKeyboard.cpp Normal file
View file

@ -0,0 +1,64 @@
#include "PyKeyboard.h"
#include "McRFPy_Doc.h"
PyObject* PyKeyboard::repr(PyObject* obj)
{
// Show current state in repr for debugging
bool shift = sf::Keyboard::isKeyPressed(sf::Keyboard::LShift) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RShift);
bool ctrl = sf::Keyboard::isKeyPressed(sf::Keyboard::LControl) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RControl);
bool alt = sf::Keyboard::isKeyPressed(sf::Keyboard::LAlt) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RAlt);
bool system = sf::Keyboard::isKeyPressed(sf::Keyboard::LSystem) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RSystem);
std::ostringstream ss;
ss << "<Keyboard shift=" << (shift ? "True" : "False")
<< " ctrl=" << (ctrl ? "True" : "False")
<< " alt=" << (alt ? "True" : "False")
<< " system=" << (system ? "True" : "False") << ">";
std::string repr_str = ss.str();
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
}
PyObject* PyKeyboard::get_shift(PyObject* self, void* closure)
{
bool pressed = sf::Keyboard::isKeyPressed(sf::Keyboard::LShift) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RShift);
return PyBool_FromLong(pressed);
}
PyObject* PyKeyboard::get_ctrl(PyObject* self, void* closure)
{
bool pressed = sf::Keyboard::isKeyPressed(sf::Keyboard::LControl) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RControl);
return PyBool_FromLong(pressed);
}
PyObject* PyKeyboard::get_alt(PyObject* self, void* closure)
{
bool pressed = sf::Keyboard::isKeyPressed(sf::Keyboard::LAlt) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RAlt);
return PyBool_FromLong(pressed);
}
PyObject* PyKeyboard::get_system(PyObject* self, void* closure)
{
bool pressed = sf::Keyboard::isKeyPressed(sf::Keyboard::LSystem) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::RSystem);
return PyBool_FromLong(pressed);
}
PyGetSetDef PyKeyboard::getsetters[] = {
{"shift", (getter)PyKeyboard::get_shift, NULL,
MCRF_PROPERTY(shift, "True if either Shift key is currently pressed (read-only)."), NULL},
{"ctrl", (getter)PyKeyboard::get_ctrl, NULL,
MCRF_PROPERTY(ctrl, "True if either Control key is currently pressed (read-only)."), NULL},
{"alt", (getter)PyKeyboard::get_alt, NULL,
MCRF_PROPERTY(alt, "True if either Alt key is currently pressed (read-only)."), NULL},
{"system", (getter)PyKeyboard::get_system, NULL,
MCRF_PROPERTY(system, "True if either System key (Win/Cmd) is currently pressed (read-only)."), NULL},
{NULL}
};

35
src/PyKeyboard.h Normal file
View file

@ -0,0 +1,35 @@
#pragma once
#include "Common.h"
#include "Python.h"
// Singleton keyboard state object
typedef struct {
PyObject_HEAD
} PyKeyboardObject;
class PyKeyboard
{
public:
// Python getters - query real-time keyboard state via SFML
static PyObject* get_shift(PyObject* self, void* closure);
static PyObject* get_ctrl(PyObject* self, void* closure);
static PyObject* get_alt(PyObject* self, void* closure);
static PyObject* get_system(PyObject* self, void* closure);
static PyObject* repr(PyObject* obj);
static PyGetSetDef getsetters[];
};
namespace mcrfpydef {
static PyTypeObject PyKeyboardType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Keyboard",
.tp_basicsize = sizeof(PyKeyboardObject),
.tp_itemsize = 0,
.tp_repr = PyKeyboard::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("Keyboard state singleton for checking modifier keys"),
.tp_getset = PyKeyboard::getsetters,
.tp_new = PyType_GenericNew,
};
}

294
src/PyMusic.cpp Normal file
View file

@ -0,0 +1,294 @@
#include "PyMusic.h"
#include "McRFPy_API.h"
#include "McRFPy_Doc.h"
#include <sstream>
#include <iomanip>
PyMusic::PyMusic(const std::string& filename)
: source(filename), loaded(false)
{
if (music.openFromFile(filename)) {
loaded = true;
}
}
void PyMusic::play()
{
if (loaded) {
music.play();
}
}
void PyMusic::pause()
{
if (loaded) {
music.pause();
}
}
void PyMusic::stop()
{
if (loaded) {
music.stop();
}
}
float PyMusic::getVolume() const
{
return music.getVolume();
}
void PyMusic::setVolume(float vol)
{
music.setVolume(std::max(0.0f, std::min(100.0f, vol)));
}
bool PyMusic::getLoop() const
{
return music.getLoop();
}
void PyMusic::setLoop(bool loop)
{
music.setLoop(loop);
}
bool PyMusic::isPlaying() const
{
return music.getStatus() == sf::Music::Playing;
}
float PyMusic::getDuration() const
{
if (!loaded) return 0.0f;
return music.getDuration().asSeconds();
}
float PyMusic::getPosition() const
{
return music.getPlayingOffset().asSeconds();
}
void PyMusic::setPosition(float pos)
{
music.setPlayingOffset(sf::seconds(pos));
}
PyObject* PyMusic::pyObject()
{
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Music");
PyObject* obj = PyMusic::pynew(type, Py_None, Py_None);
Py_DECREF(type);
try {
((PyMusicObject*)obj)->data = shared_from_this();
}
catch (std::bad_weak_ptr& e) {
std::cerr << "PyMusic::pyObject() - shared_from_this() failed" << std::endl;
}
return obj;
}
PyObject* PyMusic::repr(PyObject* obj)
{
PyMusicObject* self = (PyMusicObject*)obj;
std::ostringstream ss;
if (!self->data) {
ss << "<Music [invalid]>";
} else if (!self->data->loaded) {
ss << "<Music [failed to load: " << self->data->source << "]>";
} else {
ss << "<Music source='" << self->data->source << "' duration="
<< std::fixed << std::setprecision(2) << self->data->getDuration() << "s>";
}
std::string repr_str = ss.str();
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
}
Py_hash_t PyMusic::hash(PyObject* obj)
{
auto self = (PyMusicObject*)obj;
return reinterpret_cast<Py_hash_t>(self->data.get());
}
int PyMusic::init(PyMusicObject* self, PyObject* args, PyObject* kwds)
{
static const char* keywords[] = {"filename", nullptr};
const char* filename = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast<char**>(keywords), &filename)) {
return -1;
}
self->data = std::make_shared<PyMusic>(filename);
if (!self->data->loaded) {
PyErr_Format(PyExc_RuntimeError, "Failed to load music file: %s", filename);
return -1;
}
return 0;
}
PyObject* PyMusic::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds)
{
return (PyObject*)type->tp_alloc(type, 0);
}
// Python methods
PyObject* PyMusic::py_play(PyMusicObject* self, PyObject* args)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
self->data->play();
Py_RETURN_NONE;
}
PyObject* PyMusic::py_pause(PyMusicObject* self, PyObject* args)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
self->data->pause();
Py_RETURN_NONE;
}
PyObject* PyMusic::py_stop(PyMusicObject* self, PyObject* args)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
self->data->stop();
Py_RETURN_NONE;
}
// Property getters/setters
PyObject* PyMusic::get_volume(PyMusicObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
return PyFloat_FromDouble(self->data->getVolume());
}
int PyMusic::set_volume(PyMusicObject* self, PyObject* value, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return -1;
}
float vol = PyFloat_AsDouble(value);
if (PyErr_Occurred()) {
return -1;
}
self->data->setVolume(vol);
return 0;
}
PyObject* PyMusic::get_loop(PyMusicObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
return PyBool_FromLong(self->data->getLoop());
}
int PyMusic::set_loop(PyMusicObject* self, PyObject* value, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return -1;
}
self->data->setLoop(PyObject_IsTrue(value));
return 0;
}
PyObject* PyMusic::get_playing(PyMusicObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
return PyBool_FromLong(self->data->isPlaying());
}
PyObject* PyMusic::get_duration(PyMusicObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
return PyFloat_FromDouble(self->data->getDuration());
}
PyObject* PyMusic::get_position(PyMusicObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
return PyFloat_FromDouble(self->data->getPosition());
}
int PyMusic::set_position(PyMusicObject* self, PyObject* value, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return -1;
}
float pos = PyFloat_AsDouble(value);
if (PyErr_Occurred()) {
return -1;
}
self->data->setPosition(pos);
return 0;
}
PyObject* PyMusic::get_source(PyMusicObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Music object is invalid");
return NULL;
}
return PyUnicode_FromString(self->data->source.c_str());
}
PyMethodDef PyMusic::methods[] = {
{"play", (PyCFunction)PyMusic::py_play, METH_NOARGS,
MCRF_METHOD(Music, play,
MCRF_SIG("()", "None"),
MCRF_DESC("Start or resume playing the music.")
)},
{"pause", (PyCFunction)PyMusic::py_pause, METH_NOARGS,
MCRF_METHOD(Music, pause,
MCRF_SIG("()", "None"),
MCRF_DESC("Pause the music. Use play() to resume from the paused position.")
)},
{"stop", (PyCFunction)PyMusic::py_stop, METH_NOARGS,
MCRF_METHOD(Music, stop,
MCRF_SIG("()", "None"),
MCRF_DESC("Stop playing and reset to the beginning.")
)},
{NULL}
};
PyGetSetDef PyMusic::getsetters[] = {
{"volume", (getter)PyMusic::get_volume, (setter)PyMusic::set_volume,
MCRF_PROPERTY(volume, "Volume level from 0 (silent) to 100 (full volume)."), NULL},
{"loop", (getter)PyMusic::get_loop, (setter)PyMusic::set_loop,
MCRF_PROPERTY(loop, "Whether the music loops when it reaches the end."), NULL},
{"playing", (getter)PyMusic::get_playing, NULL,
MCRF_PROPERTY(playing, "True if the music is currently playing (read-only)."), NULL},
{"duration", (getter)PyMusic::get_duration, NULL,
MCRF_PROPERTY(duration, "Total duration of the music in seconds (read-only)."), NULL},
{"position", (getter)PyMusic::get_position, (setter)PyMusic::set_position,
MCRF_PROPERTY(position, "Current playback position in seconds. Can be set to seek."), NULL},
{"source", (getter)PyMusic::get_source, NULL,
MCRF_PROPERTY(source, "Filename path used to load this music (read-only)."), NULL},
{NULL}
};

79
src/PyMusic.h Normal file
View file

@ -0,0 +1,79 @@
#pragma once
#include "Common.h"
#include "Python.h"
class PyMusic;
typedef struct {
PyObject_HEAD
std::shared_ptr<PyMusic> data;
} PyMusicObject;
class PyMusic : public std::enable_shared_from_this<PyMusic>
{
private:
sf::Music music;
std::string source;
bool loaded;
public:
PyMusic(const std::string& filename);
// Playback control
void play();
void pause();
void stop();
// Properties
float getVolume() const;
void setVolume(float vol);
bool getLoop() const;
void setLoop(bool loop);
bool isPlaying() const;
float getDuration() const;
float getPosition() const;
void setPosition(float pos);
// Python interface
PyObject* pyObject();
static PyObject* repr(PyObject* obj);
static Py_hash_t hash(PyObject* obj);
static int init(PyMusicObject* self, PyObject* args, PyObject* kwds);
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
// Python methods
static PyObject* py_play(PyMusicObject* self, PyObject* args);
static PyObject* py_pause(PyMusicObject* self, PyObject* args);
static PyObject* py_stop(PyMusicObject* self, PyObject* args);
// Python getters/setters
static PyObject* get_volume(PyMusicObject* self, void* closure);
static int set_volume(PyMusicObject* self, PyObject* value, void* closure);
static PyObject* get_loop(PyMusicObject* self, void* closure);
static int set_loop(PyMusicObject* self, PyObject* value, void* closure);
static PyObject* get_playing(PyMusicObject* self, void* closure);
static PyObject* get_duration(PyMusicObject* self, void* closure);
static PyObject* get_position(PyMusicObject* self, void* closure);
static int set_position(PyMusicObject* self, PyObject* value, void* closure);
static PyObject* get_source(PyMusicObject* self, void* closure);
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
};
namespace mcrfpydef {
static PyTypeObject PyMusicType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Music",
.tp_basicsize = sizeof(PyMusicObject),
.tp_itemsize = 0,
.tp_repr = PyMusic::repr,
.tp_hash = PyMusic::hash,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("Streaming music object for longer audio tracks"),
.tp_methods = PyMusic::methods,
.tp_getset = PyMusic::getsetters,
.tp_init = (initproc)PyMusic::init,
.tp_new = PyType_GenericNew,
};
}

259
src/PySound.cpp Normal file
View file

@ -0,0 +1,259 @@
#include "PySound.h"
#include "McRFPy_API.h"
#include "McRFPy_Doc.h"
#include <sstream>
PySound::PySound(const std::string& filename)
: source(filename), loaded(false)
{
if (buffer.loadFromFile(filename)) {
sound.setBuffer(buffer);
loaded = true;
}
}
void PySound::play()
{
if (loaded) {
sound.play();
}
}
void PySound::pause()
{
if (loaded) {
sound.pause();
}
}
void PySound::stop()
{
if (loaded) {
sound.stop();
}
}
float PySound::getVolume() const
{
return sound.getVolume();
}
void PySound::setVolume(float vol)
{
sound.setVolume(std::max(0.0f, std::min(100.0f, vol)));
}
bool PySound::getLoop() const
{
return sound.getLoop();
}
void PySound::setLoop(bool loop)
{
sound.setLoop(loop);
}
bool PySound::isPlaying() const
{
return sound.getStatus() == sf::Sound::Playing;
}
float PySound::getDuration() const
{
if (!loaded) return 0.0f;
return buffer.getDuration().asSeconds();
}
PyObject* PySound::pyObject()
{
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sound");
PyObject* obj = PySound::pynew(type, Py_None, Py_None);
Py_DECREF(type);
try {
((PySoundObject*)obj)->data = shared_from_this();
}
catch (std::bad_weak_ptr& e) {
std::cerr << "PySound::pyObject() - shared_from_this() failed" << std::endl;
}
return obj;
}
PyObject* PySound::repr(PyObject* obj)
{
PySoundObject* self = (PySoundObject*)obj;
std::ostringstream ss;
if (!self->data) {
ss << "<Sound [invalid]>";
} else if (!self->data->loaded) {
ss << "<Sound [failed to load: " << self->data->source << "]>";
} else {
ss << "<Sound source='" << self->data->source << "' duration="
<< std::fixed << std::setprecision(2) << self->data->getDuration() << "s>";
}
std::string repr_str = ss.str();
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
}
Py_hash_t PySound::hash(PyObject* obj)
{
auto self = (PySoundObject*)obj;
return reinterpret_cast<Py_hash_t>(self->data.get());
}
int PySound::init(PySoundObject* self, PyObject* args, PyObject* kwds)
{
static const char* keywords[] = {"filename", nullptr};
const char* filename = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast<char**>(keywords), &filename)) {
return -1;
}
self->data = std::make_shared<PySound>(filename);
if (!self->data->loaded) {
PyErr_Format(PyExc_RuntimeError, "Failed to load sound file: %s", filename);
return -1;
}
return 0;
}
PyObject* PySound::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds)
{
return (PyObject*)type->tp_alloc(type, 0);
}
// Python methods
PyObject* PySound::py_play(PySoundObject* self, PyObject* args)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
self->data->play();
Py_RETURN_NONE;
}
PyObject* PySound::py_pause(PySoundObject* self, PyObject* args)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
self->data->pause();
Py_RETURN_NONE;
}
PyObject* PySound::py_stop(PySoundObject* self, PyObject* args)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
self->data->stop();
Py_RETURN_NONE;
}
// Property getters/setters
PyObject* PySound::get_volume(PySoundObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
return PyFloat_FromDouble(self->data->getVolume());
}
int PySound::set_volume(PySoundObject* self, PyObject* value, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return -1;
}
float vol = PyFloat_AsDouble(value);
if (PyErr_Occurred()) {
return -1;
}
self->data->setVolume(vol);
return 0;
}
PyObject* PySound::get_loop(PySoundObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
return PyBool_FromLong(self->data->getLoop());
}
int PySound::set_loop(PySoundObject* self, PyObject* value, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return -1;
}
self->data->setLoop(PyObject_IsTrue(value));
return 0;
}
PyObject* PySound::get_playing(PySoundObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
return PyBool_FromLong(self->data->isPlaying());
}
PyObject* PySound::get_duration(PySoundObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
return PyFloat_FromDouble(self->data->getDuration());
}
PyObject* PySound::get_source(PySoundObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
return PyUnicode_FromString(self->data->source.c_str());
}
PyMethodDef PySound::methods[] = {
{"play", (PyCFunction)PySound::py_play, METH_NOARGS,
MCRF_METHOD(Sound, play,
MCRF_SIG("()", "None"),
MCRF_DESC("Start or resume playing the sound.")
)},
{"pause", (PyCFunction)PySound::py_pause, METH_NOARGS,
MCRF_METHOD(Sound, pause,
MCRF_SIG("()", "None"),
MCRF_DESC("Pause the sound. Use play() to resume from the paused position.")
)},
{"stop", (PyCFunction)PySound::py_stop, METH_NOARGS,
MCRF_METHOD(Sound, stop,
MCRF_SIG("()", "None"),
MCRF_DESC("Stop playing and reset to the beginning.")
)},
{NULL}
};
PyGetSetDef PySound::getsetters[] = {
{"volume", (getter)PySound::get_volume, (setter)PySound::set_volume,
MCRF_PROPERTY(volume, "Volume level from 0 (silent) to 100 (full volume)."), NULL},
{"loop", (getter)PySound::get_loop, (setter)PySound::set_loop,
MCRF_PROPERTY(loop, "Whether the sound loops when it reaches the end."), NULL},
{"playing", (getter)PySound::get_playing, NULL,
MCRF_PROPERTY(playing, "True if the sound is currently playing (read-only)."), NULL},
{"duration", (getter)PySound::get_duration, NULL,
MCRF_PROPERTY(duration, "Total duration of the sound in seconds (read-only)."), NULL},
{"source", (getter)PySound::get_source, NULL,
MCRF_PROPERTY(source, "Filename path used to load this sound (read-only)."), NULL},
{NULL}
};

76
src/PySound.h Normal file
View file

@ -0,0 +1,76 @@
#pragma once
#include "Common.h"
#include "Python.h"
class PySound;
typedef struct {
PyObject_HEAD
std::shared_ptr<PySound> data;
} PySoundObject;
class PySound : public std::enable_shared_from_this<PySound>
{
private:
sf::SoundBuffer buffer;
sf::Sound sound;
std::string source;
bool loaded;
public:
PySound(const std::string& filename);
// Playback control
void play();
void pause();
void stop();
// Properties
float getVolume() const;
void setVolume(float vol);
bool getLoop() const;
void setLoop(bool loop);
bool isPlaying() const;
float getDuration() const;
// Python interface
PyObject* pyObject();
static PyObject* repr(PyObject* obj);
static Py_hash_t hash(PyObject* obj);
static int init(PySoundObject* self, PyObject* args, PyObject* kwds);
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
// Python methods
static PyObject* py_play(PySoundObject* self, PyObject* args);
static PyObject* py_pause(PySoundObject* self, PyObject* args);
static PyObject* py_stop(PySoundObject* self, PyObject* args);
// Python getters/setters
static PyObject* get_volume(PySoundObject* self, void* closure);
static int set_volume(PySoundObject* self, PyObject* value, void* closure);
static PyObject* get_loop(PySoundObject* self, void* closure);
static int set_loop(PySoundObject* self, PyObject* value, void* closure);
static PyObject* get_playing(PySoundObject* self, void* closure);
static PyObject* get_duration(PySoundObject* self, void* closure);
static PyObject* get_source(PySoundObject* self, void* closure);
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
};
namespace mcrfpydef {
static PyTypeObject PySoundType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Sound",
.tp_basicsize = sizeof(PySoundObject),
.tp_itemsize = 0,
.tp_repr = PySound::repr,
.tp_hash = PySound::hash,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("Sound effect object for short audio clips"),
.tp_methods = PySound::methods,
.tp_getset = PySound::getsetters,
.tp_init = (initproc)PySound::init,
.tp_new = PyType_GenericNew,
};
}

View file

@ -73,6 +73,119 @@ class Font:
filename: str filename: str
family: str family: str
class Sound:
"""Sound effect object for short audio clips.
Sounds are loaded entirely into memory, making them suitable for
short sound effects that need to be played with minimal latency.
Multiple Sound instances can play simultaneously.
"""
def __init__(self, filename: str) -> None:
"""Load a sound effect from a file.
Args:
filename: Path to the sound file (WAV, OGG, FLAC supported)
Raises:
RuntimeError: If the file cannot be loaded
"""
...
volume: float
"""Volume level from 0 (silent) to 100 (full volume)."""
loop: bool
"""Whether the sound loops when it reaches the end."""
playing: bool
"""True if the sound is currently playing (read-only)."""
duration: float
"""Total duration of the sound in seconds (read-only)."""
source: str
"""Filename path used to load this sound (read-only)."""
def play(self) -> None:
"""Start or resume playing the sound."""
...
def pause(self) -> None:
"""Pause the sound. Use play() to resume."""
...
def stop(self) -> None:
"""Stop playing and reset to the beginning."""
...
class Music:
"""Streaming music object for longer audio tracks.
Music is streamed from disk rather than loaded entirely into memory,
making it suitable for longer audio tracks like background music.
"""
def __init__(self, filename: str) -> None:
"""Load a music track from a file.
Args:
filename: Path to the music file (WAV, OGG, FLAC supported)
Raises:
RuntimeError: If the file cannot be loaded
"""
...
volume: float
"""Volume level from 0 (silent) to 100 (full volume)."""
loop: bool
"""Whether the music loops when it reaches the end."""
playing: bool
"""True if the music is currently playing (read-only)."""
duration: float
"""Total duration of the music in seconds (read-only)."""
position: float
"""Current playback position in seconds. Can be set to seek."""
source: str
"""Filename path used to load this music (read-only)."""
def play(self) -> None:
"""Start or resume playing the music."""
...
def pause(self) -> None:
"""Pause the music. Use play() to resume."""
...
def stop(self) -> None:
"""Stop playing and reset to the beginning."""
...
class Keyboard:
"""Keyboard state singleton for checking modifier keys.
Access via mcrfpy.keyboard (singleton instance).
Queries real-time keyboard state from SFML.
"""
shift: bool
"""True if either Shift key is currently pressed (read-only)."""
ctrl: bool
"""True if either Control key is currently pressed (read-only)."""
alt: bool
"""True if either Alt key is currently pressed (read-only)."""
system: bool
"""True if either System key (Win/Cmd) is currently pressed (read-only)."""
class Drawable: class Drawable:
"""Base class for all drawable UI elements.""" """Base class for all drawable UI elements."""
@ -446,36 +559,16 @@ class Animation:
"""Get the current interpolated value.""" """Get the current interpolated value."""
... ...
# Module-level attributes
__version__: str
"""McRogueFace version string (e.g., '1.0.0')."""
keyboard: Keyboard
"""Keyboard state singleton for checking modifier keys."""
# Module functions # Module functions
def createSoundBuffer(filename: str) -> int:
"""Load a sound effect from a file and return its buffer ID."""
...
def loadMusic(filename: str) -> None:
"""Load and immediately play background music from a file."""
...
def setMusicVolume(volume: int) -> None:
"""Set the global music volume (0-100)."""
...
def setSoundVolume(volume: int) -> None:
"""Set the global sound effects volume (0-100)."""
...
def playSound(buffer_id: int) -> None:
"""Play a sound effect using a previously loaded buffer."""
...
def getMusicVolume() -> int:
"""Get the current music volume level (0-100)."""
...
def getSoundVolume() -> int:
"""Get the current sound effects volume level (0-100)."""
...
def sceneUI(scene: Optional[str] = None) -> UICollection: def sceneUI(scene: Optional[str] = None) -> UICollection:
"""Get all UI elements for a scene.""" """Get all UI elements for a scene."""
... ...

View file

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Test for Sound, Music, Keyboard classes and __version__ (#66, #160, #164)."""
import mcrfpy
import sys
def test_version():
"""Test that __version__ exists and is a valid semver string."""
assert hasattr(mcrfpy, '__version__'), "mcrfpy.__version__ not found"
version = mcrfpy.__version__
assert isinstance(version, str), f"__version__ should be str, got {type(version)}"
parts = version.split('.')
assert len(parts) == 3, f"Version should be MAJOR.MINOR.PATCH, got {version}"
print(f" Version: {version}")
def test_keyboard():
"""Test Keyboard singleton exists and has expected properties."""
assert hasattr(mcrfpy, 'keyboard'), "mcrfpy.keyboard not found"
kb = mcrfpy.keyboard
# Check all modifier properties exist and are bool
for prop in ['shift', 'ctrl', 'alt', 'system']:
assert hasattr(kb, prop), f"keyboard.{prop} not found"
val = getattr(kb, prop)
assert isinstance(val, bool), f"keyboard.{prop} should be bool, got {type(val)}"
print(f" Keyboard state: {kb}")
def test_sound_class():
"""Test Sound class creation and properties."""
# Test with a known good file
sound = mcrfpy.Sound("assets/sfx/splat1.ogg")
# Check repr works
repr_str = repr(sound)
assert 'Sound' in repr_str
assert 'splat1.ogg' in repr_str
print(f" Sound: {repr_str}")
# Check default values
assert sound.volume == 100.0, f"Default volume should be 100, got {sound.volume}"
assert sound.loop == False, f"Default loop should be False, got {sound.loop}"
assert sound.playing == False, f"Should not be playing initially"
assert sound.duration > 0, f"Duration should be positive, got {sound.duration}"
assert sound.source == "assets/sfx/splat1.ogg"
# Test setting properties (use tolerance for floating point)
sound.volume = 50.0
assert abs(sound.volume - 50.0) < 0.01, f"Volume should be ~50, got {sound.volume}"
sound.loop = True
assert sound.loop == True
# Test methods exist (don't actually play in headless)
assert callable(sound.play)
assert callable(sound.pause)
assert callable(sound.stop)
print(f" Duration: {sound.duration:.3f}s")
def test_sound_error_handling():
"""Test Sound raises on invalid file."""
try:
sound = mcrfpy.Sound("nonexistent_file.ogg")
print(" ERROR: Should have raised RuntimeError")
return False
except RuntimeError as e:
print(f" Correctly raised: {e}")
return True
def test_music_class():
"""Test Music class creation and properties."""
music = mcrfpy.Music("assets/sfx/splat1.ogg")
# Check repr works
repr_str = repr(music)
assert 'Music' in repr_str
print(f" Music: {repr_str}")
# Check default values
assert music.volume == 100.0, f"Default volume should be 100, got {music.volume}"
assert music.loop == False, f"Default loop should be False, got {music.loop}"
assert music.playing == False, f"Should not be playing initially, got {music.playing}"
assert music.duration > 0, f"Duration should be positive, got {music.duration}"
# Position comparison needs tolerance for floating point
assert abs(music.position) < 0.001, f"Position should be ~0, got {music.position}"
assert music.source == "assets/sfx/splat1.ogg", f"Source mismatch: {music.source}"
# Test setting properties (use tolerance for floating point)
music.volume = 30.0
assert abs(music.volume - 30.0) < 0.01, f"Volume should be ~30, got {music.volume}"
music.loop = True
assert music.loop == True, f"Loop should be True, got {music.loop}"
# Test position can be set (seek)
# Can't really test this without playing, but check it's writable
music.position = 0.1
print(f" Duration: {music.duration:.3f}s")
def test_music_error_handling():
"""Test Music raises on invalid file."""
try:
music = mcrfpy.Music("nonexistent_file.ogg")
print(" ERROR: Should have raised RuntimeError")
return False
except RuntimeError as e:
print(f" Correctly raised: {e}")
return True
def main():
print("Testing mcrfpy audio and keyboard features (#66, #160, #164)")
print()
tests = [
("__version__", test_version),
("keyboard singleton", test_keyboard),
("Sound class", test_sound_class),
("Sound error handling", test_sound_error_handling),
("Music class", test_music_class),
("Music error handling", test_music_error_handling),
]
passed = 0
failed = 0
for name, test_fn in tests:
print(f"Testing {name}...")
try:
result = test_fn()
if result is False:
failed += 1
print(f" FAILED")
else:
passed += 1
print(f" PASSED")
except Exception as e:
failed += 1
print(f" FAILED: {e}")
print()
print(f"Results: {passed} passed, {failed} failed")
if failed == 0:
print("PASS")
sys.exit(0)
else:
print("FAIL")
sys.exit(1)
if __name__ == "__main__":
main()