Stimuli in SSTIM

SSTIM, the Sensory Stimulation Vocabulary, describes a stimulus in RDF: its signals, the channels it is presented on, and how each signal is rendered onto each channel. One module, music.stimulation.sstim, writes the package’s seven auditory stimuli and sessions of them as SSTIM 0.19.0 descriptions, renders them back, and reads what other tools write. It needs:

pip install 'music[sstim]'

A description says what a stimulus is. It is not a record that anyone heard it, nor a claim that it does anything, and SSTIM fixes no oscillator, phase, gain or random generator, so another engine can render a valid description differently and still be right. What the package can promise, it promises for its own renders.

Writing a stimulus, and rendering it back

to_sstim_graph() describes a stimulus as the call that renders it:

import music
from music.stimulation.sstim import (
    render_sstim, to_sstim_graph, validate_sstim,
)

graph = to_sstim_graph(
    music.binaural_beats, carrier_freq=200, beat_freq=10,
    duration=30, sample_rate=48000,
    base="https://your-lab.example/stimuli/run-001/")
report = validate_sstim(graph)
if not report.ok:
    raise ValueError(str(report))
graph.serialize("stimulus.ttl", format="turtle")
audio = render_sstim("stimulus.ttl")

The stimulus is named by its function or its name, "binaural_beats", and given the arguments it would be called with. Every argument left to its default is written out, so that a later default cannot change what the description renders. base is an IRI of your own that the description’s resources are named under; the default, under example.org, is for trying things, not for publishing.

The description is a StimulusSpecification holding SSTIM’s StimulationSignal, StimulusChannel and SignalRendering: the signals and their frequencies, the channels and where they are presented, and how each channel renders each signal – the mechanism, the parameter it drives, the carrier, and whether the result is physically in the sound or made by the listener. Each channel also states its frequencies in SSTIM’s own channel terms, so that a consumer reading only those finds them:

Stimulus

Channel terms, in sstim-ex:

binaural beats

hasCarrierFrequencyHz, each ear’s own tone; hasBeatFrequencyHz

monaural beats

hasCarrierFrequencyHz, hasBeatFrequencyHz

isochronic tones

hasCarrierFrequencyHz, hasModulationFrequencyHz, hasDutyCycle

amplitude modulation

hasCarrierFrequencyHz, hasModulationFrequencyHz

frequency modulation

hasCarrierFrequencyHz, hasModulationFrequencyHz

modulated noise

hasStimulusPattern, its colour; hasModulationFrequencyHz

spatial motion

hasToneFrequencyHz; hasModulationFrequencyHz, the motion’s

sstim-ex: standing for https://w3id.org/sstim/exposure#. What SSTIM has no term for – a depth, a deviation, a ramp, a seed, a geometry, the sample rate – goes in the package’s own namespace, https://github.com/ttm/music/terms#, never minted as SSTIM’s, together with the name of the stimulus that renders it. Those make the render exact: render_sstim() renders a description as the call it records would, sample for sample, and with oversampling_factor=4 renders it through music.render_oversampled() (see Band-limited synthesis).

validate_sstim() checks a description with SSTIM’s own validator, in its Full profile, which the sstim package fetches the first time and caches. It checks the description, not the samples.

Noise and spatial motion

A noise is described as a stochastic band, not a carrier. Its colour is SSTIM’s noise pattern where SSTIM has one: white, pink, brown, blue and violet do. The package’s black noise, which SSTIM’s black – silence – is not, and a slope in decibels an octave have none, and stay in the package’s terms. A seed is recorded, so the description renders the same noise every time; without one, each render draws anew.

noise = to_sstim_graph(
    "modulated_noise", noise_type="pink", min_freq=100,
    max_freq=10000, modulation_freq=10, seed=42,
    duration=15, sample_rate=48000,
    base="https://your-lab.example/stimuli/noise-1/")

Spatial motion is described as a tone and, separately, a signal of position, both presented on the two ears:

motion = to_sstim_graph(
    "spatial_motion", carrier_freq=440, motion_rate=0.5,
    theta1=180, theta2=0, dist=0.1,
    duration=12, sample_rate=48000,
    base="https://your-lab.example/stimuli/motion-1/")

