This page was generated from notebooks/mesonic-mbs.ipynb.

Model-Based Sonification using mesonic#

This notebooks shows how a Model-Based Sonification can be implemented using mesonic.

[1]:
import mesonic
[2]:
from pyamapping import db_to_amp, linlin
[3]:
import numpy as np
from scipy.spatial import ConvexHull
from scipy.spatial.distance import cdist
[4]:
import matplotlib.pyplot as plt
import seaborn as sns

Preparation of Synths#

Lets start by preparing our Context and Synths.

[5]:
context = mesonic.create_context()
Starting sclang process... Done.
Registering OSC /return callback in sclang... Done.
Loading default sc3nb SynthDefs... Done.
Booting SuperCollider Server... Done.
[6]:
context.enable_realtime()
[6]:
Playback(time=0.00046825408935546875, rate=1)

The model we use allows to interact with it using the mouse.

For this we create a additional SynthDef as a click marker.

[7]:
context.synths.add_synth_def("noise", r"""
{ |out=0, freq=2000, rq=0.02, amp=0.3, dur=1, pos=0 |
    Out.ar(out, Pan2.ar(
        BPF.ar(WhiteNoise.ar(10), freq, rq)
        * Line.kr(1, 0, dur, doneAction: 2).pow(4), pos, amp));
}""")
[7]:
'noise'
[8]:
test = context.synths.create("noise", mutable=False)
[9]:
test.start()

Data Preparation#

We also prepare the data for the sonification.

We will use the Palmer penguins dataset for the examples.

[10]:
df = sns.load_dataset("penguins")
df = df.dropna(subset=["bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g", "sex"])
df = df.reset_index(drop=True)
df
[10]:
species island bill_length_mm bill_depth_mm flipper_length_mm body_mass_g sex
0 Adelie Torgersen 39.1 18.7 181.0 3750.0 Male
1 Adelie Torgersen 39.5 17.4 186.0 3800.0 Female
2 Adelie Torgersen 40.3 18.0 195.0 3250.0 Female
3 Adelie Torgersen 36.7 19.3 193.0 3450.0 Female
4 Adelie Torgersen 39.3 20.6 190.0 3650.0 Male
... ... ... ... ... ... ... ...
328 Gentoo Biscoe 47.2 13.7 214.0 4925.0 Female
329 Gentoo Biscoe 46.8 14.3 215.0 4850.0 Female
330 Gentoo Biscoe 50.4 15.7 222.0 5750.0 Male
331 Gentoo Biscoe 45.2 14.8 212.0 5200.0 Female
332 Gentoo Biscoe 49.9 16.1 213.0 5400.0 Male

333 rows × 7 columns

[11]:
%matplotlib inline
[12]:
sns.pairplot(data=df, hue="species")
[12]:
<seaborn.axisgrid.PairGrid at 0x256a67e7550>
../_images/notebooks_mesonic-mbs_17_1.png

Data Sonogram#

The DataSonogram implements a Data Sonogram

  • The model gets a dataset which is plotted in two dimensions.

  • Imagine that for each point in the provided dataset a spring is created.

  • The data label of the point defines the stiffness of the spring.

  • When the user clicks into the plot a shock wave (signaled by noise Synth) is created from the nearest data point.

  • The shock wave excites the springs as it is spreading.

  • The resulting sonification can reveal clusters.

More details about the Data Sonogram and Model-Based Sonification in general can be found in the corresponding Sonification Handbook Chapter

