Machine states and source fingerprints
Domestic and mechanical sources such as ventilation units, fridges, pumps
and HVAC structure a soundscape as states rather than events: a
band-limited floor that is either present or absent, sometimes for hours.
The states module is a notebook-oriented toolbox for working with
them, developed on the Haarlem-loft case study (an air pump droning for
nine hours, a fridge cycling every ~24 minutes, a church clock at the
noise floor).
All of it runs from the cached features of a prior ambiscape analyze,
with no audio pass except segment export.
On/off segmentation (ambiscape.states)
from ambiscape import states
lvl = states.band_level(F, (250, 1000)) # the source's "machine band"
segs = states.state_segments(lvl, min_dur_s=120)
state_segments median-smooths the band level, splits it at an automatic
bimodal (Otsu) threshold with hysteresis, and merges segments shorter than
min_dur_s. Each segment reports its median level and within-state SD. A
running machine is steady (the Haarlem pump held ±0.2 dB for 9 h), while
ambience is not. switch_points lists the transitions (the moment someone
presses the off button); duty_cycle summarises a cycling machine as
period, duty fraction, and cycle count (a fridge: ~24 min at ~50 %).
Pass an explicit thresh_db when the timeline is not clearly bimodal.
Segment times (t0_s) index the 1 Hz feature rows, so map them through
F["t"] for absolute clock time in multi-take sessions.
Ask whether there are two states before splitting
sep = states.bimodal_separation(lvl)
if sep["bimodal"]:
segs = states.state_segments(lvl, thresh_db=sep["threshold_db"])
Otsu's method returns a threshold for any series, including one with a
single populated mode. The split then divides noise and duty_cycle
reports a period for a machine nothing detected, with nothing in the chain
signalling a problem. bimodal_separation returns the two class means,
their separation_db, the upper_fraction, and a bimodal flag that is
False when the modes are closer than min_separation_db or one class is
nearly empty.
One refrigerator in two rooms of the same house shows the range: 8.4 dB of separation and twelve clean cycles in the kitchen, 0.6 dB and a single night-long segment in the living room. A False flag does not prove the machine is absent — only that a two-state split of this timeline is not evidence that it is present.
A cycle is two quantities, not one
c = states.cycle_series(segs)
c["on_s"], c["period_s"] # per cycle, not medians
c["on_trend"]["rho"], c["period_trend"]["rho"]
A thermostat's on-time is set by the appliance and its period by the room,
so the two can move independently and a duty fraction — their ratio — hides
both. cycle_series returns each as a series with its correlation against
cycle number, the change per cycle and the spread. Read the trend against
the spread: a period moving by a quarter of itself and an on-time moving by
a minute can both correlate at 0.9. A refrigerator over one night: on-time
7.6 to 8.5 minutes, period 30.5 to 38.0.
A run still on when the series ends has an unknown length and is dropped
from on_s, with truncated_final_run set; the period series is measured
onset to onset and is unaffected.
For the same question asked of a level series rather than of segments, see
analysis.cycle_drift, which reports a median period and a drift percentage
over overlapping windows.
The crossing between states (states.transition_profile)
Segmentation tells you the states a room passed through. It says nothing about the crossings, and the crossing is what anyone in the room notices: a refrigerator does not fade in, it strikes, clatters for a moment and subsides into the hum that will be ignored for the next eleven minutes.
transition_profile describes each boundary — the direction, the size of
the step, the 10–90 % crossing time, and how long the level took to settle
into its new state:
from ambiscape.states import band_level, state_segments, transition_profile
lvl = band_level(F, (250.0, 1000.0))
segs = state_segments(lvl)
for t in transition_profile(lvl, segs):
print(t["direction"], t["step_db"], "dB in", t["crossing_s"], "s")
Why it matters: a fridge and a slow fade can end at the same level, and to any per-state descriptor they are identical. Only the crossing tells them apart.
The settling band is the wider of your tolerance and twice the new state's own variability, because a tolerance tighter than the state's noise would report that a steady state never settles — which says something about the tolerance and nothing about the room. "Stays inside" is a fraction rather than every sample, since a merely noisy state throws the occasional excursion past any band.
detect_cessations is the same idea without a segmentation to hand: it
finds a level that held steady, fell, and stayed down. Ordinary event
detection looks for level rising above a background, which is a good
definition of an arrival and no definition of a departure — so every
machine that stops was previously invisible.
Source fingerprints (background.source_fingerprint)
With minute masks for "source clearly on" and "clearly off" (e.g. derived from the state segments), the fingerprint is the dB difference of the two mean PSDs, which is the source's own spectrum with the room ambience subtracted:
from ambiscape import background
fp = background.source_fingerprint(F, active_minutes, quiet_minutes)
fp["rise_max_db"], fp["rise_max_hz"] # the broadband turbulence hump
fp["peaks"] # narrowband lines riding on it
fp["comb"] # {f0_hz, harmonicity} of the lines
A blade-pass or compressor comb reports its base frequency via the harmonic
sieve, which is 130 Hz for the Haarlem pump (~1950 rpm × 4 blades). Combine with
background.masking_index to quantify how much the source hides the rest of
the field.
Civic grid scans (schedule.grid_scan)
The complement of schedule.match_periods: instead of asking which grid an
event stream fits, look at every tick of a known grid for band-limited
energy, such as a church clock in the bell band, whether or not the broadband
detector heard it:
scans = schedule.grid_scan(F, 900.0, band=(350, 800), win_s=120)
Each quarter-hour tick reports detected, the peak rise_db above the
running band background, and the offset_s of that peak from the tick. A
consistent nonzero offset across ticks is recorder-clock error, so feed it
to schedule.clock_offset and store the result as clock_offset_s in
calibration.json.
Segment export (io.export_segment, io.stereo_preview)
from ambiscape.io import export_segment, stereo_preview
export_segment(sess, t0, 600.0, "seg6_vent_switchoff.wav") # bit-exact AmbiX
st = stereo_preview(x) # ±90° cardioids
export_segment copies samples using the source's own PCM subtype,
with no float round trip, so that a report's representative segments stay
citable against the raw takes. stereo_preview decodes an AmbiX block to
side-facing cardioids for listenable previews; write the result with
soundfile.