The motion is the package’s geometric one, from interaural time and intensity differences, not a measured head: it carries no elevation and cannot tell front from back. Only the stimuli’s own sine modulator and tone are written; a custom waveform table is not.

Sessions

A music.StimulationSession is written the same way. SSTIM 0.19 has no terms for phases in order, so a session is a program in the package’s terms – a StimulationProgram and its phases, with their order, ramps and gains – each phase an SSTIM description of its own:

session = music.StimulationSession(sample_rate=48000, end_ramp=.05)
session.add(music.binaural_beats, duration=600, beat_freq=10)
session.add(music.modulated_noise, duration=300, ramp=2,
            noise_type="pink", seed=42)
graph = to_sstim_graph(
    session, base="https://your-lab.example/plans/001/")
graph.serialize("program.ttl", format="turtle")

Any of the seven stimuli can be a phase, with arguments a description holds: a session holding a rendered array, another callable or a custom table is refused, and so is one of more than fifty phases. A noise phase without a seed stays random.

Given a SessionPlan, a session of a whole number of seconds, from a minute to two hours, is also SSTIM’s own SessionSpecification, which SSTIM’s Full profile accepts only with a preset, a creation time and a master volume. The plan gives all three, rather than the package inventing them:

from datetime import datetime, timezone

from music.stimulation.sstim import SessionPlan

plan = SessionPlan(
    preset_iri="https://your-lab.example/presets/alpha-v1",
    preset_label="Alpha, version 1",
    created_at=datetime.now(timezone.utc),
    master_volume=0.2)
planned = to_sstim_graph(
    session, plan=plan, base="https://your-lab.example/plans/002/")

The specification wraps the program and declares only reproEquivalentPresentation. Its configurationDigest, named sha256-music-plan-json-v1, is the SHA-256 of a JSON object, its keys sorted, written without spaces and in UTF-8 rather than escaped to ASCII: program_ntriples, the program’s N-Triples lines, sorted; preset_iri; master_volume, as a plain decimal such as "0.25"; and duration_seconds, a whole number. It changes with any of them, and identifies the plan as the package writes it, not the samples another engine would render. Neither a program nor a plan is an sstim:SessionInstance, which would claim a delivery: no clock, listener or record of playback is written. The sstim package’s own Session writes one, from what happened.

Reading a description back

from_sstim_graph() gives back what renders a description: an SstimStimulus – the stimulus’s name, its arguments, its length and its rate – a music.StimulationSession, or a PlannedSession, each with its render():

from music.stimulation.sstim import from_sstim_graph

stimulus = from_sstim_graph("stimulus.ttl")
print(stimulus.generator, stimulus.parameters)
audio = stimulus.render()

A description the package wrote is read strictly. Its statements in SSTIM’s terms and the package’s, about the resources it names, must be exactly those the package writes for what it says: one whose package terms say 40 Hz and whose SSTIM terms say 10, or that adds a gain or a phase in either, is refused rather than half believed. Statements in other vocabularies – a label, a comment, provenance – are left alone, so a description can be annotated freely. What is read back describes itself again: given the same base, to_sstim_graph(from_sstim_graph(graph), base=base) writes the same statements.

Reading another tool’s description

A description another tool wrote, in SSTIM’s terms alone, is read as one of the seven stimuli, which its mechanisms and signals say:

It states

It is read as

a noise signal

modulated noise

a signal rendered onto spatial position

spatial motion

a binaural beat of a sine signal

binaural beats

a monaural beat of a sine signal

monaural beats

amplitude modulation by a square signal

isochronic tones

amplitude modulation by a sine signal

amplitude modulation

frequency modulation by a sine signal

frequency modulation

Whatever it states is read from it: carriers, rates, a duty cycle, a noise’s band and colour, its length. What SSTIM has no term for is given, with the sample rate, which SSTIM never fixes, and nothing is taken from the package’s defaults. inspect_sstim() says what is needed:

from music.stimulation.sstim import inspect_sstim, render_sstim

