First commit

This commit is contained in:
Jamie Hardt
2020-09-16 18:32:50 -07:00
commit e73b0b7b20
4 changed files with 822 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.pyc
.vscode/*

View File

@@ -0,0 +1,319 @@
import bpy
from bpy.types import Operator
from bpy.props import BoolProperty, StringProperty, EnumProperty, FloatProperty
import bpy
from aud import Sound
import numpy
from numpy.linalg import norm
from random import choice, uniform, gauss
from math import floor
from enum import IntFlag, Enum
from dataclasses import dataclass
from typing import List
import sys
bl_info = {
"name": "Add Sounds to Objects",
"description": "Adds sounds to selected mesh objects",
"author": "Jamie Hardt",
"version": (0, 21),
"blender": (2, 90, 0),
"category": "Object",
}
class SoundBank:
def __init__(self, prefix):
self.prefix = prefix
self.cached_info = {}
def sounds(self) -> List[Sound]:
return [sound for sound in bpy.data.sounds if sound.name.startswith(self.prefix)]
def random_sound(self) -> Sound:
return choice(self.sounds())
def get_audiofile_info(self, sound, fps) -> (int, int):
"""
Returns frame of max_peak and duration in frames
"""
if sound.filepath in self.cached_info.keys():
return self.cached_info[sound.filepath]
if sound.filepath.startswith("//"):
path = bpy.path.abspath(sound.filepath)
else:
path = sound.filepath
aud_sound = Sound(path)
samples = aud_sound.data()
sample_rate = aud_sound.specs[0]
index = numpy.where(samples == numpy.amax(samples))[0][0]
max_peak_frame = (index * fps / sample_rate)
sample_count = aud_sound.length
frame_length = sample_count * fps / sample_rate
retvalue = (max_peak_frame , frame_length)
self.cached_info[sound.filepath] = retvalue
return retvalue
class TriggerMode(str, Enum):
START_FRAME = "START_FRAME"
MIN_DISTANCE = "MIN_DISTANCE"
RANDOM = "RANDOM"
RANDOM_GAUSSIAN = "RANDOM_GAUSSIAN"
@dataclass
class SpatialEnvelope:
considered_range: float
enters_range: int
closest_range: int
min_distance: float
exits_range: int
def sound_camera_spatial_envelope(scene: bpy.types.Scene, speaker_obj, considered_range: float) -> SpatialEnvelope:
min_dist = sys.float_info.max
min_dist_frame = scene.frame_start
enters_range_frame = None
exits_range_frame = None
in_range = False
for frame in range(scene.frame_start, scene.frame_end + 1):
scene.frame_set(frame)
rel = speaker_obj.matrix_world.to_translation() - scene.camera.matrix_world.to_translation()
dist = norm(rel)
if dist < considered_range and not in_range:
enters_range_frame = frame
in_range = True
if dist < min_dist:
min_dist = dist
min_dist_frame = frame
if dist > considered_range and in_range:
exits_range_frame = frame
in_range = False
break
return SpatialEnvelope(considered_range=considered_range,
enters_range=enters_range_frame,
exits_range=exits_range_frame,
closest_range=min_dist_frame,
min_distance=min_dist)
def closest_approach_to_camera(scene, speaker_object):
max_dist = sys.float_info.max
at_time = scene.frame_start
for frame in range(scene.frame_start, scene.frame_end + 1):
scene.frame_set(frame)
rel = speaker_object.matrix_world.to_translation() - scene.camera.matrix_world.to_translation()
dist = norm(rel)
if dist < max_dist:
max_dist = dist
at_time = frame
return (max_dist, at_time)
def track_speaker_to_camera(speaker, camera):
camera_lock = speaker.constraints.new('TRACK_TO')
camera_lock.target = bpy.context.scene.camera
camera_lock.use_target_z = True
def random_sound_startswith(prefix):
sounds = [sound for sound in bpy.data.sounds if sound.name.startswith(prefix)]
return choice(sounds)
def spot_audio(context, speaker, trigger_mode, sync_peak, sound_peak, sound_length, gaussian_stddev, envelope):
if trigger_mode == TriggerMode.START_FRAME:
audio_scene_in = context.scene.frame_start
elif trigger_mode == TriggerMode.MIN_DISTANCE:
audio_scene_in = envelope.closest_range
elif trigger_mode == TriggerMode.RANDOM:
audio_scene_in = floor(uniform(context.scene.frame_start, context.scene.frame_end))
elif trigger_mode == TriggerMode.RANDOM_GAUSSIAN:
mean = (context.scene.frame_end - context.scene.frame_start) / 2
audio_scene_in = floor(gauss(mean, gaussian_stddev))
target_strip = speaker.animation_data.nla_tracks[0].strips[0]
if sync_peak:
peak = int(sound_peak)
audio_scene_in = audio_scene_in - peak
audio_scene_in = max(audio_scene_in, 0)
# we have to do this weird dance because setting a start time
# that happens after the end time has side effects
target_strip.frame_end = context.scene.frame_end
target_strip.frame_start = audio_scene_in
target_strip.frame_end = audio_scene_in + sound_length
def sync_audio(speaker, sound, context, sync_peak, trigger_mode, gaussian_stddev, sound_bank, envelope):
print("sync_audio entered")
fps = context.scene.render.fps
audiofile_info = sound_bank.get_audiofile_info(sound, fps)
spot_audio(context=context, speaker=speaker, trigger_mode=trigger_mode,
gaussian_stddev=gaussian_stddev, sync_peak=sync_peak,
sound_peak= audiofile_info[0], sound_length=audiofile_info[1],
envelope=envelope)
def constrain_speaker_to_mesh(speaker_obj, mesh):
speaker_obj.constraints.clear()
location_loc = speaker_obj.constraints.new(type='COPY_LOCATION')
location_loc.target = mesh
location_loc.target = mesh
def apply_gain_envelope(speaker_obj, envelope):
pass
def add_speakers_to_meshes(meshes, context, sound=None,
sound_name_prefix = None,
sync_peak = False,
trigger_mode = TriggerMode.START_FRAME,
gaussian_stddev = 1.
):
context.scene.frame_set(0)
sound_bank = SoundBank(prefix=sound_name_prefix)
for mesh in meshes:
if mesh.type != 'MESH':
print("object is not mesh")
continue
envelope = sound_camera_spatial_envelope(context.scene, mesh, considered_range=5.)
speaker_obj = next( (spk for spk in context.scene.objects \
if spk.type == 'SPEAKER' and spk.constraints['Copy Location'].target == mesh ), None)
if speaker_obj is None:
bpy.ops.object.speaker_add()
speaker_obj = context.selected_objects[0]
constrain_speaker_to_mesh(speaker_obj, mesh)
if sound_name_prefix is not None:
sound = sound_bank.random_sound()
if sound is not None:
speaker_obj.data.sound = sound
sync_audio(speaker_obj, sound, context,
sync_peak=sync_peak,
trigger_mode=trigger_mode,
gaussian_stddev=gaussian_stddev,
sound_bank=sound_bank, envelope=envelope)
apply_gain_envelope(speaker_obj, envelope)
speaker_obj.data.update_tag()
track_speaker_to_camera(speaker_obj, context.scene.camera)
#########
class AddSoundToMeshOperator(Operator):
"""Add a speaker to each selected object"""
bl_idname = "object.add_speakers_to_obj"
bl_label = "Add Sounds to Meshes"
TRIGGER_OPTIONS = (
(TriggerMode.START_FRAME,
"Start Frame",
"Sound will play on the first frame of the animation"),
(TriggerMode.MIN_DISTANCE,
"Minimum Distance",
"Sound will play when the object is closest to the camera"),
(TriggerMode.RANDOM,
"Random",
"Sound will play exactly once, at a random time"),
(TriggerMode.RANDOM_GAUSSIAN,
"Random (Gaussian)",
"Sound will play exactly once, at a guassian random time with " +
"stdev of 1 and mean in the middle of the animation")
)
@classmethod
def poll(cls, context):
sounds_avail = bpy.data.sounds
return len(context.selected_objects) > 0 and len(sounds_avail) > 0
use_sounds: StringProperty(
name="Sound Prefix",
description="Sounds having names starting with thie field will be assigned randomly to each speaker"
)
sync_audio_peak: BoolProperty(
name="Sync Audio Peak",
default=True,
description="Synchronize speaker audio to loudest peak instead of beginning of file"
)
trigger_mode: EnumProperty(
items=TRIGGER_OPTIONS,
name="Trigger",
description="Select when each sound will play",
default=TriggerMode.MIN_DISTANCE,
)
gaussian_stddev: FloatProperty(
name="Gaussian StDev",
description="Standard Deviation of Gaussian random time",
default=1.,
min=0.001,
max=6.,
)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
add_speakers_to_meshes(bpy.context.selected_objects, bpy.context,
sound=None,
sound_name_prefix=self.use_sounds,
trigger_mode=self.trigger_mode,
sync_peak=self.sync_audio_peak,
gaussian_stddev=self.gaussian_stddev)
return {'FINISHED'}
def menu_func(self, context):
self.layout.operator(AddSoundToMeshOperator.bl_idname, icon='SPEAKER')
def register():
bpy.utils.register_class(AddSoundToMeshOperator)
bpy.types.VIEW3D_MT_object.append(menu_func)
def unregister():
bpy.utils.unregister_class(AddSoundToMeshOperator)
bpy.types.VIEW3D_MT_object.remove(menu_func)
if __name__ == "__main__":
register()

395
operator_adm_export.py Normal file
View File

@@ -0,0 +1,395 @@
import sys
import bpy
import os
from ear.fileio.utils import openBw64
from ear.fileio.bw64.utils import interleave
from ear.fileio.bw64.chunks import (FormatInfoChunk, ChnaChunk)
from ear.fileio.adm import chna as adm_chna
from ear.fileio.adm.xml import adm_to_xml
from ear.fileio.adm.elements.block_formats import (AudioBlockFormatObjects, JumpPosition)
from ear.fileio.adm.elements.geom import ObjectCartesianPosition
from ear.fileio.adm.builder import (ADMBuilder, TypeDefinition)
from ear.fileio.adm.generate_ids import generate_ids
import lxml
import uuid
from fractions import Fraction
import struct
import numpy
from numpy.linalg import norm
from mathutils import Quaternion, Vector
from time import strftime
from math import sqrt
bl_info = {
"name": "Export ADM Broadcast-WAV File",
"description": "Export a Broadcast-WAV with each speaker as an ADM object",
"author": "Jamie Hardt",
"version": (0, 22),
"warning": "Requires `ear` EBU ADM Renderer package to be installed",
"blender": (2, 90, 0),
"category": "Import-Export",
}
def compute_relative_vector(camera: bpy.types.Camera, object: bpy.types.Object):
"""
Return a vector from `camera` to `object` in the camera's coordinate space.
The camera's lens is assumed to be norm to the ZX plane.
"""
cam_loc, cam_rot, _ = camera.matrix_world.decompose()
obj_loc, _, _ = object.matrix_world.decompose()
relative_vector = obj_loc - cam_loc
rotation = cam_rot.to_matrix().transposed()
relative_vector.rotate(rotation)
# The camera's worldvector is norm to the horizon, we want a vector
# down the barrel.
camera_correction = Quaternion( ( sqrt(2.) / 2. , sqrt(2.) / 2. , 0. , 0.) )
relative_vector.rotate(camera_correction)
return relative_vector
def room_norm_vector(vec, room_size=1.):
"""
The Room is tearing me apart, Lisa.
The room is a cube with the camera at its center. We use a chebyshev normalization
to convert a vector in world or camera space into a vector the represents the projection
of that vector onto the room's walls.
The Pro Tools/Dolby Atmos workflow I am targeting uses "Room Centric" panner coordinates
("cartesian allocentric coordinates" in ADM speak) and this process seems to yield good
results.
"""
chebyshev = norm(vec, ord=numpy.inf)
if chebyshev < room_size:
return vec / room_size
else:
return vec / chebyshev
def speaker_active_time_range(speaker):
"""
The time range this speaker must control in order to sound right.
At this time this is assuming the str
"""
start, end = 0xffffffff, -0xffffffff
for track in speaker.animation_data.nla_tracks:
for strip in track.strips:
if strip.frame_start < start:
start = strip.frame_start
if strip.frame_end > end:
end = strip.frame_end
return int(start), int(end)
def speakers_by_start_time(speaker_objs):
return sorted(speaker_objs, key=(lambda spk: speaker_active_time_range(spk)[0]))
def group_speakers(speaker_objs):
def group_speakers_impl1(bag):
"Returns a useable group and the remainder"
leftover = []
this_group = []
boundary = -0xffffffff
for speaker in bag:
start, end = speaker_active_time_range(speaker)
if start > boundary:
this_group.append(speaker)
boundary = end
else:
leftover.append(speaker)
return (this_group, leftover)
groups = []
remaining = speaker_objs
while len(remaining) > 0:
results = group_speakers_impl1(remaining)
groups.append(results[0])
remaining = results[1]
print("Will group {} sources into {} objects".format(len(speaker_objs), len(groups)))
return groups
def adm_block_formats_for_speakers(scene, speaker_objs, room_size=1.):
block_formats = []
# frame_start = start_frame or scene.frame_start
# frame_end = end_frame or scene.frame_end
fps = scene.render.fps
for speaker_obj in speakers_by_start_time(speaker_objs):
speaker_start, speaker_end = speaker_active_time_range(speaker_obj)
for frame in range(speaker_start, speaker_end + 1):
scene.frame_set(frame)
relative_vector = compute_relative_vector(camera=scene.camera,
object=speaker_obj)
norm_vec = room_norm_vector(relative_vector, room_size=room_size)
pos = ObjectCartesianPosition(X=norm_vec.x , Y=norm_vec.y , Z=norm_vec.z)
if len(block_formats) == 0 or pos != block_formats[-1].position:
jp = JumpPosition(flag=True, interpolationLength=Fraction(1,fps * 2) )
block = AudioBlockFormatObjects(position= pos,
rtime=Fraction(frame,fps),
duration=Fraction(1,fps) ,
cartesian=True,
jumpPosition=jp)
block_formats.append(block)
else:
block_formats[-1].duration = block_formats[-1].duration + Fraction(1,fps)
return block_formats
def adm_data_for_scene(scene, speaker_objs_lists, wav_format, room_size):
b = ADMBuilder()
frame_start = scene.frame_start
frame_end = scene.frame_end
fps = scene.render.fps
b.create_programme(audioProgrammeName=scene.name,
start=Fraction(frame_start ,fps),
end=Fraction(frame_end, fps) )
b.create_content(audioContentName="Objects")
for i, speakers_this_mixdown in enumerate(speaker_objs_lists):
block_formats = adm_block_formats_for_speakers(scene, speakers_this_mixdown,
room_size=room_size)
created = b.create_item_objects(track_index=i,
name=speakers_this_mixdown[0].name,
block_formats=block_formats)
created.audio_object.start = Fraction(frame_start, fps)
created.audio_object.duration = Fraction(frame_end - frame_start, fps)
created.track_uid.sampleRate = wav_format.sampleRate
created.track_uid.bitDepth = wav_format.bitsPerSample
adm = b.adm
generate_ids(adm)
chna = ChnaChunk()
adm_chna.populate_chna_chunk(chna, adm)
return adm_to_xml(adm), chna
def bext_data(scene, speaker_obj, sample_rate, room_size):
description = "SCENE={};ROOM_SIZE={}\n".format(scene.name, room_size).encode("ascii")
originator_name = "Blender {}".format(bpy.app.version_string).encode("ascii")
originator_ref = uuid.uuid1().hex.encode("ascii")
date10 = strftime("%Y-%m-%d").encode("ascii")
time8 = strftime("%H:%M:%S").encode("ascii")
timeref = int(float(scene.frame_start) * sample_rate / float(scene.render.fps))
version = 0
umid = b"\0" * 64
pad = b"\0" * 190
data = struct.pack("<256s32s32s10s8sQH64s190s", description, originator_name,
originator_ref, date10, time8, timeref, version, umid, pad)
return data
def write_muxed_adm(scene, mixdowns, output_filename=None, room_size=1.):
"""
mixdowns are a tuple of wave filename, and corresponding speaker object
"""
object_count = len(mixdowns)
assert object_count > 0
READ_BLOCK=1024
out_file = output_filename or os.path.join(os.path.dirname(mixdowns[0][0]),
bpy.path.clean_name(scene.name) + ".wav")
infiles = []
shortest_file = 0xFFFFFFFFFFFF
for elem in mixdowns:
infile = openBw64(elem[0], 'r')
infiles.append(infile)
if len(infile) < shortest_file:
shortest_file = len(infile)
out_format = FormatInfoChunk(channelCount=object_count,
sampleRate=infiles[0].sampleRate,
bitsPerSample=infiles[0].bitdepth)
with openBw64(out_file, 'w', formatInfo=out_format) as outfile:
speakers = list(map(lambda x: x[1], mixdowns))
adm, chna = adm_data_for_scene(scene, speakers, out_format, room_size=room_size)
outfile.axml = lxml.etree.tostring(adm, pretty_print=True)
outfile.chna = chna
outfile.bext = bext_data(scene, None, out_format.sampleRate, room_size=room_size)
cursor = 0
while True:
remainder = shortest_file - cursor
to_read = min(READ_BLOCK, remainder)
if to_read == 0:
break
buffer = numpy.zeros((to_read, object_count))
for i, infile in enumerate(infiles):
buffer[: , i] = infile.read(to_read)[: , 0]
outfile.write(buffer)
cursor = cursor + to_read
for infile in infiles:
infile._buffer.close()
for elem in mixdowns:
os.unlink(elem[0])
def all_speakers(scene):
return [obj for obj in scene.objects if obj.type == 'SPEAKER']
def solo_speakers(scene, solo_group):
for speaker in all_speakers(scene):
if speaker in solo_group:
speaker.data.muted = False
else:
speaker.data.muted = True
speaker.data.update_tag()
def unmute_all_speakers(scene):
for speaker in all_speakers(scene):
speaker.data.muted = False
speaker.data.update_tag()
def speaker_mixdowns(scene, filepath):
basedir = os.path.dirname(filepath)
for speaker_group in group_speakers(all_speakers(scene)):
solo_speakers(scene, speaker_group)
scene_name = bpy.path.clean_name(scene.name)
speaker_name = bpy.path.clean_name(speaker_group[0].name)
fn = os.path.join(basedir, "%s_%s.wav" % (scene_name, speaker_name) )
bpy.ops.sound.mixdown(filepath=fn, container='WAV', codec='PCM', format='S24')
yield (fn, speaker_group)
def save_output_state(context):
"""
save render settings that we change to produce object WAV files
"""
ff = context.scene.render.image_settings.file_format
codec = context.scene.render.ffmpeg.audio_codec
chans = context.scene.render.ffmpeg.audio_channels
return (ff, codec, chans)
def restore_output_state(ctx, context):
context.scene.render.image_settings.file_format = ctx[0]
context.scene.render.ffmpeg.audio_codec = ctx[1]
context.scene.render.ffmpeg.audio_channels = ctx[2]
def write_some_data(context, filepath, room_size):
ctx = save_output_state(context)
scene = bpy.context.scene
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.audio_codec = 'PCM'
scene.render.ffmpeg.audio_channels = 'MONO'
mixdowns = list(speaker_mixdowns(scene, filepath))
mixdown_count = len(mixdowns)
if mixdown_count == 0:
return {'FINISHED'}
else:
write_muxed_adm(scene, mixdowns, output_filename= filepath, room_size=room_size)
#cleanup
unmute_all_speakers(scene)
restore_output_state(ctx, context)
return {'FINISHED'}
#########################################################################
### BOILERPLATE EXPORTER CODE BELOW
# ExportHelper is a helper class, defines filename and
# invoke() function which calls the file selector.
from bpy_extras.io_utils import ExportHelper
from bpy.props import StringProperty, BoolProperty, EnumProperty, FloatProperty
from bpy.types import Operator
class ADMWaveExport(Operator, ExportHelper):
"""Export a Broadcast-WAV audio file with each speaker encoded as an ADM object"""
bl_idname = "export.adm_wave_file" # important since its how bpy.ops.import_test.some_data is constructed
bl_label = "Export ADM Wave File"
# ExportHelper mixin class uses this
filename_ext = ".wav"
filter_glob: StringProperty(
default="*.wav",
options={'HIDDEN'},
maxlen=255, # Max internal buffer length, longer would be clamped.
)
room_size: FloatProperty(
default=1.0,
name="Room Size",
description="Distance from the lens to the front room boundary",
min=0.001,
step=1.,
unit='LENGTH'
)
def execute(self, context):
return write_some_data(context, self.filepath, self.room_size)
# Only needed if you want to add into a dynamic menu
def menu_func_export(self, context):
self.layout.operator(ADMWaveExport.bl_idname, text="ADM Broadcast-WAVE (.wav)")
def register():
bpy.utils.register_class(ADMWaveExport)
bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
def unregister():
bpy.utils.unregister_class(ADMWaveExport)
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
if __name__ == "__main__":
register()

106
operator_wav_import.py Normal file
View File

@@ -0,0 +1,106 @@
import bpy
import os
bl_info = {
"name": "Import WAV Files",
"description": "Import WAV files, with options to automatically pack and Fake user",
"author": "Jamie Hardt",
"version": (0, 20),
"blender": (2, 90, 0),
"location": "File > Import > WAV Audio Files",
"warning": "", # used for warning icon and text in addons panel
"support": "COMMUNITY",
"category": "Import-Export",
}
def read_some_data(context, filepath, pack, dir, fake):
def import_one(fp):
sound = bpy.data.sounds.load(fp, check_existing=False)
if pack:
sound.pack()
if fake:
sound.use_fake_user = True
if dir:
the_dir = os.path.dirname(filepath)
for child in os.listdir(the_dir):
if child.endswith(".wav"):
import_one(os.path.join(the_dir, child))
else:
import_one(filepath)
return {'FINISHED'}
# ImportHelper is a helper class, defines filename and
# invoke() function which calls the file selector.
from bpy_extras.io_utils import ImportHelper
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.types import Operator
class ImportWav(Operator, ImportHelper):
"""Import WAV audio files"""
bl_idname = "import_test.wav_file_batch" # important since its how bpy.ops.import_test.some_data is constructed
bl_label = "Import WAV Files"
# ImportHelper mixin class uses this
filename_ext = ".wav"
filter_glob: StringProperty(
default="*.wav",
options={'HIDDEN'},
maxlen=255, # Max internal buffer length, longer would be clamped.
)
# List of operator properties, the attributes will be assigned
# to the class instance from the operator settings before calling.
# pack_it_in: BoolProperty(
# name="Pack Into .blend File",
# description="Embed the audio data into the .blend file",
# default=True,
# )
fake: BoolProperty(
name="Add Fake User",
description="Add the Fake User to each of the sound files",
default=True,
)
all_in_directory: BoolProperty(
name="All Files in Folder",
description="Import every WAV file found in the folder as the selected file",
default=False,
)
def execute(self, context):
return read_some_data(context=context, filepath=self.filepath,
dir=self.all_in_directory, fake=self.fake,
pack=False)
# Only needed if you want to add into a dynamic menu
def menu_func_import(self, context):
self.layout.operator(ImportWav.bl_idname, text="WAV Audio Files (.wav)")
def register():
bpy.utils.register_class(ImportWav)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
def unregister():
bpy.utils.unregister_class(ImportWav)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
if __name__ == "__main__":
register()
# test call
# bpy.ops.import_test.wav_file_batch('INVOKE_DEFAULT')