Similarity demo#

For the full set of experiment files, see demos/pipelines/04-similarity in the GitHub repository.

"""
In this experiment participants listen to pairs of sounds and rate their similarity on a scale from 1 to 5.
"""

# pylint: disable=missing-class-docstring,missing-function-docstring

from math import comb
from pathlib import Path

import psynet.experiment
from psynet.asset import Asset, asset  # noqa
from psynet.modular_page import ModularPage, RatingControl
from psynet.page import InfoPage
from psynet.participant import Participant
from psynet.timeline import Event, MediaSpec, ProgressDisplay, Timeline
from psynet.trial.static import StaticNode, StaticTrial, StaticTrialMaker


STIMULUS_DIR = "data/instrument_sounds"
STIMULUS_PATTERN = "*.mp3"

# With a large number of stimuli, the number of pairwise combinations becomes very large,
# so we need to limit the number of trials per participant.
N_TRIALS_PER_PARTICIPANT = 10


def get_nodes():
    stimuli = list_stimuli()
    return [
        StaticNode(
            definition={
                "stimulus_a": stimulus_a["name"],
                "stimulus_b": stimulus_b["name"],
            },
        )
        for stimulus_a in stimuli
        for stimulus_b in stimuli
        if stimulus_a["name"] != stimulus_b["name"]
    ]


def get_assets():
    stimuli = list_stimuli()
    return {
        stimulus["name"]: asset(
            stimulus["path"],
            extension=".mp3",
            cache=True,  # reuse the uploaded file between deployments
        )
        for stimulus in stimuli
    }


def list_stimuli():
    return [
        {
            "name": path.stem,
            "path": path,
        }
        for path in sorted(list(Path(STIMULUS_DIR).glob(STIMULUS_PATTERN)))
    ]


# Run `python3 experiment.py` to list the stimuli.
if __name__ == "__main__":
    stimuli = list_stimuli()
    print(f"Found {len(stimuli)} stimuli:")
    for stimulus in stimuli:
        print(f"- {stimulus['name']}")


class CustomTrial(StaticTrial):
    time_estimate = 10

    def show_trial(self, experiment, participant):
        return ModularPage(
            "ratings",
            "Please listen to Sound A and Sound B and rate their similarity on a scale from 1 to 5.",
            RatingControl(
                values=5,
                min_description="Not at all similar",
                max_description="Very similar",
            ),
            time_estimate=10,
            media=MediaSpec(
                audio={
                    "stimulusA": self.trial_maker.assets[self.definition["stimulus_a"]],
                    "stimulusB": self.trial_maker.assets[self.definition["stimulus_b"]],
                }
            ),
            events={
                "playStimulusA": Event(
                    is_triggered_by="trialStart",
                    js="psynet.audio.stimulusA.play();",
                    message="Sound A",
                    message_color="blue",
                ),
                "silence": Event(
                    is_triggered_by="audioFinished: stimulusA",
                    message="",
                ),
                "playStimulusB": Event(
                    is_triggered_by="silence",
                    delay=0.5,
                    js="psynet.audio.stimulusB.play();",
                    message="Sound B",
                    message_color="blue",
                ),
                "responseEnable": Event(
                    is_triggered_by="audioFinished: stimulusB",
                    delay=0.0,
                ),
                "submitEnable": Event(
                    is_triggered_by="responseEnable",
                    delay=0.0,
                ),
            },
            progress_display=ProgressDisplay([], show_bar=False),
        )


class Exp(psynet.experiment.Experiment):
    label = "Subjective rating"

    timeline = Timeline(
        InfoPage(
            """
            In this experiment you will hear some sounds. Your task will be to rate
            them for similarity on a scale of 1 to 5.
            """,
            time_estimate=5,
        ),
        StaticTrialMaker(
            id_="ratings",
            trial_class=CustomTrial,
            nodes=get_nodes,  # this is a callable, it only gets called on the local machine, where the input files are available
            assets=get_assets,  # likewise a callable
            expected_trials_per_participant=N_TRIALS_PER_PARTICIPANT,
            max_trials_per_participant=N_TRIALS_PER_PARTICIPANT,
        ),
        InfoPage("Thank you for your participation!", time_estimate=5),
    )

    def test_experiment(self):
        super().test_experiment()

        assert Participant.query.count() == 1
        assert CustomTrial.query.count() == N_TRIALS_PER_PARTICIPANT
        assert Asset.query.count() == len(list_stimuli())
        assert (
            StaticNode.query.count() == comb(len(list_stimuli()), 2) * 2
        )  # we see each combination twice, once in each order