path = "tests/fixtures/sstim_third_party_am.ttl"
print(inspect_sstim(path).needs)
# ('sample_rate', 'modulation_depth')
audio = render_sstim(path, sample_rate=48000,
                     controls={"modulation_depth": 0.7})

That file was written by hand, without the package, as were the noise and the spatial motion beside it in the repository’s tests/fixtures. A beat needs only the sample rate. The others need, in controls:

Stimulus

Controls

isochronic tones

ramp_duration; duty_cycle, unless hasDutyCycle states it

amplitude modulation

modulation_depth

frequency modulation

frequency_deviation

modulated noise

seed; modulation_depth, if it is modulated; noise_type, unless a pattern names it

spatial motion

theta1, theta2, dist, zeta and air_temp

and duration, given apart, when no channel states one. A control the description states may be given too, and must then agree with it.

The package’s own description of the stimulus read must then state everything the given one does. What is compared is what the two say, not how: node names, which may be blank, the order of statements, how a number is written and the labels may all differ, and numbers agree to a part in a billion. A description may leave out what SSTIM makes optional, such as a carrier or a channel’s frequencies, and may state a more general term where the package writes a specific one: a modulation frequency for a beat, or a channel’s plain hasFrequencyHz. It may state what says nothing about the sound – what the stimulation targets, a band a signal lies within, a channel’s role, comfort boundary or device – and that is left alone. It may not state an SSTIM term the package does not render, such as a gain, a phase offset or a sound pressure level, since the render would not be what it describes; nor contradict itself, as two carriers not one beat apart do. Each refusal says what was found.

A session in SSTIM’s terms alone names a preset only its own engine renders, and is refused.

Beats by a named convention

A binaural or monaural beat can also be rendered without the package’s generator, by a convention another engine can follow to within the rounding of its sines, BEAT_PROFILE: both tones are sines starting at phase zero, and a monaural beat is their mean.

from music.stimulation.sstim import BEAT_PROFILE, from_sstim_graph

beat = from_sstim_graph("beat.ttl", profile=BEAT_PROFILE,
                        sample_rate=48000)
print(beat.carrier_freq, beat.beat_freq)
samples = beat.render()

SSTIM fixes neither the phase nor the gain, so the convention is the package’s, named, and not SSTIM’s: a render that agrees in beat, laterality and mechanism can differ in every sample from another engine’s that does not follow it. A description the package wrote can be rendered by it too.

From the command line

tools/sstim_interop_check.py renders a Turtle file with render_sstim’s arguments, read from a JSON file of at most 8 KiB:

python tools/sstim_interop_check.py \
    tests/fixtures/sstim_third_party_am.ttl \
    tests/fixtures/sstim_third_party_am.render.json \
    --json interop-report.json

where the JSON is {"sample_rate": 48000, "controls": {"modulation_depth": 0.7}}. Its report gives the package’s version, the stimulus and what rendered it, the shape, the peak and RMS, and SHA-256 digests of the samples, as little-endian float64, and of the arguments, which identify a render on this installation and nothing more. --official also validates the file against SSTIM’s Full profile, and --wav writes the samples as 24-bit PCM.

None of this is an agreement between engines. A shared reference for phase and amplitude, a random stream, a localization model, calibration of the hardware, timing and records of delivery would each need a rendering profile versioned apart from any one engine, with shared fixtures and tolerances, and a perceptual comparison would need listeners.

What can be done with a description

inspect_sstim() says, before anything renders, what the package can do with a description:

capability = inspect_sstim("stimulus.ttl")
print(capability.kind, capability.generator, capability.needs)

kind is stimulus, session, planned-session, or unsupported with the reason. written_by_package says whether it uses the package’s terms, by which it is then judged whole: one whose package terms disagree with its SSTIM terms is unsupported, rather than read by whichever set suits. needs lists what rendering still needs, profiles the conventions a beat can also be rendered by, and can_render is true when nothing more is needed. It is a quick local reading, not the validator.

Reading safely