[13]:
class DataSonogram:

    def __init__(self, context, df, x, y, label, max_duration=1.5, spring_synth="s1", trigger_synth="noise"):
        self.context = context

        #prepare synths
        self.trigger_synth = context.synths.create(trigger_synth, mutable=False)
        self.spring_synth = context.synths.create(spring_synth, mutable=False)

        # save dataframe
        self.df = df
        self.numeric_df = df.select_dtypes(include=[np.number])

        # check if x and y are valid
        allowed_columns = self.numeric_df.columns
        assert x in allowed_columns, f"x must be in {allowed_columns}"
        assert y in allowed_columns, f"y must be in {allowed_columns}"

        # prepare data for model
        self.labels = self.df[label]
        self.unique_labels = self.labels.unique()
        label2id = {label: idx for idx, label in enumerate(self.unique_labels)}
        self.numeric_labels = [label2id[label] for label in self.labels]
        self.xy_data = self.numeric_df[[x,y]].values
        self.data = self.numeric_df.values

        # get the convex hull of the data
        hull = ConvexHull(self.data)
        hull_data = self.data[hull.vertices,:]
        # get distances of the data points in the hull
        hull_distances = cdist(hull_data, hull_data, metric='euclidean')
        self.max_distance =  hull_distances.max()

        # set model parameter
        self.max_duration = max_duration

        # prepare plot
        self.fig = plt.figure(figsize=(5,5))
        self.ax = plt.subplot(111)

        # plot data
        sns.scatterplot(x=x, y=y, hue=label, data=df, ax=self.ax)

        # set callback
        def onclick(event):
            if event.inaxes is None: # outside plot area
                return
            if event.button != 1: # ignore other than left click
                return
            click_xy = np.array([event.xdata, event.ydata])
            self.create_shockwave(click_xy)

        self.fig.canvas.mpl_connect('button_press_event', onclick)

    def create_shockwave(self, click_xy):
        with self.context.now(0.2) as start_time:
            self.trigger_synth.start()
        # find the point that is the nearest to the click location
        center_idx = np.argmin(np.linalg.norm(self.xy_data - click_xy, axis=1))
        center = self.data[center_idx]
        # get the distances from the other points to this point
        distances_to_center = np.linalg.norm(self.data - center, axis=1)
        # get idx sorted by distances
        order_of_points = np.argsort(distances_to_center)
        # for each point create a sound using the spring synth
        for idx in order_of_points:
            distance = distances_to_center[idx]
            nlabel = self.numeric_labels[idx]
            onset = (distance / self.max_distance) * self.max_duration
            with self.context.at(start_time + onset):
                self.spring_synth.start(
                    freq = 2 * (400 + 100 * nlabel),
                    amp = db_to_amp(linlin(distance, 0, self.max_distance, -30, -40)),
                    pan = [-1,1][int(self.xy_data[idx, 0]-click_xy[0] > 0)],
                    dur = 0.01,
                    info = {"label": self.labels[idx]},
                )

To interact with the plot we use the matplotlib widget

[14]:
%matplotlib widget

The x and y value can be setted to one of the numeric columns of the data set

[15]:
numeric_columns = ['bill_length_mm', 'bill_depth_mm', 'flipper_length_mm', 'body_mass_g']

Create two views of the model with different x and y value

[16]:
dsg1 = DataSonogram(context, df, x="flipper_length_mm", y="body_mass_g", label="species")
[17]:
dsg2 = DataSonogram(context, df, x="bill_length_mm", y="bill_depth_mm", label="species")

Filtering the MBS#

The MBS can be filtered using the processor.event_filters

  • Lets create a helper that provides us with a filter function for the labels

[18]:
def create_label_filter(allowed):
    def label_filter(event):
        label = event.info.get("label", None)
        if label:
            return event if label in allowed else None
        return event
    return label_filter
[19]:
filter_chinstrap = create_label_filter(["Chinstrap"])
filter_adelie = create_label_filter(["Adelie"])
filter_gentoo = create_label_filter(["Gentoo"])

Select a filter and click again on the plot to listen to the filtered result.

[20]:
context.processor.event_filters.append(filter_chinstrap)
[21]:
context.processor.event_filters.remove(filter_chinstrap)
[22]:
context.processor.event_filters.append(filter_adelie)
[23]:
context.processor.event_filters.remove(filter_adelie)
[24]:
context.processor.event_filters.append(filter_gentoo)
[25]:
context.processor.event_filters.remove(filter_gentoo)

If you want you can combine the single filters

[26]:
context.processor.event_filters.append(filter_chinstrap)
context.processor.event_filters.append(filter_adelie)
[27]:
context.processor.event_filters.remove(filter_chinstrap)
context.processor.event_filters.remove(filter_adelie)

Or create a filter that directly filters both labels

[28]:
combined_filter = create_label_filter(["Adelie", "Chinstrap"])
  • We can also remove the panning from the Events

    • This can help to identify the same point in the two different views

[29]:
def pan_filter(event):
    if event.data.get("pan", None) is not None:
        event.data["pan"] = 0
    return event
[30]:
context.processor.event_filters.append(pan_filter)

Setting the filter to an empty list [] will reset it

[31]:
context.processor.event_filters = []
[32]:
context.close()
Quitting SCServer... Done.
Exiting sclang... Done.
[ ]: