diff --git a/korlib/CMakeLists.txt b/korlib/CMakeLists.txt index 57e16fe..265ea13 100644 --- a/korlib/CMakeLists.txt +++ b/korlib/CMakeLists.txt @@ -1,6 +1,8 @@ project(korlib) cmake_minimum_required(VERSION 3.0) +set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") + # Stolen shamelessly from PyHSPlasma find_package(PythonLibs REQUIRED) find_package(PythonInterp "${PYTHONLIBS_VERSION_STRING}" REQUIRED) @@ -10,24 +12,30 @@ if (NOT "${PYTHONLIBS_VERSION_STRING}" STREQUAL "${PYTHON_VERSION_STRING}") endif() find_package(HSPlasma REQUIRED) +find_package(Ogg REQUIRED) find_package(OpenGL REQUIRED) +find_package(Vorbis REQUIRED) # Da files set(korlib_HEADERS buffer.h korlib.h + sound.h texture.h ) set(korlib_SOURCES buffer.cpp module.cpp + sound.cpp texture.cpp ) include_directories(${HSPlasma_INCLUDE_DIRS}) +include_directories(${Ogg_INCLUDE_DIR}) include_directories(${OPENGL_INCLUDE_DIR}) include_directories(${PYTHON_INCLUDE_DIRS}) +include_directories(${Vorbis_INCLUDE_DIR}) add_library(_korlib SHARED ${korlib_HEADERS} ${korlib_SOURCES}) if(NOT WIN32) @@ -35,4 +43,4 @@ if(NOT WIN32) else() set_target_properties(_korlib PROPERTIES SUFFIX ".pyd") endif() -target_link_libraries(_korlib HSPlasma ${OPENGL_LIBRARIES} ${PYTHON_LIBRARIES}) +target_link_libraries(_korlib HSPlasma ${Ogg_LIBRARIES} ${OPENGL_LIBRARIES} ${PYTHON_LIBRARIES} ${Vorbis_LIBRARIES}) diff --git a/korlib/cmake/FindOgg.cmake b/korlib/cmake/FindOgg.cmake new file mode 100644 index 0000000..f3071cd --- /dev/null +++ b/korlib/cmake/FindOgg.cmake @@ -0,0 +1,30 @@ +if(Ogg_INCLUDE_DIR AND Ogg_LIBRARY) + set(Ogg_FIND_QUIETLY TRUE) +endif() + + +find_path(Ogg_INCLUDE_DIR ogg/ogg.h + /usr/local/include + /usr/include +) + +find_library(Ogg_LIBRARY NAMES ogg + PATHS /usr/local/lib /usr/lib +) + +set(Ogg_LIBRARIES ${Ogg_LIBRARY}) + + +if(Ogg_INCLUDE_DIR AND Ogg_LIBRARY) + set(Ogg_FOUND TRUE) +endif() + +if (Ogg_FOUND) + if(NOT Ogg_FIND_QUIETLY) + message(STATUS "Found libogg: ${Ogg_INCLUDE_DIR}") + endif() +else() + if(Ogg_FIND_REQUIRED) + message(FATAL_ERROR "Could not find libogg") + endif() +endif() diff --git a/korlib/cmake/FindVorbis.cmake b/korlib/cmake/FindVorbis.cmake new file mode 100644 index 0000000..428d356 --- /dev/null +++ b/korlib/cmake/FindVorbis.cmake @@ -0,0 +1,37 @@ +if(Vorbis_INCLUDE_DIR AND Vorbis_LIBRARY) + set(Vorbis_FIND_QUIETLY TRUE) +endif() + + +find_path(Vorbis_INCLUDE_DIR vorbis/codec.h + /usr/local/include + /usr/include +) + +find_library(Vorbis_LIBRARY NAMES vorbis + PATHS /usr/local/lib /usr/lib +) + +find_library(VorbisFile_LIBRARY NAMES vorbisfile + PATHS /usr/local/lib /usr/lib +) + +set(Vorbis_LIBRARIES + ${Vorbis_LIBRARY} + ${VorbisFile_LIBRARY} +) + + +if(Vorbis_INCLUDE_DIR AND Vorbis_LIBRARY AND VorbisFile_LIBRARY) + set(Vorbis_FOUND TRUE) +endif() + +if (Vorbis_FOUND) + if(NOT Vorbis_FIND_QUIETLY) + message(STATUS "Found libvorbis: ${Vorbis_INCLUDE_DIR}") + endif() +else() + if(Vorbis_FIND_REQUIRED) + message(FATAL_ERROR "Could not find libvorbis") + endif() +endif() diff --git a/korlib/korlib.h b/korlib/korlib.h index ffbc0b6..b59e4a2 100644 --- a/korlib/korlib.h +++ b/korlib/korlib.h @@ -17,6 +17,8 @@ #ifndef _KORLIB_H #define _KORLIB_H +#define NOMINMAX + #include #include diff --git a/korlib/module.cpp b/korlib/module.cpp index 3c5697d..6ff987c 100644 --- a/korlib/module.cpp +++ b/korlib/module.cpp @@ -15,16 +15,23 @@ */ #include "buffer.h" +#include "sound.h" #include "texture.h" extern "C" { +static PyMethodDef korlib_Methods[] = { + { _pycs("inspect_vorbisfile"), (PyCFunction)inspect_vorbisfile, METH_VARARGS, NULL }, + + { NULL, NULL, 0, NULL }, +}; + static PyModuleDef korlib_Module = { PyModuleDef_HEAD_INIT, /* m_base */ "_korlib", /* m_name */ "C++ korlib implementation",/* m_doc */ 0, /* m_size */ - NULL, /* m_methods */ + korlib_Methods, /* m_methods */ NULL, /* m_reload */ NULL, /* m_traverse */ NULL, /* m_clear */ diff --git a/korlib/sound.cpp b/korlib/sound.cpp new file mode 100644 index 0000000..78fff01 --- /dev/null +++ b/korlib/sound.cpp @@ -0,0 +1,119 @@ +/* This file is part of Korman. + * + * Korman is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Korman is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Korman. If not, see . + */ + +#include "sound.h" + +#include +#include +#include + +static const int BITS_PER_SAMPLE = 16; + +extern "C" { + +typedef struct { + PyObject_HEAD + hsStream* fThis; + bool fPyOwned; +} pyStream; + +typedef struct { + PyObject_HEAD + plWAVHeader* fThis; + bool fPyOwned; +} pyWAVHeader; + +static size_t _read_stream(void* ptr, size_t size, size_t nmemb, void* datasource) { + hsStream* s = static_cast(datasource); + // hsStream is a bit overzealous protecting us against overreads, so we need to + // make sure to not tick it off. + size_t request = nmemb * size; + size_t remaining = s->size() - s->pos(); + size_t min = std::min(request, remaining); + if (min == 0) { + return 0; + } else { + size_t read = s->read(min, ptr); + return read / size; + } +} + +static int _seek_stream(void* datasource, ogg_int64_t offset, int whence) { + hsStream* s = static_cast(datasource); + switch (whence) { + case SEEK_CUR: + s->skip(offset); + break; + case SEEK_SET: + s->seek(offset); + break; + case SEEK_END: + s->seek(s->size() - offset); + break; + } + return 0; +} + +static long _tell_stream(void* datasource) { + hsStream* s = static_cast(datasource); + return s->pos(); +} + +static ov_callbacks s_callbacks = { + (size_t(*)(void *, size_t, size_t, void *))_read_stream, + (int (*)(void *, ogg_int64_t, int))_seek_stream, + (int (*)(void *))NULL, + (long (*)(void *))_tell_stream, +}; + +PyObject* inspect_vorbisfile(PyObject*, PyObject* args) { + pyStream* stream; + pyWAVHeader* header; + if (!PyArg_ParseTuple(args, "OO", &stream, &header)) { + PyErr_SetString(PyExc_TypeError, "inspect_vorbisfile expects an hsStream, plWAVHeader"); + return NULL; + } + + // The OGG file may actually be in Blender's memory, so we will use hsStream. Therefore, + // we must tell vorbisfile how to do this. + OggVorbis_File vorbis; + int result = ov_open_callbacks(stream->fThis, &vorbis, NULL, 0, s_callbacks); + if (result < 0) { + PyErr_Format(PyExc_RuntimeError, "vorbisfile ov_open_callbacks: %d", result); + return NULL; + } + + header->fThis->setFormatTag(plWAVHeader::kPCMFormatTag); + header->fThis->setBitsPerSample(BITS_PER_SAMPLE); + vorbis_info* info = ov_info(&vorbis, -1); + header->fThis->setNumChannels(info->channels); + header->fThis->setNumSamplesPerSec(info->rate); + unsigned short align = (BITS_PER_SAMPLE * info->channels) >> 3; + header->fThis->setBlockAlign(align); + header->fThis->setAvgBytesPerSec(info->rate * align); + + // This behavior was copied and pasted from CWE + ogg_int64_t size = (ov_pcm_total(&vorbis, -1) - 1) * align; + + // Cleanup + ov_clear(&vorbis); + + // We got the plWAVHeader from Python because we don't link against PyHSPlasma, only libHSPlasma + // Therefore, we only need to return the size. + return PyLong_FromSsize_t(size); +} + +}; diff --git a/korlib/sound.h b/korlib/sound.h new file mode 100644 index 0000000..c67eb1e --- /dev/null +++ b/korlib/sound.h @@ -0,0 +1,28 @@ +/* This file is part of Korman. + * + * Korman is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Korman is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Korman. If not, see . + */ + +#ifndef _KORLIB_SOUND_H +#define _KORLIB_SOUND_H + +#include "korlib.h" + +extern "C" { + +PyObject* inspect_vorbisfile(PyObject*, PyObject* args); + +}; + +#endif // _KORLIB_SOUND_H diff --git a/korman/korlib/__init__.py b/korman/korlib/__init__.py index a2c2644..4c3fbe6 100644 --- a/korman/korlib/__init__.py +++ b/korman/korlib/__init__.py @@ -17,3 +17,20 @@ try: from _korlib import * except ImportError: from .texture import * + + def inspect_voribsfile(stream, header): + raise NotImplementedError("Ogg Vorbis not supported unless _korlib is compiled") + + def inspect_wavefile(stream, header): + assert stream.read(4) == b"RIFF" + stream.readInt() + assert stream.read(4) == b"WAVE" + assert stream.read(3) == b"fmt" + header.read(stream) + + # read thru the chunks until we find "data" + while stream.read(4) != b"data" and not stream.eof(): + stream.skip(stream.readInt()) + assert not stream.eof() + size = stream.readInt() + return (header, size) diff --git a/korman/nodes/node_messages.py b/korman/nodes/node_messages.py index 7933f79..e500637 100644 --- a/korman/nodes/node_messages.py +++ b/korman/nodes/node_messages.py @@ -482,6 +482,131 @@ class PlasmaSceneObjectMsgRcvrNode(PlasmaNodeBase, bpy.types.Node): return ref_so_key +class PlasmaSoundMsgNode(PlasmaMessageNode, bpy.types.Node): + bl_category = "MSG" + bl_idname = "PlasmaSoundMsgNode" + bl_label = "Sound" + bl_width_default = 190 + + object_name = StringProperty(name="Object", + description="Sound emitter object") + sound_name = StringProperty(name="Sound", + description="Sound datablock") + + go_to = EnumProperty(name="Go To", + description="Where should the sound start?", + items=[("BEGIN", "Beginning", "The beginning"), + ("CURRENT", "(Don't Change)", "The current position"), + ("TIME", "Time", "The time specified in seconds")], + default="CURRENT") + looping = EnumProperty(name="Looping", + description="Is the sound looping?", + items=[("kSetLooping", "Yes", "The sound is looping",), + ("CURRENT", "(Don't Change)", "Don't change the loop status"), + ("kSetUnLooping", "No", "The sound is NOT looping")], + default="CURRENT") + action = EnumProperty(name="Action", + description="What do you want the sound to do?", + items=[("kPlay", "Play", "Plays the sound"), + ("kStop", "Stop", "Stops the sound",), + ("kToggleState", "Toggle", "Toggles between Play and Stop"), + ("CURRENT", "(Don't Change)", "Don't change the sound's playing state")], + default="CURRENT") + volume = EnumProperty(name="Volume", + description="What should happen to the volume?", + items=[("MUTE", "Mute", "Mutes the volume"), + ("CURRENT", "(Don't Change)", "Don't change the volume"), + ("CUSTOM", "Custom", "Manually specify the volume")], + default="CURRENT") + + time = FloatProperty(name="Time", + description="Time in seconds to begin playing from", + min=0.0, default=0.0, + options=set(), subtype="TIME", unit="TIME") + volume_pct = IntProperty(name="Volume Level", + description="Volume to play the sound", + min=0, max=100, default=100, + options=set(), + subtype="PERCENTAGE") + + def convert_callback_message(self, exporter, so, msg, target, wait): + cb = plEventCallbackMsg() + cb.addReceiver(target) + cb.event = kEnd + cb.user = wait + msg.addCallback(cb) + msg.setCmd(plSoundMsg.kAddCallbacks) + + def convert_message(self, exporter, so): + sound_bo = bpy.data.objects.get(self.object_name, None) + if sound_bo is None: + self.raise_error("'{}' is not a valid object".format(self.object_name)) + soundemit = sound_bo.plasma_modifiers.soundemit + if not soundemit.enabled: + self.raise_error("'{}' is not a valid Sound Emitter".format(self.object_name)) + + # Always test the specified audible for validity + if self.sound_name and soundemit.sounds.get(self.sound_name, None) is None: + self.raise_error("Invalid Sound '{}' requested from Sound Emitter '{}'".format(self.sound_name, self.object_name)) + + # Remember that 3D stereo sounds are exported as two emitters... + # But, if we only have one sound attached, who cares, we can just address the message to all + audible_key = exporter.mgr.find_create_key(plAudioInterface, bl=sound_bo) + indices = (-1,) if not self.sound_name or len(soundemit.sounds) == 1 else soundemit.get_sound_indices(self.sound_name) + for idx in indices: + msg = plSoundMsg() + msg.addReceiver(audible_key) + msg.index = idx + + # NOTE: There are a number of commands in Plasma's enumeration that do nothing. + # This is what I determine to be the most useful and functional subset... + # Please see plAudioInterface::MsgReceive for more details. + if self.go_to == "BEGIN": + msg.setCmd(plSoundMsg.kGoToTime) + msg.time = 0.0 + elif self.go_to == "TIME": + msg.setCmd(plSoundMsg.kGoToTime) + msg.time = self.time + + if self.volume == "MUTE": + msg.setCmd(plSoundMsg.kSetVolume) + msg.volume = 0.0 + elif self.volume == "CUSTOM": + msg.setCmd(plSoundMsg.kSetVolume) + msg.volume = self.volume_pct + + if self.looping != "CURRENT": + msg.setCmd(getattr(plSoundMsg, self.looping)) + if self.action != "CURRENT": + msg.setCmd(getattr(plSoundMsg, self.action)) + + # Because we might be giving two messages here... + yield msg + + def draw_buttons(self, context, layout): + layout.prop_search(self, "object_name", bpy.data, "objects") + bo = bpy.data.objects.get(self.object_name, None) + if bo is not None: + soundemit = bo.plasma_modifiers.soundemit + if soundemit.enabled: + layout.prop_search(self, "sound_name", soundemit, "sounds", icon="SOUND") + else: + layout.label("Not a Sound Emitter", icon="ERROR") + + layout.prop(self, "go_to") + if self.go_to == "TIME": + layout.prop(self, "time") + layout.prop(self, "action") + if self.volume == "CUSTOM": + layout.prop(self, "volume_pct") + layout.prop(self, "looping") + layout.prop(self, "volume") + + @property + def has_callbacks(self): + return True + + class PlasmaTimerCallbackMsgNode(PlasmaMessageNode, bpy.types.Node): bl_category = "MSG" bl_idname = "PlasmaTimerCallbackMsgNode" diff --git a/korman/nodes/node_responder.py b/korman/nodes/node_responder.py index ca16368..a052eb3 100644 --- a/korman/nodes/node_responder.py +++ b/korman/nodes/node_responder.py @@ -16,6 +16,7 @@ import bpy from bpy.props import * from collections import OrderedDict +import inspect from PyHSPlasma import * import uuid @@ -230,16 +231,28 @@ class PlasmaResponderCommandNode(PlasmaNodeBase, bpy.types.Node): ]) def convert_command(self, exporter, so, responder, commandMgr, waitOn=-1): - # If this command has no message, there is no need to export it... - msgNode = self.find_output("msg") - if msgNode is not None: + def prepare_message(exporter, so, responder, commandMgr, waitOn, msg): idx, command = commandMgr.add_command(self, waitOn) - - # Finally, convert our message... - msg = msgNode.convert_message(exporter, so) if msg.sender is None: msg.sender = responder.key msg.BCastFlags |= plMessage.kLocalPropagate + command.msg = msg + return (idx, command) + + # If this command has no message, there is no need to export it... + msgNode = self.find_output("msg") + if msgNode is not None: + # HACK: Some message nodes may need to sneakily send multiple messages. So, convert_message + # is therefore now a generator. We will ASSume that the first message generated is the + # primary msg that we should use for callbacks, if applicable + if inspect.isgeneratorfunction(msgNode.convert_message): + messages = tuple(msgNode.convert_message(exporter, so)) + msg = messages[0] + for i in messages[1:]: + prepare_message(exporter, so, responder, commandMgr, waitOn, i) + else: + msg = msgNode.convert_message(exporter, so) + idx, command = prepare_message(exporter, so, responder, commandMgr, waitOn, msg) # If we have child commands, we need to make sure that we support chaining this message as a callback # If not, we'll export our children and tell them to not actually wait on us. @@ -249,7 +262,6 @@ class PlasmaResponderCommandNode(PlasmaNodeBase, bpy.types.Node): msgNode.convert_callback_message(exporter, so, msg, responder.key, childWaitOn) else: childWaitOn = waitOn - command.msg = msg else: childWaitOn = waitOn diff --git a/korman/operators/__init__.py b/korman/operators/__init__.py index 3e35852..ef4e5c4 100644 --- a/korman/operators/__init__.py +++ b/korman/operators/__init__.py @@ -17,6 +17,7 @@ from . import op_export as exporter from . import op_lightmap as lightmap from . import op_modifier as modifier from . import op_nodes as nodes +from . import op_sound as sound from . import op_texture as texture from . import op_toolbox as toolbox from . import op_world as world diff --git a/korman/operators/op_sound.py b/korman/operators/op_sound.py new file mode 100644 index 0000000..44b6adc --- /dev/null +++ b/korman/operators/op_sound.py @@ -0,0 +1,88 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +import bpy +from bpy.props import * + +class SoundOperator: + @classmethod + def poll(cls, context): + return context.scene.render.engine == "PLASMA_GAME" + + +class PlasmaSoundOpenOperator(SoundOperator, bpy.types.Operator): + bl_idname = "sound.plasma_open" + bl_label = "Load Sound" + bl_options = {"INTERNAL"} + + filter_glob = StringProperty(default="*.ogg;*.wav", options={"HIDDEN"}) + filepath = StringProperty(subtype="FILE_PATH") + + data_path = StringProperty(options={"HIDDEN"}) + sound_property = StringProperty(options={"HIDDEN"}) + + def execute(self, context): + # Check to see if the sound exists... Because the sneakily introduced bpy.data.sounds.load + # check_existing doesn't tell us if it already exists... dammit... + # We don't want to take ownership forcefully if we don't have to. + for i in bpy.data.sounds: + if self.filepath == i.filepath: + sound = i + break + else: + sound = bpy.data.sounds.load(self.filepath) + sound.plasma_owned = True + sound.use_fake_user = True + + # Now do the stanky leg^H^H^H^H^H^H^H^H^H^H deed and put the sound on the mod + dest = eval(self.data_path) + setattr(dest, self.sound_property, sound.name) + return {"FINISHED"} + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + +class PlasmaSoundPackOperator(SoundOperator, bpy.types.Operator): + bl_idname = "sound.plasma_pack" + bl_label = "Pack" + bl_options = {"INTERNAL"} + + def execute(self, context): + soundemit = context.active_object.plasma_modifiers.soundemit + sound = bpy.data.sounds.get(soundemit.sounds[soundemit.active_sound_index].sound_data) + sound.pack() + return {"FINISHED"} + + +class PlasmaSoundUnpackOperator(SoundOperator, bpy.types.Operator): + bl_idname = "sound.plasma_unpack" + bl_label = "Unpack" + bl_options = {"INTERNAL"} + + method = EnumProperty(name="Method", description="How to unpack", + # See blender/makesrna/intern/rna_packedfile.c + items=[("USE_LOCAL", "Use local file", "", 5), + ("WRITE_LOCAL", "Write Local File (overwrite existing)", "", 4), + ("USE_ORIGINAL", "Use Original File", "", 6), + ("WRITE_ORIGINAL", "Write Original File (overwrite existing)", "", 3)], + options=set()) + + def execute(self, context): + soundemit = context.active_object.plasma_modifiers.soundemit + sound = bpy.data.sounds.get(soundemit.sounds[soundemit.active_sound_index].sound_data) + sound.unpack(self.method) + return {"FINISHED"} diff --git a/korman/properties/modifiers/__init__.py b/korman/properties/modifiers/__init__.py index 6223ac8..938e333 100644 --- a/korman/properties/modifiers/__init__.py +++ b/korman/properties/modifiers/__init__.py @@ -22,6 +22,7 @@ from .logic import * from .physics import * from .region import * from .render import * +from .sound import * from .water import * class PlasmaModifiers(bpy.types.PropertyGroup): diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py new file mode 100644 index 0000000..a641141 --- /dev/null +++ b/korman/properties/modifiers/sound.py @@ -0,0 +1,384 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +import bpy +from bpy.props import * +from bpy.app.handlers import persistent +import math +from pathlib import Path +from PyHSPlasma import * + +from ... import korlib +from .base import PlasmaModifierProperties +from ...exporter import ExportError + +class PlasmaSfxFade(bpy.types.PropertyGroup): + fade_type = EnumProperty(name="Type", + description="Fade Type", + items=[("NONE", "[Disable]", "Don't fade"), + ("kLinear", "Linear", "Linear fade"), + ("kLogarithmic", "Logarithmic", "Log fade"), + ("kExponential", "Exponential", "Exponential fade")], + options=set()) + length = FloatProperty(name="Length", + description="Seconds to spend fading", + default=1.0, min=0.0, + options=set(), subtype="TIME", unit="TIME") + + +class PlasmaSound(bpy.types.PropertyGroup): + def _sound_picked(self, context): + if not self.sound_data: + self.name = "[Empty]" + return + + try: + header, size = self._get_sound_info() + except Exception as e: + self.is_valid = False + # this might be perfectly acceptable... who knows? + # user consumable error report to be handled by the UI code + print("---Invalid SFX selection---\n{}\n------".format(str(e))) + else: + self.is_valid = True + self.is_stereo = header.numChannels == 2 + self._update_name(context) + + def _update_name(self, context): + if self.is_stereo and self.channel != {"L", "R"}: + self.name = "{}:{}".format(self.sound_data, "L" if "L" in self.channel else "R") + else: + self.name = self.sound_data + + enabled = BoolProperty(name="Enabled", default=True, options=set()) + sound_data = StringProperty(name="Sound", description="Sound Datablock", + options=set(), update=_sound_picked) + + is_stereo = BoolProperty(default=True, options={"HIDDEN"}) + is_valid = BoolProperty(default=False, options={"HIDDEN"}) + + soft_region = StringProperty(name="Soft Volume", + description="Soft region this sound can be heard in", + options=set()) + + sfx_type = EnumProperty(name="Category", + description="Describes the purpose of this sound", + items=[("kSoundFX", "3D", "3D Positional SoundFX"), + ("kAmbience", "Ambience", "Ambient Sounds"), + ("kBackgroundMusic", "Music", "Background Music"), + ("kGUISound", "GUI", "GUI Effect"), + ("kNPCVoices", "NPC", "NPC Speech")], + options=set()) + channel = EnumProperty(name="Channel", + description="Which channel(s) to play", + items=[("L", "Left", "Left Channel"), + ("R", "Right", "Right Channel")], + options={"ENUM_FLAG"}, + default={"L", "R"}, + update=_update_name) + + auto_start = BoolProperty(name="Auto Start", + description="Start playing when the age is loaded", + default=False, + options=set()) + incidental = BoolProperty(name="Incidental", + description="Sound is a low-priority incident and the engine may forgo playback", + default=False, + options=set()) + loop = BoolProperty(name="Loop", + description="Loop the sound", + default=False, + options=set()) + + inner_cone = FloatProperty(name="Inner Angle", + description="Angle of the inner cone from the negative Z-axis", + min=0, max=math.radians(360), default=0, step=100, + options=set(), + subtype="ANGLE") + outer_cone = FloatProperty(name="Outer Angle", + description="Angle of the outer cone from the negative Z-axis", + min=0, max=math.radians(360), default=math.radians(360), step=100, + options=set(), + subtype="ANGLE") + outside_volume = IntProperty(name="Outside Volume", + description="Sound's volume when outside the outer cone", + min=0, max=100, default=100, + options=set(), + subtype="PERCENTAGE") + + min_falloff = IntProperty(name="Begin Falloff", + description="Distance where volume attenuation begins", + min=0, max=1000000000, default=1, + options=set(), + subtype="DISTANCE") + max_falloff = IntProperty(name="End Falloff", + description="Distance where the sound is inaudible", + min=0, max=1000000000, default=1000, + options=set(), + subtype="DISTANCE") + volume = IntProperty(name="Volume", + description="Volume to play the sound", + min=0, max=100, default=100, + options=set(), + subtype="PERCENTAGE") + + fade_in = PointerProperty(type=PlasmaSfxFade, options=set()) + fade_out = PointerProperty(type=PlasmaSfxFade, options=set()) + + @property + def channel_override(self): + if self.is_stereo and len(self.channel) == 1: + return min(self.channel) + else: + return None + + def convert_sound(self, exporter, so, audible): + header, dataSize = self._get_sound_info() + length = dataSize / header.avgBytesPerSec + + # There is some bug in the MOUL code that causes a crash if this does not match the expected + # result. There's no sense in debugging that though--the user should never specify + # streaming vs static. That's an implementation detail. + pClass = plWin32StreamingSound if length > 4.0 else plWin32StaticSound + + # OK. Any Plasma engine that uses OpenAL (MOUL) is subject to this restriction. + # 3D Positional audio MUST... and I mean MUST... have mono emitters. + # That means if the user has specified 3D and a stereo sound AND both channels, we MUST + # export two emitters from here. Otherwise, it's no biggie. Wheeeeeeeeeeeeeeeeeeeeeeeee + if self.is_3d_stereo or (self.is_stereo and len(self.channel) == 1): + header.avgBytesPerSec = int(header.avgBytesPerSec / 2) + header.numChannels = int(header.numChannels / 2) + header.blockAlign = int(header.blockAlign / 2) + dataSize = int(dataSize / 2) + if self.is_3d_stereo: + audible.addSound(self._convert_sound(exporter, so, pClass, header, dataSize, channel="L")) + audible.addSound(self._convert_sound(exporter, so, pClass, header, dataSize, channel="R")) + else: + audible.addSound(self._convert_sound(exporter, so, pClass, header, dataSize, channel=self.channel_override)) + + def _convert_sound(self, exporter, so, pClass, wavHeader, dataSize, channel=None): + if channel is None: + name = "Sfx-{}_{}".format(so.key.name, self.sound_data) + else: + name = "Sfx-{}_{}:{}".format(so.key.name, self.sound_data, channel) + print(" [{}] {}".format(pClass.__name__[2:], name)) + sound = exporter.mgr.find_create_object(pClass, so=so, name=name) + + # If this object is a soft volume itself, we will use our own soft region. + # Otherwise, check what they specified... + sv_mod, sv_key = self.id_data.plasma_modifiers.softvolume, None + if sv_mod.enabled: + sv_key = sv_mod.get_key(exporter, so) + elif self.soft_region: + sv_bo = bpy.data.objects.get(self.soft_region, None) + if sv_bo is None: + raise ExportError("'{}': Invalid object '{}' for SoundEmit '{}' soft volume".format(self.id_data.name, self.soft_region, self.sound_data)) + sv_mod = sv_bo.plasma_modifiers.softvolume + if not sv_mod.enabled: + raise ExportError("'{}': SoundEmit '{}', '{}' is not a SoftVolume".format(self.id_data.name, self.sound_data, self.soft_region)) + sv_key = sv_mod.get_key(exporter) + if sv_key is not None: + sv_key.object.listenState |= plSoftVolume.kListenCheck | plSoftVolume.kListenDirty | plSoftVolume.kListenRegistered + sound.softRegion = sv_key + + # Sound + sound.type = getattr(plSound, self.sfx_type) + if sound.type == plSound.kSoundFX: + sound.properties |= plSound.kPropIs3DSound + if self.auto_start: + sound.properties |= plSound.kPropAutoStart + if self.loop: + sound.properties |= plSound.kPropLooping + if self.incidental: + sound.properties |= plSound.kPropIncidental + sound.dataBuffer = self._find_sound_buffer(exporter, so, wavHeader, dataSize, channel) + + # Cone effect + # I have observed that Blender 2.77's UI doesn't show the appropriate unit (degrees) for + # IntProperty angle subtypes. So, we're storing the angles as floats in Blender even though + # Plasma only wants integers. Sigh. + sound.innerCone = int(math.degrees(self.inner_cone)) + sound.outerCone = int(math.degrees(self.outer_cone)) + sound.outerVol = self.outside_volume + + # Falloff + sound.desiredVolume = self.volume / 100.0 + sound.minFalloff = self.min_falloff + sound.maxFalloff = self.max_falloff + + # Fade FX + fade_in, fade_out = sound.fadeInParams, sound.fadeOutParams + for blfade, plfade in ((self.fade_in, fade_in), (self.fade_out, fade_out)): + if blfade.fade_type == "NONE": + plfade.lengthInSecs = 0.0 + else: + plfade.lengthInSecs = blfade.length + plfade.type = getattr(plFadeParams, blfade.fade_type) + plfade.currTime = -1.0 + + # Some manual fiddling -- this is hidden deep inside the 3dsm exporter... + # Kind of neat how it's all generic though :) + fade_in.volStart = 0.0 + fade_in.volEnd = 1.0 + fade_out.volStart = 1.0 + fade_out.volEnd = 0.0 + fade_out.stopWhenDone = True + + # Some last minute buffer tweaking based on our props here... + buffer = sound.dataBuffer.object + if isinstance(sound, plWin32StreamingSound): + buffer.flags |= plSoundBuffer.kStreamCompressed + if sound.type == plSound.kBackgroundMusic: + buffer.flags |= plSoundBuffer.kAlwaysExternal + + # Win32Sound + if channel == "L": + sound.channel = plWin32Sound.kLeftChannel + else: + sound.channel = plWin32Sound.kRightChannel + + # Whew, that was a lot of work! + return sound.key + + def _get_sound_info(self): + """Generates a tuple (plWAVHeader, PCMsize) from the current sound""" + sound = self._sound + if sound.packed_file is None: + stream = hsFileStream() + try: + stream.open(sound.filepath, fmRead) + except IOError: + self._raise_error("failed to open file") + else: + stream = hsRAMStream() + stream.buffer = sound.packed_file.data + + try: + magic = stream.read(4) + stream.rewind() + + header = plWAVHeader() + if magic == b"RIFF": + size = korlib.inspect_wavefile(stream, header) + elif magic == b"OggS": + size = korlib.inspect_vorbisfile(stream, header) + return (header, size) + else: + raise NotSupportedError("unsupported audio format") + except Exception as e: + self._raise_error(str(e)) + finally: + stream.close() + + def _find_sound_buffer(self, exporter, so, wavHeader, dataSize, channel): + # First, cleanup the file path to not have directories and have the .ogg extension + filename = Path(self._sound.filepath).with_suffix(".ogg").name + if channel is None: + key_name = filename + else: + key_name = "{}:{}".format(filename, channel) + + key = exporter.mgr.find_key(plSoundBuffer, so=so, name=key_name) + if key is None: + sound = exporter.mgr.add_object(plSoundBuffer, so=so, name=key_name) + sound.header = wavHeader + sound.fileName = filename + sound.dataLength = dataSize + # Maybe someday we will allow packed sounds? I'm in no hurry... + sound.flags |= plSoundBuffer.kIsExternal + if channel == "L": + sound.flags |= plSoundBuffer.kOnlyLeftChannel + elif channel == "R": + sound.flags |= plSoundBuffer.kOnlyRightChannel + key = sound.key + return key + + @property + def is_3d_stereo(self): + return self.sfx_type == "kSoundFX" and self.channel == {"L", "R"} and self.is_stereo + + def _raise_error(self, msg): + raise ExportError("SoundEmitter '{}': Sound '{}' {}".format(self.id_data.name, self.sound_data, msg)) + + @property + def _sound(self): + try: + sound = bpy.data.sounds.get(self.sound_data) + except: + self._raise_error("is not loaded") + else: + return sound + + +class PlasmaSoundEmitter(PlasmaModifierProperties): + pl_id = "soundemit" + + bl_category = "Logic" + bl_label = "Sound Emitter" + bl_description = "Point at which sound(s) are played" + bl_icon = "SPEAKER" + + sounds = CollectionProperty(type=PlasmaSound) + active_sound_index = IntProperty(options={"HIDDEN"}) + + def export(self, exporter, bo, so): + winaud = exporter.mgr.find_create_object(plWinAudible, so=so, name=self.key_name) + winaud.sceneNode = exporter.mgr.get_scene_node(so.key.location) + aiface = exporter.mgr.find_create_object(plAudioInterface, so=so, name=self.key_name) + aiface.audible = winaud.key + + # Pass this off to each individual sound for conversion + for i in self.sounds: + if i.sound_data and i.enabled: + i.convert_sound(exporter, so, winaud) + + def get_sound_indices(self, name): + """Returns the index of the given sound in the plWin32Sound. This is needed because stereo + 3D sounds export as two mono sound objects -- wheeeeee""" + idx = 0 + for i in self.sounds: + if i.name == name: + yield idx + if i.is_3d_stereo: + yield idx + 1 + break + else: + idx += 2 if i.is_3d_stereo else 1 + else: + raise ValueError(name) + + @classmethod + def register(cls): + bpy.types.Sound.plasma_owned = BoolProperty(default=False, options={"HIDDEN"}) + + @property + def requires_actor(self): + return True + + +@persistent +def _toss_orphaned_sounds(scene): + used_sounds = set() + for i in bpy.data.objects: + soundemit = i.plasma_modifiers.soundemit + used_sounds.update((j.sound_data for j in soundemit.sounds)) + dead_sounds = [i for i in bpy.data.sounds if i.plasma_owned and i.name not in used_sounds] + for i in dead_sounds: + i.use_fake_user = False + i.user_clear() + bpy.data.sounds.remove(i) + +# collects orphaned Plasma owned sound datablocks +bpy.app.handlers.save_pre.append(_toss_orphaned_sounds) diff --git a/korman/ui/modifiers/__init__.py b/korman/ui/modifiers/__init__.py index 5cf2fe7..ffbf492 100644 --- a/korman/ui/modifiers/__init__.py +++ b/korman/ui/modifiers/__init__.py @@ -19,4 +19,5 @@ from .logic import * from .physics import * from .region import * from .render import * +from .sound import * from .water import * diff --git a/korman/ui/modifiers/sound.py b/korman/ui/modifiers/sound.py new file mode 100644 index 0000000..becabc9 --- /dev/null +++ b/korman/ui/modifiers/sound.py @@ -0,0 +1,105 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +import bpy + +def _draw_fade_ui(modifier, layout, label): + layout.label(label) + layout.prop(modifier, "fade_type", text="") + layout.prop(modifier, "length") + +class SoundListUI(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0): + if item.sound_data: + sound = bpy.data.sounds.get(item.sound_data) + icon = "SOUND" if sound is not None else "ERROR" + layout.prop(item, "name", emboss=False, icon=icon, text="") + layout.prop(item, "enabled", text="") + else: + layout.label("[Empty]") + + +def soundemit(modifier, layout, context): + row = layout.row() + row.template_list("SoundListUI", "sounds", modifier, "sounds", modifier, "active_sound_index", + rows=2, maxrows=3) + col = row.column(align=True) + op = col.operator("object.plasma_modifier_collection_add", icon="ZOOMIN", text="") + op.modifier = modifier.pl_id + op.collection = "sounds" + op = col.operator("object.plasma_modifier_collection_remove", icon="ZOOMOUT", text="") + op.modifier = modifier.pl_id + op.collection = "sounds" + op.index = modifier.active_sound_index + + try: + sound = modifier.sounds[modifier.active_sound_index] + except: + pass + else: + # Sound datablock picker + row = layout.row(align=True) + row.prop_search(sound, "sound_data", bpy.data, "sounds", text="") + open_op = row.operator("sound.plasma_open", icon="FILESEL", text="") + open_op.data_path = repr(sound) + open_op.sound_property = "sound_data" + + # Pack/Unpack + data = bpy.data.sounds.get(sound.sound_data) + if data is not None: + if data.packed_file is None: + row.operator("sound.plasma_pack", icon="UGLYPACKAGE", text="") + else: + row.operator_menu_enum("sound.plasma_unpack", "method", icon="PACKAGE", text="") + + # If an invalid sound data block is spec'd, let them know about it. + if sound.sound_data and not sound.is_valid: + layout.label(text="Invalid sound specified", icon="ERROR") + + # Core Props + row = layout.row() + row.prop(sound, "sfx_type", text="") + row.prop_menu_enum(sound, "channel") + + split = layout.split() + col = split.column() + col.label("Playback:") + col.prop(sound, "auto_start") + col.prop(sound, "incidental") + col.prop(sound, "loop") + + col.separator() + _draw_fade_ui(sound.fade_in, col, "Fade In:") + col.separator() + _draw_fade_ui(sound.fade_out, col, "Fade Out:") + + col = split.column() + col.label("Cone Effect:") + col.prop(sound, "inner_cone") + col.prop(sound, "outer_cone") + col.prop(sound, "outside_volume", text="Volume") + + col.separator() + col.label("Volume Falloff:") + col.prop(sound, "min_falloff", text="Begin") + col.prop(sound, "max_falloff", text="End") + col.prop(sound, "volume", text="Max Volume") + + # Only allow SoftVolume spec if this is not an FX and this object is not an SV itself + sv = modifier.id_data.plasma_modifiers.softvolume + if not sv.enabled: + col.separator() + col.label("Soft Region:") + col.prop_search(sound, "soft_region", bpy.data, "objects", text="")