Every reader takes a graph, a local file or Turtle text, never a URL, and follows no link in a graph: up to 2 MiB of Turtle and 15,000 triples. No name in a graph is imported or run; the stimuli it can name are a fixed list.

A description is only numbers, so writing or reading one has no length cap: a two-hour plan is written and read like a minute’s. render_sstim renders at most MAX_SAMPLES samples a channel, about 113 s at 44.1 kHz, unless given a larger max_samples, so that a description from elsewhere cannot exhaust memory by accident. What from_sstim_graph returns renders with no cap, once its length has been seen.

The bounds stop a large file being read by accident. They do not make a hostile one safe, since the parser has run before the triples can be counted: do not read untrusted Turtle without isolating the process and limiting what it may use.

Reference

None of these is exported at the top level: import them from music.stimulation.sstim, with pip install 'music[sstim]'.

music.stimulation.sstim.to_sstim_graph(source, /, *, base=None, plan=None, duration=None, sample_rate=None, **parameters)[source]

Describe a stimulus or a session in SSTIM’s terms.

A stimulus is described as the call source(duration=duration, sample_rate=sample_rate, **parameters) renders it, with every argument left to its default written out, so that a later default cannot change what the description renders. Its SSTIM terms state its signals, its channels and their frequencies; the package’s state what SSTIM has no term for, and which stimulus renders it.

A session is a program of its phases in the package’s terms, each an SSTIM stimulus. With a plan, it is also SSTIM’s SessionSpecification: a plan of a whole number of seconds, from 60 to 7,200, that names a preset and declares reproEquivalentPresentation.

Parameters:
sourcecallable(), str, SstimStimulus, StimulationSession or PlannedSession

One of the seven stimuli, by its function or its name (see STIMULI); a stimulus from_sstim_graph() read; a session whose phases are each one of the seven by its function; or a planned session.

basestr, optional

Your own http(s) IRI, ending in / or #, the description’s resources are named under. Defaults to one under https://example.org/music/, which is fine to try things but not to publish.

planSessionPlan, optional

For a session: the preset, creation time and master volume that make it a SessionSpecification.

durationfloat, optional

For a stimulus: seconds. Defaults to the generator’s.

sample_rateint, optional

For a stimulus: samples per second, from 8,000 to 192,000. Defaults to the generator’s. SSTIM has no term for it, so it is written in the package’s.

**parameters

For a stimulus: its arguments, but for its length, rate, source sound and tables, which are not numbers a description holds. A noise’s colour is named or a slope; its seed is None or an integer below 2**64.

Returns:
rdflib.Graph

The description. Check it with validate_sstim().

Raises:
ValueError

If the stimulus, an argument or base cannot be described, or a planned session is not a whole number of seconds from 60 to 7,200.

TypeError

If a stimulus is given a plan, or a session given arguments.

Parameters:
Return type:

Graph

Examples

>>> from music.stimulation import binaural_beats
>>> graph = to_sstim_graph(binaural_beats, carrier_freq=200,
...                        beat_freq=10, duration=60)
>>> from_sstim_graph(graph).parameters
{'carrier_freq': 200.0, 'beat_freq': 10.0}
music.stimulation.sstim.from_sstim_graph(value, /, *, controls=None, profile=None, sample_rate=None, duration=None)[source]

Read a description: what renders it, checked.

A description the package wrote gives back what was described: a SstimStimulus, a music.StimulationSession or a PlannedSession. One in SSTIM’s terms alone gives the SstimStimulus it describes, given what SSTIM cannot say; or, for a beat with profile, an SstimBeat. See the module’s introduction for how each is checked.

Parameters:
valuerdflib.Graph, str or Path

The description: a graph, a local Turtle file, or Turtle text.

controlsmapping, optional

For a description in SSTIM’s terms alone: the stimulus’s arguments it does not state, by name. Those it states may be given too, and must agree.

profilestr, optional

BEAT_PROFILE, to read a beat as an SstimBeat.

sample_rateint, optional

The rate to render at, which a description in SSTIM’s terms alone needs: SSTIM fixes none. One the package wrote fixes its own, which this must match if given.

durationfloat, optional

Seconds, for a description whose channels state no length; a length stated must match if given.

