Skip to main content

Devices


The following documentation is presented as Python code running inside a Jupyter Notebook. To run it yourself you can copy/type each individual cell or directly download the full notebook, including all required files.

Device/Head Related Transfer Functions (DRTF) are widely used in acoustics to be able to compute how a device or a person would experience the soundfield and to be able to dynamically change the properties/orientation of the device without the need to do another simulation.

In this tutorial, we'll show an example how a Device can be defined and used in a simulation as well as the re-orienting the device later and rendering it for a different receiver in the simulation.

First off, let's initialize the SDK and start a project

from pathlib import Path
# Our working directory for tsdk files.
# Defaults to your home directory /tmp/tsdk, feel free to change it to what you like.
base_dir = Path.home() / "tmp" / "tsdk"
base_dir.mkdir(parents=True, exist_ok=True)

from treble_tsdk.tsdk import TSDK
from treble_tsdk import tsdk_namespace as treble
from treble_tsdk import display_data as dd

tsdk = TSDK()

project_name = "Device Tutorial"
project = tsdk.get_or_create_project(
name=project_name,
description="Tutorial demonstrating the usage of devices with Treble SDK",
)

For this demonstration, we will create a room model with the treble.GeometryGenerator where we define a set of corner-points of a polygon, trace a line between them and extrude the volume to a certain fixed height.

polygon_points = [
[6.4, 2.2],
[2.8, 6.6],
[0.2, 4],
[0, 3.4],
[0.7, 1.9],
[2.4, 0.6],
]

poly_room = treble.GeometryGenerator.create_polygon_room(
project=project,
model_name="Polygon room",
points_xy=polygon_points,
height_z=2.2,
join_wall_layers=False,
)

# The room can be plotted if desired
# poly_room.plot()

As this tutorial is not focused on acoustic design, we assign random materials from either the gypsum or porous categories from the material library

import random

# Get all materials in the porous category
porous_materials = tsdk.material_library.get(
category=treble.MaterialCategory.porous
)
gypsum = tsdk.material_library.get(category=treble.MaterialCategory.gypsum)
gypsum_or_porous = porous_materials + gypsum

# Assign materials to all layers in the model.
material_assignments = [
treble.MaterialAssignment(x, random.choice(gypsum_or_porous))
for x in poly_room.layer_names
]

Create two sources and assign them to locations inside the domain

source_list = []
source_list.append(
treble.Source(
x=1.7,
y=3.5,
z=1.5,
source_type=treble.SourceType.omni,
label="Omni_source_1",
)
)

source_list.append(
treble.Source(
x=3.7,
y=2.5,
z=1.5,
source_type=treble.SourceType.omni,
label="Omni_source_2",
)
)

We will create three different types of receivers.

  1. treble.ReceiverType.mono: The simplest kind of receiver, but also the computationally cheapest, yields a single channel result.
  2. treble.ReceiverType.spatial: This creates an SN3D normalized spatial impulse response where the number of ambisonics channels is a function of the ambisonics order of the simulation.
  3. treble.RecevierType.device: This is inherently a spatial receiver, but as a last step a device response is rendered for the spatial impulse response.

Each receiver type contains all the receiver types with a lower number (e.g. a device receiver is also a spatial and mono receiver).

For now let's create one of each types of receivers

receiver_list = []
receiver_list.append(
treble.Receiver(
x=3.2,
y=3.6,
z=1.5,
receiver_type=treble.ReceiverType.spatial,
label="Spatial_receiver",
)
)
receiver_list.append(
treble.Receiver(
x=4.8,
y=2.5,
z=1.0,
receiver_type=treble.ReceiverType.mono,
label="Mono_receiver",
)
)

To create the device receiver we need to specify a device related transfer function. This can be done, either by importing a .sofa file or by supplying the raw measurement/simulation data. The formatting of the .sofa file can be problematic and it's thus possible to directly import the data contained in the sofa file by creating a few class instances demonstrated below.

DRTF

Here we import a fictional device created by analytically computing planewaves propagating from the small dots on the surrounding sphere (sources), recorded by the larger dots on the inner sphere (microphones). After making the calculations, the raw results were stored in an hdf5 file and in the cell below these data will be read to construct a DRTF usable with Treble.

First, we define the DeviceSourceLocations are defined (the locations of the small dots in the figure above). We then loop through the microphones on the inner sphere, read the DeviceImpulseResponses, which are the impulse responses recorded by each microphone by each source. The impulse responses and the source locations are then used to define a DeviceMicrophone instances which contains all required information to construct a DRTF for that specific microphone on the device.

The max_ambisonics_order parameter defines what the maximum ambisonics order to use to render the device. If the spatial impulse response has a higher ambisonics order, a subset of the channels will be used. If, however, the spatial impulse response has a lower order, a subset of the DRTF channels will be used.

import h5py

