From b29f4ebf75b93f218a45cc685ac94307b6b6da20 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 21 Feb 2016 16:06:11 -0500 Subject: [PATCH 1/4] Implement Plasma Sound loading This allows us to use Blender's Sound datablocks, despite it actively opposing our attempts to do so. Meaning the artist can pack their sounds. :) --- korman/operators/__init__.py | 1 + korman/operators/op_sound.py | 88 +++++++++++++++++++++++++ korman/properties/modifiers/__init__.py | 1 + korman/properties/modifiers/sound.py | 64 ++++++++++++++++++ korman/ui/modifiers/__init__.py | 1 + korman/ui/modifiers/sound.py | 60 +++++++++++++++++ 6 files changed, 215 insertions(+) create mode 100644 korman/operators/op_sound.py create mode 100644 korman/properties/modifiers/sound.py create mode 100644 korman/ui/modifiers/sound.py 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..b315f91 --- /dev/null +++ b/korman/properties/modifiers/sound.py @@ -0,0 +1,64 @@ +# 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 +from PyHSPlasma import * + +from .base import PlasmaModifierProperties +from ...exporter import ExportError + +class PlasmaSound(bpy.types.PropertyGroup): + enabled = BoolProperty(name="Enabled", default=True, options=set()) + sound_data = StringProperty(name="Sound", description="Sound Datablock", options=set()) + + +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): + pass + + @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)) + for i in bpy.data.sounds: + if i.plasma_owned and i.name not in used_sounds: + i.use_fake_user = False + i.user_clear() + +# 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..feabe39 --- /dev/null +++ b/korman/ui/modifiers/sound.py @@ -0,0 +1,60 @@ +# 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 + +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, "sound_data", 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="") From 36f0ac194da9a6e50d3bf5dcbeeabbb5776fe6da Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 30 May 2016 21:07:11 -0400 Subject: [PATCH 2/4] Implement SoundEmit modifier This sound emitter modifier is almost as fully functional as PlasmaMAX's various sound emitter components. Additional functionality was added to C korlib so that artists can specify OGG Vorbis sound files. If korlib is not compiled, only WAVE sounds can be utilized in Korman. This fixes some of the more fiddly bugs related to exporting to CWE that were seen in PyPRP. Sound nodes to be implemented... --- korlib/CMakeLists.txt | 10 +- korlib/cmake/FindOgg.cmake | 30 +++ korlib/cmake/FindVorbis.cmake | 37 ++++ korlib/korlib.h | 2 + korlib/module.cpp | 9 +- korlib/sound.cpp | 119 ++++++++++ korlib/sound.h | 28 +++ korman/korlib/__init__.py | 17 ++ korman/properties/modifiers/sound.py | 317 ++++++++++++++++++++++++++- korman/ui/modifiers/sound.py | 47 +++- 10 files changed, 607 insertions(+), 9 deletions(-) create mode 100644 korlib/cmake/FindOgg.cmake create mode 100644 korlib/cmake/FindVorbis.cmake create mode 100644 korlib/sound.cpp create mode 100644 korlib/sound.h 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/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py index b315f91..57b7d6b 100644 --- a/korman/properties/modifiers/sound.py +++ b/korman/properties/modifiers/sound.py @@ -16,14 +16,310 @@ 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()) + 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): @@ -38,7 +334,15 @@ class PlasmaSoundEmitter(PlasmaModifierProperties): active_sound_index = IntProperty(options={"HIDDEN"}) def export(self, exporter, bo, so): - pass + 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) @classmethod def register(cls): @@ -55,10 +359,11 @@ def _toss_orphaned_sounds(scene): for i in bpy.data.objects: soundemit = i.plasma_modifiers.soundemit used_sounds.update((j.sound_data for j in soundemit.sounds)) - for i in bpy.data.sounds: - if i.plasma_owned and i.name not in used_sounds: - i.use_fake_user = False - i.user_clear() + 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/sound.py b/korman/ui/modifiers/sound.py index feabe39..becabc9 100644 --- a/korman/ui/modifiers/sound.py +++ b/korman/ui/modifiers/sound.py @@ -15,12 +15,17 @@ 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, "sound_data", emboss=False, icon=icon, text="") + layout.prop(item, "name", emboss=False, icon=icon, text="") layout.prop(item, "enabled", text="") else: layout.label("[Empty]") @@ -58,3 +63,43 @@ def soundemit(modifier, layout, context): 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="") From 8e262f815c11bb26ee5a57e32b3c20efea8165e7 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 19 Jun 2016 19:20:27 -0400 Subject: [PATCH 3/4] Allow message nodes to export multiple messages This shim is needed in some cases for plSoundMsg --- korman/nodes/node_responder.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) 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 From fd3026021a6ae9928369de8b7d1cecb03419e2ea Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 19 Jun 2016 19:20:57 -0400 Subject: [PATCH 4/4] Implement Sound logic nodes --- korman/nodes/node_messages.py | 125 +++++++++++++++++++++++++++ korman/properties/modifiers/sound.py | 15 ++++ 2 files changed, 140 insertions(+) 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/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py index 57b7d6b..a641141 100644 --- a/korman/properties/modifiers/sound.py +++ b/korman/properties/modifiers/sound.py @@ -344,6 +344,21 @@ class PlasmaSoundEmitter(PlasmaModifierProperties): 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"})