Browse Source

Merge pull request #35 from Hoikas/sounds

Sounds
pull/38/head
Adam Johnson 9 years ago committed by GitHub
parent
commit
b1db07132a
  1. 10
      korlib/CMakeLists.txt
  2. 30
      korlib/cmake/FindOgg.cmake
  3. 37
      korlib/cmake/FindVorbis.cmake
  4. 2
      korlib/korlib.h
  5. 9
      korlib/module.cpp
  6. 119
      korlib/sound.cpp
  7. 28
      korlib/sound.h
  8. 17
      korman/korlib/__init__.py
  9. 125
      korman/nodes/node_messages.py
  10. 26
      korman/nodes/node_responder.py
  11. 1
      korman/operators/__init__.py
  12. 88
      korman/operators/op_sound.py
  13. 1
      korman/properties/modifiers/__init__.py
  14. 384
      korman/properties/modifiers/sound.py
  15. 1
      korman/ui/modifiers/__init__.py
  16. 105
      korman/ui/modifiers/sound.py

10
korlib/CMakeLists.txt

@ -1,6 +1,8 @@
project(korlib) project(korlib)
cmake_minimum_required(VERSION 3.0) cmake_minimum_required(VERSION 3.0)
set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
# Stolen shamelessly from PyHSPlasma # Stolen shamelessly from PyHSPlasma
find_package(PythonLibs REQUIRED) find_package(PythonLibs REQUIRED)
find_package(PythonInterp "${PYTHONLIBS_VERSION_STRING}" REQUIRED) find_package(PythonInterp "${PYTHONLIBS_VERSION_STRING}" REQUIRED)
@ -10,24 +12,30 @@ if (NOT "${PYTHONLIBS_VERSION_STRING}" STREQUAL "${PYTHON_VERSION_STRING}")
endif() endif()
find_package(HSPlasma REQUIRED) find_package(HSPlasma REQUIRED)
find_package(Ogg REQUIRED)
find_package(OpenGL REQUIRED) find_package(OpenGL REQUIRED)
find_package(Vorbis REQUIRED)
# Da files # Da files
set(korlib_HEADERS set(korlib_HEADERS
buffer.h buffer.h
korlib.h korlib.h
sound.h
texture.h texture.h
) )
set(korlib_SOURCES set(korlib_SOURCES
buffer.cpp buffer.cpp
module.cpp module.cpp
sound.cpp
texture.cpp texture.cpp
) )
include_directories(${HSPlasma_INCLUDE_DIRS}) include_directories(${HSPlasma_INCLUDE_DIRS})
include_directories(${Ogg_INCLUDE_DIR})
include_directories(${OPENGL_INCLUDE_DIR}) include_directories(${OPENGL_INCLUDE_DIR})
include_directories(${PYTHON_INCLUDE_DIRS}) include_directories(${PYTHON_INCLUDE_DIRS})
include_directories(${Vorbis_INCLUDE_DIR})
add_library(_korlib SHARED ${korlib_HEADERS} ${korlib_SOURCES}) add_library(_korlib SHARED ${korlib_HEADERS} ${korlib_SOURCES})
if(NOT WIN32) if(NOT WIN32)
@ -35,4 +43,4 @@ if(NOT WIN32)
else() else()
set_target_properties(_korlib PROPERTIES SUFFIX ".pyd") set_target_properties(_korlib PROPERTIES SUFFIX ".pyd")
endif() endif()
target_link_libraries(_korlib HSPlasma ${OPENGL_LIBRARIES} ${PYTHON_LIBRARIES}) target_link_libraries(_korlib HSPlasma ${Ogg_LIBRARIES} ${OPENGL_LIBRARIES} ${PYTHON_LIBRARIES} ${Vorbis_LIBRARIES})

30
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()

37
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()

2
korlib/korlib.h

@ -17,6 +17,8 @@
#ifndef _KORLIB_H #ifndef _KORLIB_H
#define _KORLIB_H #define _KORLIB_H
#define NOMINMAX
#include <cstdint> #include <cstdint>
#include <Python.h> #include <Python.h>

9
korlib/module.cpp