# We know the microphone names in the file and loop through them
microphone_names = ["microphone_0", "microphone_1", "microphone_2", "microphone_3"]
device_microphones = []
with h5py.File("notebooks/Device.h5", "r") as f:
# All the microphones share the same source locations
source_positions = treble.DeviceSourceLocations(
azimuth_deg=f["source_positions"]["azimuth"][:],
elevation_deg=f["source_positions"]["elevation"][:],
)

for microphone_name in microphone_names:
# Here we read in the impulse responses for each microphone and
# append a DeviceMicrophone object to the list of microphones
impulse_response = treble.DeviceImpulseResponses(
impulse_responses=f[microphone_name][:],
sampling_rate=f[microphone_name].attrs.get("sampling_rate"),
)
device_microphones.append(
treble.DeviceMicrophone(
source_locations=source_positions,
impulse_responses=impulse_response,
max_ambisonics_order=16,
)
)

We can then take the list of DeviceMicrophones and define the device (DeviceDefinition). An instance of such a class can be used to render a device locally after the simulation (shown later).

device_definition = treble.DeviceDefinition(
device_microphones=device_microphones,
name="spherical_device",
description="Spherical device with 4 microphones",
)

After constructing the device, we can plot some specific properties of the device, like the transfer functions of the microphones for an arrival coming from a certain angle.

angle = (80, 25)  # azimuth, elevation degrees

device_definition.plot_transfer_function(angle=angle)

Or all the transfer functions for a specific microphone on a device

# Plot the transfer functions from all arrivals
device_microphones[0].plot_transfer_function()

The DRTF is encoded into spherical harmonics and we can plot how the energy is distributed as a function of harmonics order and frequency for each microphone. This gives an indication of up to which frequency the DRTF gives valid results and in which cases higher orders are needed.

device_microphones[0].plot_spherical_harmonics()

To use the device in a simulation, it needs to be added to a project, and the device can later be recovered to be used again (project.get_device(id/device_object))

device = tsdk.device_library.add_device(device_definition=device_definition)

We now have all the information to create a treble.ReceiverType.device receiver

receiver_list.append(
treble.Receiver(
x=2.5,
y=4.6,
z=1.5,
receiver_type=treble.ReceiverType.device,
label="Device_receiver",
receiver_properties=treble.ReceiverProperties(
device, azimuth_angle=25, elevation_angle=45, roll_angle=15
),
)
)

Now we can start a simulation where we'll set the ambisonics order of the simulation to the 16th order.

settings = treble.SimulationSettings(ambisonics_order=16)
simulation_definition = treble.SimulationDefinition(
name="Device Tutorial",
simulation_type=treble.SimulationType.hybrid,
model=poly_room,
crossover_frequency=500,
receiver_list=receiver_list,
source_list=source_list,
material_assignment=material_assignments,
ir_length=0.1,
)

We can plot the simulation setup

simulation_definition.plot()
simulation = project.add_simulation(simulation_definition)

When the simulation is added to a project, the model gets meshed and when the mesh is ready we can estimate the compute cost of the simulation

simulation_estimation = simulation.wait_for_estimate()
simulation_estimation.as_tree()

We can now run the simulation and if we want, the status can be monitored. The simulation id (simulation.id) can be used later on to retrieve the simulation object and it will not affect the running of the actual simulation.

simulation.start()
simulation.as_live_progress()

As soon as the simulation is done, we can get the results and inspect them in various ways.

results_dir = base_dir / "DeviceDemo"
results = simulation.get_results_object(results_directory=results_dir)

We offer functionality to plot an overview of the mono impulse responses (all receiver types have mono responses) and then a way to get more detailed information on individual sources and receivers.

results.plot()

We can look at individual receivers, such as the device receiver.

device_receiver = [r for r in simulation.receivers if r.receiverType == "Device"][0]
device = results.get_device_ir(
source=simulation.sources[1], receiver=device_receiver
)
device.write_to_wav("device_response.wav")

The device response can be plotted in a similar way as the overview plot but now with the individual microphones on the device

device.plot()

After the simulation, one can also render a new device for either a spatial receiver or a device receiver. Let's now do it for the spatial receiver.

spatial_receiver = [r for r in simulation.receivers if r.receiverType == "Spatial"][0]
spatial = results.get_spatial_ir(
source=simulation.sources[1],
receiver=spatial_receiver,
)
device_from_spatial = spatial.render_device_ir(
device=device_definition, azimuth=180.0, elevation=10
)
device_from_spatial.plot()

Our simulation type previously was a treble.SimulationType.hybrid which means that it included both a wave-based simulation and a geometrical simulation. To isolate, for example, the wave-based part of the results, one can get the spatial impulse response only from the wave-based simulation

spatial_wave_based = results.get_spatial_ir(
source=simulation.sources[1],
receiver=spatial_receiver,
sim_type=treble.SimulationType.wave_based,
)
device_from_wave_based_spatial = spatial.render_device_ir(
device=device_definition, azimuth=180.0, elevation=10
)
device_from_wave_based_spatial.plot()