Returns:
SstimStimulus, SstimBeat, StimulationSession or PlannedSession

What renders the description, with its render().

Raises:
ValueError

If the description is not one the package can render as stated, or lacks or contradicts what is given: the message says which.

Parameters:
Return type:

SstimStimulus | SstimBeat | StimulationSession | PlannedSession

music.stimulation.sstim.render_sstim(value, /, *, controls=None, profile=None, sample_rate=None, duration=None, oversampling_factor=None, max_samples=5000000)[source]

Render a description, as from_sstim_graph() reads it.

Parameters:
valuerdflib.Graph, str or Path

The description: a graph, a local Turtle file, or Turtle text.

controls, profile, sample_rate, duration

As for from_sstim_graph().

oversampling_factorint, optional

For one of the package’s stimuli: render through music.render_oversampled() at this many times the rate, from 2 to 16, which needs SciPy. None renders directly.

max_samplesint

The most samples a channel may have, so that a description from elsewhere cannot exhaust memory by accident. Raise it to render a longer one.

Returns:
ndarray

Mono, or (2, nsamples) for the binaural beats, spatial motion and a session containing either.

Raises:
ValueError

As from_sstim_graph() does; or if the render would be longer than max_samples, or oversampling_factor is given for a session or a beat rendered by its profile.

Parameters:
  • value (Graph | str | Path)

  • controls (Mapping[str, Any] | None)

  • profile (str | None)

  • sample_rate (int | None)

  • duration (float | None)

  • oversampling_factor (int | None)

  • max_samples (int)

Return type:

NDArray[float64]

music.stimulation.sstim.inspect_sstim(value, /)[source]

Say what the package can do with a description, rendering nothing.

A description the package wrote is checked whole, as from_sstim_graph() checks it. One in SSTIM’s terms alone is read as one of the seven stimuli, and checked with the generator’s defaults standing in for the controls it leaves unsaid, which needs then lists. Needs RDFLib, not SSTIM’s validator.

Parameters:
valuerdflib.Graph, str or Path

The description: a graph, a local Turtle file, or Turtle text.

Returns:
SstimCapability

What it is, and what rendering it still needs.

Raises:
ValueError

If value is not a local Turtle description within the budgets.

Parameters:

value (Graph | str | Path)

Return type:

SstimCapability

music.stimulation.sstim.validate_sstim(value, /, *, version='0.19.0')[source]

Check a description against SSTIM’s Full profile, by SSTIM’s validator.

The validator fetches the release’s modules the first time, checks their checksums and caches them, so it needs the network once. Its checks are SSTIM’s, and stricter than reading a description: one the package reads may still not conform, and one that conforms may still be one the package cannot render.

Parameters:
valuerdflib.Graph, str or Path

The description: a graph, a local Turtle file, or Turtle text.

versionstr

The SSTIM release to validate against.

Returns:
sstim.Report

Whether it conforms, with any terms it uses that SSTIM does not define or that are minted in SSTIM’s namespace.

Raises:
ImportError

If SSTIM’s validator is not installed.

Parameters:
Return type:

Any

What they take and give

class music.stimulation.sstim.SstimStimulus(generator, parameters, duration, sample_rate)[source]

Bases: object

One stimulus as a description gives it.

Attributes:
generatorstr

Which of STIMULI it is.

parametersdict

Every one of its arguments but its length, rate and tables.

durationfloat

Seconds.

sample_rateint

Samples per second.

Parameters:
generator: str
parameters: dict[str, Any]
duration: float
sample_rate: int
render(*, oversampling_factor=None)[source]

The samples, as the stimulus renders them.

Parameters:
oversampling_factorint, optional

Render through music.render_oversampled() at this many times the rate, from 2 to 16, which needs SciPy; None renders directly.

Returns:
ndarray

Mono, or (2, nsamples) for the binaural beats and spatial motion.

Parameters:

oversampling_factor (int | None)

Return type:

NDArray[float64]

class music.stimulation.sstim.SstimBeat(generator, carrier_freq, beat_freq, duration, sample_rate)[source]

