Skip to content

Working with other packages

The intended shape

The fourMs packages divide by signal domain, and the dependency arrow points from the heavy packages to the light ones:

Package Owns Weight
musicalgestures (MGT) video in, visual features out ~282 MB — opencv, librosa, numba, scikit-image; ffmpeg binary
ambiscape spatial audio in, soundscape features out numpy, scipy, soundfile, matplotlib
musiscape music corpora, fingerprints, similarity + librosa, ambiscape
micromotion motion time series: mocap, IMU, force plate numpy, scipy, pandas

micromotion depends on none of them, deliberately. Someone analysing accelerometer data should not have to install a computer-vision stack to do it, and the arrow that makes sense runs the other way, with MGT depending on this package.

There is an existing precedent for how to cross the boundary. MGT's _soundscape.py consumes ambiscape's output and adapts it at the seam, and ambiscape never imports MGT.

What to reach for ambiscape for

ambiscape owns the sound side of a cross-modal study, and its outputs are per-second tables that join straight onto a micromotion series on a shared clock. In this corpus it supplied the whole soundscape layer: the feature pass, the mechanical / anthropophony / geophony / tonality domain indices, enf for mains hum, speechgate for a privacy check, and taxonomy for Schafer and Schaeffer figures. Docs: https://fourms.github.io/ambiscape/, and its interop page is the mirror of this one.

Two hazards live at that join, both learned the hard way here.

Clocks are not shared just because recordings are simultaneous. Devices started separately drift, and a phone app that suspends loses time from its timeline rather than leaving a gap, as formats describes for Physics Toolbox. Align on a physical event present in both signals, such as a tap that registers in the accelerometer and the microphone at once. An acoustic clap will not do it: it leaves nothing in an accelerometer.

A cross-modal null is worth little without a positive control. Before concluding that a body does not track its surroundings, check that the same statistic detects something it should. Correlating two environmental channels against each other is the cheap version of that check. If sound and light track each other and the body tracks neither, the null is about the body rather than about the pipeline.

Overlap with MGT, and a measured disagreement

MGT contains band_limited_qom, accel_to_speed, read_qtm_tsv, cop_sway_metrics and respiration_rate. Their docstrings credit the same source studies as this package, and they were copied into MGT rather than the other way round, so this is not a case of matching someone else's prior art. Both packages are published, so the overlap is a published one.

band_limited_qom band-limits the position and not the speed derived from it. Differentiation amplifies the high end, so the velocity carries energy above the stated upper edge and nothing removes it. Decomposed on a 200 Hz optical recording at a matched band:

recipe mean speed
first difference, no second band-pass 3.1475 mm/s what band_limited_qom does
central difference, no second band-pass 3.1490 +0.05 %, so the differentiation rule is not the cause
first difference, second band-pass 2.9743 −5.50 %, which is the whole of it
central difference, second band-pass 2.9754 what qom and speed_from_position do

The gap is therefore 5.5 per cent, and almost all of it is the second band-pass rather than the differentiation rule. micromotion.qom is the one that respects its stated band. band_limited_qom is kept unchanged, in both packages, so that figures already computed with it keep reproducing. State which package and which function produced any number reported.

One disagreement is unresolved. balance.axial_rayleigh, absorbed from MGT, returns R = 0.9985 where circular.rayleigh_axial returns 0.9222 on the same bimodal input. Only the latter matches the textbook resultant length of the doubled angles.

MGT: video motion into micromotion

MGT's released motiondata() returns a file path, not an array:

import pandas as pd, musicalgestures as mg

path = mg.MgVideo("dance.mp4").motiondata(data_format="csv")   # -> str
df = pd.read_csv(path)
arr = df[["ComX", "ComY"]].to_numpy()      # (n_samples, 2)
fs = 1000.0 / df.Time.diff().median()      # Time is in milliseconds

Three things to know.

Use the CSV, not the TSV or TXT. Those write Qom with an integer format, so values normalised to 0–1 truncate to zero.

The Qom column is divided by its own maximum, so it is dimensionless and not comparable between recordings. The centroid columns are normalised to frame width and height. If you want a physical speed, feed the centroid columns to micromotion.qom with kind="position", but remember that the units are frame fractions rather than millimetres, so scale them first or treat the result as relative.

Prefer MgVideo.fps over recovering the rate from Time, which is rounded to integer milliseconds and loses precision at rates like 29.97.

ambiscape: soundscape features alongside motion

ambiscape emits a dict[str, np.ndarray] in an .npz, on four fixed time bases:

Vector Rate Contents
t_hi 50 Hz env_hi
t_fast 8 Hz fast_db, fast_dba
t 1 Hz rms_w, centroid, az, el, diffuse, octave bands
min_t 1/60 Hz minspec

Times are absolute seconds from session midnight and are not monotonic across overlapping takes. Levels are uncalibrated dBFS, not SPL.

To relate a soundscape to a body, downsample both to a common rate and align:

F = np.load("features.npz")
lag = mm.search_lag(F["t"], F["rms_w"], t_motion, qom_1hz, max_lag_s=300)

ambiscape computes no quantity of motion and explicitly defers video analysis to MGT, so there is no overlap to worry about there.

musiscape and respy

musiscape emits only per-track scalars, since it time-averages its per-frame features away before writing features.json. There is nothing to interoperate with.

respy is not a fourMs package; it is a personal one by a RITMO colleague, single release, 2023. It emits a time-indexed pandas.DataFrame and infers its sampling rate from the index with a round(), which mis-reads genuine 12.5 or 62.5 Hz recordings. Its published metadata declares no dependencies, so pip install respy installs nothing and fails on import. Adapt it at the boundary if you need it:

fs = 1.0 / np.mean(np.diff(df.index))     # do not round

micromotion.detect_breaths covers the common case without the dependency.