@ -15,16 +15,23 @@
*/ */
#include "buffer.h" #include "buffer.h"
#include "sound.h"
#include "texture.h" #include "texture.h"
extern "C" { extern "C" {
static PyMethodDef korlib_Methods[] = {
{ _pycs("inspect_vorbisfile"), (PyCFunction)inspect_vorbisfile, METH_VARARGS, NULL },
{ NULL, NULL, 0, NULL },
};
static PyModuleDef korlib_Module = { static PyModuleDef korlib_Module = {
PyModuleDef_HEAD_INIT, /* m_base */ PyModuleDef_HEAD_INIT, /* m_base */
"_korlib", /* m_name */ "_korlib", /* m_name */
"C++ korlib implementation",/* m_doc */ "C++ korlib implementation",/* m_doc */
0, /* m_size */ 0, /* m_size */
NULL, /* m_methods */ korlib_Methods, /* m_methods */
NULL, /* m_reload */ NULL, /* m_reload */
NULL, /* m_traverse */ NULL, /* m_traverse */
NULL, /* m_clear */ NULL, /* m_clear */

119
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 <http://www.gnu.org/licenses/>.
*/
#include "sound.h"
#include <PRP/Audio/plSoundBuffer.h>
#include <Stream/hsStream.h>
#include <vorbis/vorbisfile.h>
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<hsStream*>(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<hsStream*>(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<hsStream*>(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);
}
};

28
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 <http://www.gnu.org/licenses/>.
*/
#ifndef _KORLIB_SOUND_H
#define _KORLIB_SOUND_H
#include "korlib.h"
extern "C" {
PyObject* inspect_vorbisfile(PyObject*, PyObject* args);
};
#endif // _KORLIB_SOUND_H

17
korman/korlib/__init__.py

@ -17,3 +17,20 @@ try:
from _korlib import * from _korlib import *
except ImportError: except ImportError:
from .texture import * 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)

125
korman/nodes/node_messages.py

@ -482,6 +482,131 @@ class PlasmaSceneObjectMsgRcvrNode(PlasmaNodeBase, bpy.types.Node):
return ref_so_key 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): class PlasmaTimerCallbackMsgNode(PlasmaMessageNode, bpy.types.Node):
bl_category = "MSG" bl_category = "MSG"
bl_idname = "PlasmaTimerCallbackMsgNode" bl_idname = "PlasmaTimerCallbackMsgNode"

26
korman/nodes/node_responder.py

@ -16,6 +16,7 @@
import bpy import bpy
from bpy.props import * from bpy.props import *
from collections import OrderedDict from collections import OrderedDict
import inspect
from PyHSPlasma import * from PyHSPlasma import *
import uuid import uuid
@ -230,16 +231,28 @@ class PlasmaResponderCommandNode(PlasmaNodeBase, bpy.types.Node):
]) ])
def convert_command(self, exporter, so, responder, commandMgr, waitOn=-1): def convert_command(self, exporter, so, responder, commandMgr, waitOn=-1):
# If this command has no message, there is no need to export it... def prepare_message(exporter, so, responder, commandMgr, waitOn, msg):
msgNode = self.find_output("msg")
if msgNode is not None:
idx, command = commandMgr.add_command(self, waitOn) idx, command = commandMgr.add_command(self, waitOn)
# Finally, convert our message...
msg = msgNode.convert_message(exporter, so)
if msg.sender is None: if msg.sender is None:
msg.sender = responder.key msg.sender = responder.key
msg.BCastFlags |= plMessage.kLocalPropagate 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 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. # 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) msgNode.convert_callback_message(exporter, so, msg, responder.key, childWaitOn)
else: else:
childWaitOn = waitOn childWaitOn = waitOn
command.msg = msg
else: else:
childWaitOn = waitOn childWaitOn = waitOn

1
korman/operators/__init__.py

@ -17,6 +17,7 @@ from . import op_export as exporter
from . import op_lightmap as lightmap from . import op_lightmap as lightmap
from . import op_modifier as modifier from . import op_modifier as modifier
from . import op_nodes as nodes from . import op_nodes as nodes
from . import op_sound as sound
from . import op_texture as texture from . import op_texture as texture
from . import op_toolbox as toolbox from . import op_toolbox as toolbox
from . import op_world as world from . import op_world as world

88
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 <http://www.gnu.org/licenses/>.
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"}

1
korman/properties/modifiers/__init__.py

@ -22,6 +22,7 @@ from .logic import *
from .physics import * from .physics import *
from .region import * from .region import *
from .render import * from .render import *
from .sound import *
from .water import * from .water import *
class PlasmaModifiers(bpy.types.PropertyGroup): class PlasmaModifiers(bpy.types.PropertyGroup):

384
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 <http://www.gnu.org/licenses/>.
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)

1
korman/ui/modifiers/__init__.py

@ -19,4 +19,5 @@ from .logic import *
from .physics import * from .physics import *
from .region import * from .region import *
from .render import * from .render import *
from .sound import *
from .water import * from .water import *

105
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 <http://www.gnu.org/licenses/>.
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="")
Loading…
Cancel
Save