Bases: object

A beat as SSTIM’s terms give it, to render by BEAT_PROFILE.

SSTIM states a beat’s tones, its rate and its length; it does not fix the tones’ phase or gain, so this renders them by a named convention, not by the package’s generator: both start at phase zero, and a monaural beat is their mean. Another engine following the convention renders the same samples, to within the rounding of its sines.

Attributes:
generatorstr

binaural_beats or monaural_beats.

carrier_freqfloat

The frequency midway between the two tones, in Hz.

beat_freqfloat

The difference between them, in Hz.

durationfloat

Seconds.

sample_rateint

Samples per second.

Parameters:
generator: str
carrier_freq: float
beat_freq: float
duration: float
sample_rate: int
render()[source]

The samples, by BEAT_PROFILE.

Returns:
ndarray

(2, nsamples) for a binaural beat, one tone in each ear; mono for a monaural one.

Return type:

NDArray[float64]

class music.stimulation.sstim.SessionPlan(preset_iri, preset_label, created_at, master_volume=1.0)[source]

Bases: object

What SSTIM’s SessionSpecification needs beyond the session itself.

Attributes:
preset_iristr

An http(s) IRI naming the preset, outside SSTIM’s namespace.

preset_labelstr

The preset’s name, not empty.

created_atdatetime

When the plan was made, with its time zone.

master_volumefloat

The playback level, from 0 to 1.

Parameters:
preset_iri: str
preset_label: str
created_at: datetime
master_volume: float = 1.0
class music.stimulation.sstim.PlannedSession(session, plan)[source]

Bases: object

A session, and the plan a SessionSpecification made of it.

Attributes:
sessionStimulationSession

The session, as the program in the description gives it.

planSessionPlan

The preset, the creation time and the master volume.

Parameters:
session: StimulationSession
plan: SessionPlan
render()[source]

The session’s samples, as session.render() gives them.

Return type:

NDArray[float64]

class music.stimulation.sstim.SstimCapability(kind, written_by_package, generator, needs, profiles, reason)[source]

Bases: object

What the package can do with a description.

Attributes:
kindstr

stimulus, session (a program the package wrote) or planned-session (an SSTIM SessionSpecification around one), or unsupported.

written_by_packagebool

Whether it says anything in the package’s own terms. Such a description carries every control and renders as written, and is refused if those terms and SSTIM’s disagree; any other is read by SSTIM’s terms alone.

generatorstr or None

Which of STIMULI renders a stimulus.

needstuple of str

What render_sstim() must still be given: sample_rate, duration, and by name the controls SSTIM has no term for.

profilestuple of str

The named conventions it can also be rendered by without them but for sample_rate: BEAT_PROFILE, for a beat.

reasonstr

Why it is unsupported; empty otherwise.

Parameters:
kind: str
written_by_package: bool
generator: str | None
needs: tuple[str, ...]
profiles: tuple[str, ...]
reason: str
property can_render: bool

Whether render_sstim() renders it given nothing more.

Constants

music.stimulation.sstim.STIMULI

The seven stimuli a description can be written for, by name: binaural_beats, monaural_beats, isochronic_tones, amplitude_modulation, frequency_modulation, modulated_noise and spatial_motion.

music.stimulation.sstim.BEAT_PROFILE = "zero-phase-equal-gain-beats-v1"

The convention a beat in SSTIM’s terms alone can be rendered by.

music.stimulation.sstim.SSTIM_VERSION = "0.19.0"

The SSTIM release descriptions are written in and validated against.

music.stimulation.sstim.MAX_SAMPLES = 5000000

How many samples a channel render_sstim() renders at most, by default.

music.stimulation.sstim.SSTIM
music.stimulation.sstim.SSTIM_V
music.stimulation.sstim.SSTIM_EX
music.stimulation.sstim.MUSIC

The namespaces: SSTIM’s core, https://w3id.org/sstim#; its vocabulary, https://w3id.org/sstim/vocab#; its exposure module, https://w3id.org/sstim/exposure#; and the package’s own, https://github.com/ttm/music/terms#, as rdflib.Namespace.