diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 85da741..f987e6e 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -122,5 +122,178 @@ # `OK (skipped=2)`, validate_api 55 instruments and 45 effects, the smoke # green -- the same counts as at 02b1841. # +# 2026-09-27: 4e58091 -> v0.6.1 (094dde5), on Brad's instruction. The +# effects program's Phase 5 (Time) was un-parked in a cloud session, where +# the desktop interpreters are built from the audiodsp checkout beside the +# anchor -- and at 4e58091 the unix MicroPython no longer links under +# micropython-pydevices' current overlay: every usermod entry in the module +# table comes up undefined (the manifest names the modules the way audiodsp +# main has named them since #144), so not one desktop gate could run at the +# floor this file named. Told that, Brad: "Move the pin and document that I +# told you to." And, the same hour: v0.6.1 was tagged on 2026-09-25 and is +# on TestPyPI, on the MIP index and on production PyPI (uploaded by hand +# after the Trusted Publisher step failed that day); the only commit past it +# is #151, which moves the PyPI upload into audiodsp's own workflow, and no +# source file differs -- so the desktop pair built from main (dd84add) is +# the release's DSP byte for byte. What moved beneath the components between +# 4e58091 and v0.6.1, by `git log 4e58091..v0.6.1 -- src`: a mixer voice at +# level 1.0 is a wire (audiodsp#129); the shaper's oversample ceiling is 16 +# and a build-time knob (#131); a deinit() holds the pump lock over its whole +# body (#132); the pump's 64-bit status words are read under the 32-bit- +# atomics rule (#133); a file-backed source is refused at handover and the +# funnel refuses a loop entered at any node (#134, #135); a Tap read with +# nothing new says so (#136); a refused press is counted, so the instrument +# layer can see it (#137); a strike on a modal bank can carry a frame (#138); +# audiomodal's inner loop runs over the modes that can sound (#139); the +# pump's per-block path can be placed in IRAM (#142, #143); and ulab is named +# by the manifest once for every port (#144). Re-run here at the new pin, on +# the cloud desktop (Linux x86-64, Python 3.12, MicroPython v1.29.0 + +# overlay, CircuitPython 10.3.0 + the apply scripts): the unit suite `Ran +# 1548 tests ... OK (skipped=2)`, two more than the 1546 the same tree +# counted before this move's test edits, because #129 is the fix the four +# audiodsp#95 plants were waiting for (Mix 0 through +# the dry voice at unity, in Compressor, DeEsser, DynamicEQ and +# MultibandCompressor: it cannot fire on this floor, so each is a control +# now, and Compressor and MultibandCompressor gained the one-LSB dry-path +# plant the other two already had, so every WIRE row still has a fault it +# is shown catching); validate_api 55 instruments and 45 effects; +# validate_metadata valid; flake8 clean; the smoke 45 classes, 249 patches, +# 0 failures on CPython, MicroPython and CircuitPython; verify_dsp 49 +# comparisons, 0 failures, on all three. Pending both boards: +# this move has not been proven on the P4 or the S3 -- there is no board in +# the cloud -- and the Phase 5 board test plan carries it as its first row. +# The pyproject floors stay at >=0.5.1; raising them is a release-time +# change and Phase 7's. +# +# 2026-09-27: v0.6.1 -> v0.6.2 (1c89b03), on Brad's instruction, the same +# day: "merge it and release and move the pin ... Work unattended and +# continue with effects after the release and pin move." v0.6.2 is on +# TestPyPI and production PyPI (the first upload through audiodsp's own +# Trusted Publisher). What moved beneath the components between v0.6.1 and +# v0.6.2, by `git log v0.6.1..v0.6.2 -- src`: audiopump.Tap gained a C +# stream reader (#152; no DSP node touched), and audioecho.FeedbackDelay's +# fed-back term is truncated toward zero exactly where round-to-nearest +# could hand a repeat back unchanged (#154, `recirculated()`, +# src/shared/audiodsp_feedback_delay.c:344, called at :529), so a tail +# reaches exact zero at every feedback. Below feedback 0.5 the output is +# byte-identical; above it a tail's last few LSB differ, 1-3 LSB and at most +# 6 at 0.99. That fix turned ten assertions red here, each pinning the old +# floor: DigitalDelay's floor test, CombFilter's parked-residue, parked-ring +# and un-reset-line tests, and Flanger's F8 audio T60 at Color max, which +# reads 1.91 s against its 2.0 s bar (2.13 s at v0.6.1: the floor was +# holding the ring up). The first eight are rewritten to the fixed +# behaviour, each shown red at v0.6.1; Flanger's two stay red for the gate +# audit, because the bar is not the pin move's to lower. Re-run here at the +# new pin on Brad's workstation (Linux x86-64, Python 3.12, MicroPython +# v1.29.0 + overlay and CircuitPython 10.3.0 + the apply scripts, both +# rebuilt from v0.6.2): the unit suite `Ran 1601 tests ... FAILED +# (failures=2, skipped=2)`, the two being Flanger's F8 rows above; +# validate_api 55 instruments and 45 effects; validate_metadata valid; +# flake8 clean; the smoke 45 classes, 249 patches, 0 failures on CPython, +# MicroPython and CircuitPython, line for line what v0.6.1 printed. A +# burst-and-two-seconds census of the whole catalogue (every class and +# parked rebuild, constructor default and every patch, 316 cells; the anchor's +# docs/effects-internal/probes/phase5_probes/pin062_catalogue_digest.py) +# moved 18 cells, the same 18 with the same digests on all three +# interpreters: CombFilter (5), the parked Flanger (6), TapeDelay (3), +# AnalogDelay (2), PingPongDelay (1) and the parked DigitalDelay (1). +# Pending both boards, as v0.6.1 was. +# +# 2026-09-28: v0.6.2 -> v0.6.3rc1 (bd7d2e2), a pre-release of the next +# patch, on Brad's ruling of the same day, "fix the node now, in parallel". +# It is on TestPyPI only (18 wheels; pre-releases skip production PyPI and +# MIP), so CI's `git+...@v0.6.3rc1` install is the one that reaches it. +# What moved beneath the components, by `git log v0.6.2..v0.6.3rc1 -- +# src`: audiodsp#161, four fixes to audioecho.FeedbackDelay. A loop +# low-pass taken out (damping_hz 0) keeps its state on the tap, and a +# high-pass taken out (cut_hz 0) holds its state at zero, where both used +# to freeze and play back out of silence when the filter came back (#158, +# #159); a damping state that has stopped moving at or below 64 LSB is set +# onto its input once a block, so a tail reaches exact zero with the +# low-pass in (#157); a new wow_depth_ms ramps in over 20 ms instead of +# landing at once (#160). Eight tests here pinned the old node or proved a +# class-side workaround was needed (DigitalDelay 3, SlapbackDelay 3, +# CombFilter 2, one of them the stall canary) and went red at the new pin, +# nothing else did; the workarounds came out in the same change (the +# 32 x rate tracking off stops, the stall-window stepping, the Repeat Cut +# disclosure, SlapbackDelay's Wow step) and those tests assert the good +# behaviour directly, each beside a planted fault. The census (every class +# and parked rebuild at the default and every patch, 319 cells) is +# identical to v0.6.2's on CPython, MicroPython and CircuitPython, both +# before and after the workarounds came out; the library smoke moves 15 +# peaks on all three, all where a patch change on a playing instance moves +# a wow depth (Chorus 1-5, Vibrato 3-6, the parked Flanger 1-6), which is +# #160's ramp. Re-run here on Brad's workstation, with the desktop +# interpreters rebuilt at the tag (audioecho.__revision__ +# v0.0.3-290-gbd7d2e2 on MicroPython; CircuitPython carries none, and its +# provenance stamp names the same): the unit suite `Ran 1693 tests ... OK +# (skipped=3)`; validate_api 55 instruments and 45 effects; +# validate_metadata valid; flake8 clean; the smoke 45 classes, 259 +# patches, 0 failures on CPython, MicroPython and CircuitPython. Pending +# both boards. +# +# 2026-09-28: v0.6.3rc1 -> v0.6.3rc2 (0d35a90), on this class branch +# (effects/phase5-convolutionreverb) first, so ConvolutionReverb is audited +# once, on the fixed node (Brad, the same day). A pre-release of the next +# patch, on TestPyPI only, like rc1. What moved beneath the components, by +# `git log v0.6.3rc1..v0.6.3rc2 -- src`: audiodsp#165, two fixes to +# audioconvolve.Convolver. A re-synthesis on a node that holds an impulse +# keeps the node's state: the block in flight is recomputed against the +# old room and the new and crossfaded over its unplayed frames, no frame +# of the dry drops at any mix, and from the next block the node is a node +# built with the new room (#163; load() still resets, and a first +# synthesis on an empty node still does). A stereo synthesized room gets +# one energy and one scale per side, so it no longer leans (#164). Mono +# rooms and measured impulses render the bytes they did; every stereo +# synthesized room moved. Five ConvolutionReverb tests pinned a defect or +# a figure of the old node and went red at the new pin, nothing else did; +# each is restated to the good behaviour beside a planted copy of the old +# (ResetOnMove renders the v0.6.3rc1 node's bytes on a mono room). Station +# C's 60 digests agree on CPython, MicroPython and CircuitPython built at +# the tag (bin/micropython-063rc2, audioecho.__revision__ +# v0.0.3-295-g0d35a90, and bin/circuitpython-063rc2, whose provenance +# stamp names the same): the 30 stereo cells moved, the 30 mono cells did +# not. Gates here: the unit suite `Ran 1966 tests ... OK (skipped=3)` +# (no Flanger F8 failure: effects/phase5 restated F8); validate_api 55 instruments and 45 effects; +# validate_metadata valid; flake8 clean; the smoke 45 classes, 259 +# patches, 0 failures on CPython and the two new binaries. The old +# reverb.ConvolutionReverb synthesizes stereo rooms too, so its renders +# move; no test holds them. Pending both boards, and the pump race on a +# board (audiodsp#166, open). +# +# 2026-09-29: v0.6.3rc1 -> v0.6.3rc3 (b386248), the next pre-release of the +# same patch, under the same ruling; TestPyPI only, as rc1. What moved +# beneath the components, by `git log v0.6.3rc1..v0.6.3rc3 -- src`: +# audiodsp#165, audioconvolve.Convolver keeps the block in flight across a +# re-synthesis (#163) and normalises each side of a synthesized stereo room +# on its own (#164); audiodsp#172, audioverb.Tank's tilt pole keeps tracking +# at tone_db 0 (#168) and set(delays=, taps=) re-cuts a playing node in place +# (#169); audiodsp#173, a cross-fed stereo audioecho.FeedbackDelay tail +# reaches exact zero (#170). Nothing on this branch pinned the old +# behaviour: the unit suite `Ran 1894 tests ... FAILED (failures=1, +# skipped=2)`, the one failure the provenance test refusing bin/micropython +# (an rc1 build; it passes pointed at bin/micropython-063rc3). The census +# (336 cells, three interpreters) moves exactly the 8 library +# ConvolutionReverb cells, from #164, to the digests audiodsp#165's own +# census printed; no delay class and no Reverb cell moves. The smoke moves +# ConvolutionReverb's 7 patch peaks and nothing else. Desktop interpreters +# built clean at the tag beside the live pair (bin/*-063rc3; +# audioecho.__revision__ v0.0.3-305-gb386248 on MicroPython, the provenance +# stamp on CircuitPython). validate_api 55 instruments and 45 effects; +# validate_metadata valid; flake8 clean; the smoke 45 classes, 259 patches, +# 0 failures on CPython and both rc3 binaries. Pending both boards. +# +# 2026-09-29: v0.6.3rc3 -> v0.6.3 (5afe010), the release, named by Brad +# the same day ("release audiodsp 0.6.3"); on TestPyPI and production +# PyPI. Nothing moved beneath the components: `git diff v0.6.3rc3 v0.6.3` +# is VERSION and CHANGELOG.md, no source file. Re-run here on Brad's +# workstation with both desktop interpreters rebuilt from clean build +# directories at the tag (audioecho.__revision__ v0.0.3-308-g5afe010 on +# MicroPython, the provenance stamp on CircuitPython; kept as bin/*-063) +# and the venv's audiodsp installed from PyPI: the unit suite `Ran 2280 +# tests ... OK (skipped=2)`, the lifecycle matrix for all eight Phase 5 +# classes included; validate_api, validate_metadata and flake8 clean. +# Pending both boards. +# # -- ref is what CI installs (git+https://...@) -4e5809195af58dd17f1705edcb9981e6fec3a312 4e5809195af58dd17f1705edcb9981e6fec3a312 +v0.6.3 5afe010b61b1b9e628534bc90895b005f7df6d6d diff --git a/CHANGELOG.md b/CHANGELOG.md index 82433e4..bd100b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,317 @@ here. The two packages version and release together, from this repository; Releases up to and including audiodsp's v0.1.1 shipped both packages from there, and are recorded in its changelog. +## Unreleased + +### Added + +- MultiTapDelay: the docstring says which patches need a P4-class board (patch 4 does, patch 3 may), from the boards' figures at audiodsp v0.6.3 +- **`DigitalDelay` (rebuilt, Phase 5; adopted 2026-09-28):** a clean + interpolated line with the Boss DD-2's control law on one + `audioecho.FeedbackDelay`, and what `audioeffects.DigitalDelay` and + `create()` now serve. Turning Time pitch-bends the repeats at a rate Glide + sets instead of clicking, every static Time is handed as a whole frame + (the node lands 18 of the 128 knob positions one float32 step off it at + 44.1 and at 22.05 kHz, none at 48 kHz), Mix 0 is a wire while the line + keeps recording, and Repeat Tone and Repeat Cut put the pedal's 7 kHz and + 40 Hz corners into the loop as knobs that default out. `tail_samples` is + finite with Repeat Cut out, and either filter back in after silence plays + nothing (at audiodsp v0.6.3rc1, whose node keeps an out filter's state + live). On the boards: at most + 7.0 % of a block on the P4 and about 14.6 % on the S3 (patch 5, + same-conditions reading), passed by Brad; patch 5's digest is identical + board to board and differs from the desktop through single-precision + Python floats in two derived settings (audiocomponents#75). The old + `delay.DigitalDelay` and its `set_time` / `set_mix` stay in `delay.py` + until the class comes home. It lives in + `lib/audioeffects/rebuilt/digitaldelay.py` until then. Its docstring + carries only sentences a test asserts (`CLAIMS`), with the family's two + disclosed limits: a control that jumps steps the output + (audiocomponents#117), and a tail waits for a source that stopped + (audiodsp#180); `tail_samples` is `None` with Repeat Cut in, as at + patch 5. +- **`SlapbackDelay` (rebuilt, Phase 5; adopted 2026-09-28):** the Sun + Studio tape slap on one `audioecho.FeedbackDelay`, and what + `audioeffects.SlapbackDelay` now serves: one mono repeat at 135 ms, landed + as a whole frame at every rate, with Saturation, Tone and Wow as the + tape's colours and Repeats defaulting to 0. Level 0 is a wire while the + line keeps recording, Time walks rather than clicks, and a host echoing + Time back keeps the constructor's exact frame. Tone out is byte-identical + to no filter whatever came before, and a Wow move glides over 20 ms (both + at audiodsp v0.6.3rc1). + On the boards: at most 7.4 % of a block on the P4; on the S3 14.6-14.7 %, + and 15.2 % at patch 5 (Tone in), which Brad passed against the 15 % bar; + every patch digest is identical board to board and differs from the + desktop through the Wow depth worked out in single precision + (audiocomponents#75). The docstring states the input ceiling (-2.5 dBFS + peak at the defaults, -3.4 over the shipped patches, on `noise_det`), the + 15 kHz swing the wow costs the repeat, the Times the node lands off the + frame at 44.1 and 22.05 kHz, and Tone's flat top + at 22.05 kHz. It lives in `lib/audioeffects/rebuilt/slapbackdelay.py` + until it comes home. +- **`TapeDelay` (rebuilt, Phase 5; adopted 2026-09-29):** a tape loop + on one `audioecho.FeedbackDelay` with two transports as characters: the + RE-201's motor (`"varispeed"`, the default), where a Time move bends the + pitch by the ratio of the two times for exactly the new time, and the + EP-3's sliding head (`"sliding-head"`), where the pitch bends only while + the head moves, at a rate Glide sets. Each repeat darkens by a playback + loss law whose corner follows Spacing and the tape speed; Wow and Flutter + drive one periodic wow table with a slow drift; Record Level is the + loop's cubic squash, without memory. Mix 0 is a wire while the loop keeps + recording. It is what `audioeffects.TapeDelay` and `create()` now serve, + from `lib/audioeffects/rebuilt/tapedelay.py` until it comes home; the old + `delay.TapeDelay` stays in `delay.py` until then. Every sentence of the + docstring that makes a claim is tied to a test (`CLAIMS` in its test + file); it says where the pitch bend stops, which Wow and Flutter moves + still step, and the family's two limits (a control that jumps steps the + output; the tail rings only while the source feeds). At audiodsp + v0.6.3rc1 its Feedback is handed as set (no stepping clear of the stall + windows; the bound counts one landing lap there), a Wow or Flutter move + that changes only the wobble's depth glides over 20 ms, and a knob turned + down to 0 keeps the last wow table while the depth ramps out; a move + that changes the balance of Wow and Flutter still steps, disclosed. At + v0.6.3rc3 Spread is handed as set: the node ends a cross-fed tail itself + (audiodsp#173), so the 1/4096 grid the class used is gone. On the boards + at audiodsp v0.6.3 every patch runs in real time at 13.4-14.1 % of a + block on the P4 and 10.0-12.8 % on the S3, and every digest equals the + desktop's or a single-precision desktop build's (audiocomponents#75). +- **`AnalogDelay` (rebuilt, Phase 5; adopted 2026-09-29):** a + bucket-brigade delay on one `audioecho.FeedbackDelay`, with the Boss + DM-2's one 4096-stage line (`"single-line"`, the default) and the Deluxe + Memory Man's two in series (`"double-line"`) as characters. The Time knob + is the line's clock: the repeats' high-frequency corner tracks it as + 0.2211 N / T, so they darken as Time grows, and a Time move bends their + pitch by T_old / T_new for exactly T_new instead of clicking. Every Time + lands on a whole frame at every rate, and Mix 0 is a wire while the line + keeps recording. Modulation is a triangle of fixed millisecond swing, and + Sync follows a host's beat by Division. There is no sample-and-hold, no + compander and no fixed ~3 kHz pair, so short Times are brighter than + either pedal. It is what `audioeffects.AnalogDelay` and `create()` now + serve, from `lib/audioeffects/rebuilt/analogdelay.py` until it comes + home. The docstring states the input ceiling (-4.4 dBFS peak at the + default Mix 0.4) and that turning Time through several positions takes + seconds to settle. At audiodsp v0.6.3rc1 the Feedback is handed as set + (no stepping clear of the stall windows; the bound counts one landing lap + there), and a Modulation move glides over 20 ms instead of stepping the + read offset. At v0.6.3rc3 Spread is handed as set: the node's stereo + cross-feed tail now ends on its own (audiodsp#170), so the 1/4096 grid + the class used to dodge it is gone. A jumping control steps the output + and a tail cut short by a stopped source carries on when it comes back, + disclosed as family limits (audiocomponents#117, audiodsp#180). On the + boards at audiodsp v0.6.3 every patch runs in real time at 7.0-7.5 % of + a block on the P4 and 14.0-14.5 % on the S3, and every digest equals the + desktop's or a single-precision desktop build's (audiocomponents#75). +- **`PingPongDelay` (rebuilt, Phase 5; adopted 2026-09-29):** repeats + that alternate between the speakers, on one `audioecho.FeedbackDelay` + whose cross-feed and input pan Spread moves between two plain delays and + the full bounce, by a stated law. The dry path is each channel's own + signal, the mono sum is an ordinary feedback delay exactly, and the class + hands a one-channel node the settings that keep its loop alive (the old + class silenced it). Every static Time lands on a whole frame at 48 kHz; at + 44.1 and 22.05 kHz 25 and 20 knob positions land one float32 step off, and + so can a constructor or Sync Time (5554 and 2785 of the whole frames from + 20 to 1000 ms), which needs a node option. Time walks rather than clicks, + Sync follows the host's beat, and Repeat Tone and Repeat Cut put a + low-pass and a high-pass in the loop, defaulting out. It lives in + `lib/audioeffects/rebuilt/pingpongdelay.py` until it comes home, and it + is what `audioeffects.PingPongDelay` and `create()` now serve. The + docstring states + the input ceiling (-3 dBFS peak at the defaults, -3.1 over the shipped + patches, on `noise_det`; -7.96 dBFS on any material with Repeat Cut in at + the default Mix), and that at full Spread the loop hears (L + R) / 2. + On the boards at audiodsp v0.6.3 every patch runs in real time at + 11.9-12.2 % of a block on the P4 and 16.4-16.6 % on the S3, and every + digest equals the desktop's. At audiodsp v0.6.3rc1 both loop filters' out + stops hand exactly 0 and a filter taken out is out whatever came before + (no tracking Tone stop, no Cut held in at 20 Hz, so `tail_samples` is + finite once Cut is out again), and the Feedback is handed as set (no + stepping clear of the stall windows; the bound counts one landing lap + there). +- **`ConvolutionReverb` (rebuilt, Phase 5; adopted 2026-09-29):** a + short room on one `audioconvolve.Convolver`, synthesized from Decay, + Damping, Predelay, Diffusion and one of 64 Room seeds, or loaded from + your own impulse (int16 frames or a 16-bit WAV at the graph's rate) with + only Mix live. The allocation is `seconds`, 0.08 s by default, from a + 0.06 s floor to the node's 512-partition ceiling, and anything outside + raises naming the class, the taps, the rate and the limit in seconds. + `latency_samples` reads the node: 256 frames whenever an impulse is + loaded, 0 when none is, and held at Mix 0 the output is the source + delayed by exactly that, byte for byte, while the source keeps feeding + it and nothing resets it. Measured mode loads at unit mean energy and + refuses the synthesis macros with `IndexError`; no impulse ships. It + lives in `lib/audioeffects/rebuilt/convolutionreverb.py` until it comes + home, and it is what `audioeffects.ConvolutionReverb` and `create()` now + serve. It stands on + audiodsp v0.6.3rc3: a room-knob move never drops or repeats a dry frame + at any Mix, from the end of the block in flight the output is that of an + instance that always had the new settings, and each side of a stereo + room is unit energy on its own, so the room sits in the middle. + `reset()` mid-stream silences the block in flight, dry included, and a + Mix move lands at the end of it. The docstring is the player's text, + every claim in it tied to a test. On the boards at audiodsp v0.6.3 every + patch runs in real time at 41.6-42.2 % of a block on the P4 and + 71.2-73.1 % on the S3, and every digest equals the desktop's or a + single-precision desktop build's (audiocomponents#75). +- **`MultiTapDelay` (rebuilt, Phase 5; adopted 2026-09-29):** the + RE-201's multi-head modes, with the Echorec as a second reference. Heads + sit on a grid of whole multiples of one base time, exactly, at every + rate; Pattern is the RE-202's twelve head sets over that grid (mode 12 + the plain grid, not Roland's unpublished one); the laps go round an + `audioecho.FeedbackDelay` whose loop low-pass darkens once per lap, and + Repeat Tone's out stop swaps to a lighter graph (patch 1, the one to + stack on an S3). Mix 0 is a wire. It lives in + `lib/audioeffects/rebuilt/multitapdelay.py` until it comes home, and it + is what `audioeffects.MultiTapDelay` and `create()` now serve. The tap + node reads + its input one block behind the dry, so settings made before the first + pull or after `reset()` land on the grid in both lanes. Any number of + resets and returns from Mix 0, before the first pull or between two + pulls, leave the dry at +0 against the source and every head at +k n1, + and take no frame from the source or play one twice, whatever size of + buffer it hands out (a bare `RawSample` included): the source is read + through the input adapter at every Mix, Mix 0 included, and a reset + leaves the adapter's unread frames where they are. Any number of Repeat + Tone crossings between pulls leave the wet where it was; the output ends + in a width-1 MidSide, so a host resetting the output leaves the lines as + they are. On a build without `audiocore.get_buffer` (a patched + CircuitPython board may leave it out), a class built at Mix 0 and reset + before it was ever turned up plays its source 256 frames late; a class + built above Mix 0 and taken to Mix 0 before the first pull plays the + Mix-0 run 256 frames early without the source's first block, then plays + that block, with its heads, when Mix comes back up or `reset()` is + called; and a reset or a return from Mix 0 lets the block the tap node + had not read into the lines, its heads late by any Mix-0 run between. The + constructor refuses a sample rate below 12 825 Hz with a `ValueError`, + where the one-block lag could not place the 20 ms head. At audiodsp + v0.6.3rc1 the Feedback is handed to the lap node as set (no stepping + clear of the stall windows; the bound counts one landing lap there). The + docstring states the input ceiling, the click and channel crossing of a + Time or Heads move while audio plays, and that on CircuitPython alone + the stereo dry's right lane is one LSB hot within 32 LSB of the rails. + On the boards at audiodsp v0.6.3 every patch runs in real time on the P4 + (36.0-72.5 % of a block); on the S3 patches 0-3, 5 and 6 do (57.8-78.1 %) + and patch 4 does not (5.567 ms, 104.4 %), so the docstring says patch 4 + needs a P4-class board and patch 3 may. Every digest equals the + desktop's or a single-precision desktop build's (audiocomponents#75). +- **`Reverb` (rebuilt, Phase 5; adopted 2026-09-29):** Dattorro's plate + network on one `audioverb.Tank`, cut four ways as a Character macro: an + EMT 140 plate that is dense at once and darkens as Decay shortens, and a + room, a chamber and a hall that build. Decay is T60 at 500 Hz within 12 % + at the reference patches from 2 s to 10 s (the hall from 4 s), held on + eight seed sets; shorter Decays are not claimed. At audiodsp v0.6.3rc3 a + Character or Size move re-cuts the one Tank in place and cuts the tail + without losing a frame of the dry, `reset()` keeps the dry the Tank holds, + and Tone's centre is handed as 0 dB. Mix 0 is a byte-exact wire while the + tank keeps ringing; latency is zero. It lives in + `lib/audioeffects/rebuilt/reverb.py` until it comes home, and it is what + `audioeffects.Reverb` and `create()` now serve; the old `reverb.Reverb`, + with its `preset`, stays in `reverb.py` until then. The Fender 6G15 + spring character is parked until the node carries a dispersive chain. + On the boards at audiodsp v0.6.3 every patch runs in real time at + 19.0-23.4 % of a block on the P4 and 33.7-36.3 % on the S3; every digest + equals a single-precision desktop build's (audiocomponents#75) but patch + 7's, which differs from it by nine one-LSB samples of 65536 + (audiodsp#183). At audiodsp v0.6.3rc3 the class plays what it + played at rc1; its docstring is now the player's text, each claim tied + to a test, with a jumping control disclosed as a family limit + (audiocomponents#117), and a return from Mix 0 said plainly to start + both lines empty. + +### Changed + +- **`ShimmerHall` and `AirSpace` keep the classes they were ported + against.** With the Phase 5 `TapeDelay` and `Reverb` adopted, the two + racks name the old `delay.TapeDelay` and `reverb.Reverb` directly (a + `Rack` chain entry may now be a class in place of a `NAME`), so they + build and sound as before; moving them onto the rebuilds would re-voice + them. A chain that names `"Reverb"` gets the rebuild, which takes + `character=` where the old class took `preset=`. +- **The audiodsp pin moves to v0.6.3rc3.** The release carries the + convolution node's two fixes (audiodsp#165: the block in flight kept + across a re-synthesis, each side of a stereo room normalised on its own), + the reverb tank's two (audiodsp#172: the tilt keeps tracking at Tone 0, + `set(delays=, taps=)` re-cuts a playing node) and `FeedbackDelay`'s + stereo cross-feed stall (audiodsp#173). `ConvolutionReverb`'s default + and its seven patches render differently (the stereo room no longer + leans); no other class's render moved (the census, three interpreters). +- **The audiodsp pin moves to v0.6.3rc2.** The release adds audiodsp#165 to + rc1: `audioconvolve.Convolver` keeps the audio in flight when a room is + re-synthesized (#163: nothing drops, the tail rings on, and the block + on its way out crossfades to the new room) and normalises each + side of a stereo room on its own (#164: the room no longer leans, and + the pair's level holds). Mono rooms and measured impulses render the + bytes they did. `ConvolutionReverb`, rebuilt and old, synthesizes + stereo rooms, so its stereo renders move; nothing else here uses the + node. +- **The audiodsp pin moves to v0.6.3rc1, and three workarounds come out.** + The release carries audiodsp#161: `audioecho.FeedbackDelay` keeps an out + loop filter's state live (#158, #159), lands a stalled damping state so a + tail reaches zero with the low-pass in (#157), and ramps a new wow depth + over 20 ms (#160). `DigitalDelay`, `SlapbackDelay` and `CombFilter` now + hand exactly 0 at a filter's out stop (no more 32 x rate tracking stop, + which was 1 LSB off the filter out in places) and hand the Feedback you + set (no more stepping clear of the stall windows, up to 3 x 10^-5 away); + `tail_samples` counts one landing lap at a window centre and stays finite. + `DigitalDelay`'s Repeat Cut back in after silence is silent (it played + 20 858 LSB, disclosed), and `SlapbackDelay`'s Wow no longer steps (7 684 + LSB at a 36 -> 73 move). Both docstrings now say which static Times the + node lands one float32 step off the whole frame at 44.1 and 22.05 kHz, as + `PingPongDelay`'s does. No shipped patch's render moved (the census, + three interpreters). +- **`Flanger` (parked under `rebuilt/`): F8 restated, the sound unchanged.** + At Color max (0.99, 3 ms, Filter Matrix on) the ring to -60 dB is about + 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 / 2.13 s + at audiodsp v0.6.2), not "2 s on a 200-440 Hz burst": since v0.6.2 the + node no longer holds a few LSB going round for ever, and part of the old + ring was that. The docstring and catalogue row say the measured values, + and the two F8 tests pin them within 0.03 s. +- **`CombFilter`: `tail_samples` is finite at every setting.** It was + declared `None`. The bound is the lap law on the longest line the read + head may be at, plus the Tone low-pass's memory, plus 1.5 s while the + Trim is in circuit (its fixed-point shelf was measured at up to 0.834 s). + With Tone in, Feedback is handed under 0.00003 clear of the node's stall + windows (audiodsp#157), where it held 1 or 2 LSB for ever at Feedback 0.5 + and 0.75; with Tone off nothing moves, and no shipped patch sits in a + window. +- **`CombFilter`: Tone back in after silence no longer plays a stale + ring.** Tone off after Tone had been in froze the node's loop low-pass, + and bringing Tone back after the ring had died played what it held: + 15 070 LSB at 48 kHz (10 110 at 44.1, 8 828 at 22.05). Once Tone has been + in since a reset, the off stop now keeps the low-pass tracking the line at + a coefficient of exactly 1, as `DigitalDelay` and `SlapbackDelay` do; a + fresh or reset instance still hands no filter. Against the filter truly + out that is exact on a whole-frame tuning and within 1 LSB otherwise, and + a few LSB (7 at most measured) at a Feedback inside a stall window, where + the off stop keeps Tone in's stepped Feedback. + +### Fixed + +- **Ten effects no longer replay old audio when a control brings a bypassed + part of their graph back** (#113). Mix 0 hands the source straight back, + so nothing behind it is pulled, and the graph kept what it held - its + filters' memory and the block each mixer voice had queued; Mix back up + after a pause played it out of silence. Measured peaks, LSB: Distortion + 24 589, Fuzz 23 153, MultibandCompressor 22 264, Bitcrusher 16 896, + Overdrive 11 640, Exciter 10 768, Saturation 7 927, DynamicEQ 7 769, and + Compressor's construction block (20 000). One helper in + `_component.Component` (`_route_around`, `_rejoin`, `_clear_nodes`) marks + a graph routed around and clears it when it comes back, and each class + then arms it the way its constructor does: level gates, voices, a biased + coupling pole charged. Limiter's Lookahead and True Peak (31 373: the + whole last note) were the node's lookahead line, written only as far as + its delay reaches; a stage whose delay grows is now reset first. Bitcrusher's + Band Limit and Dither clear their sections and gate when they come back. + Saturation drains its plate pole when Bias returns to the centre (29 058), + re-charges it on Drive, Output, Headroom and Hysteresis moves off centre, + starts Hysteresis's play operator at the centre when it comes back in, and + no longer pulls a block off its dry tap on a Bias re-charge - which put the + dry leg 256 frames ahead of the wet after every `program_change` and moves + the one census digest that changed, patch 7 (Mix 44). DynamicEQ's mixer + renders one block at a time in mono as well as stereo. After: 0 LSB out of + silence at every rate, channel count and patch on CPython, MicroPython and + CircuitPython (at most 1 LSB at Saturation's off-centre patches, what its + constructor's charge leaves); nothing else moved with no control moved. + ## v0.3.2 (2026-09-25) No change to either package's code: both packages are 0.3.1 under a new diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index 722ebe5..b12f16c 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -24,7 +24,7 @@ Every class takes its audio source as the first argument - a synthesizer, an `.output` - and exposes its chain tail as `.output`. The underlying nodes are kept as attributes (`.node`, `.mixer`, `.cutoff`, ...) so applications can bind parameters straight to them; the classes with a natural swept control -also expose `set_*` helpers (`DigitalDelay.set_time`, ...). A class that +also expose `set_*` helpers (`TapeDelay.set_time`, ...). A class that has been rebuilt on the component contract drops those helpers for its macro surface: `LadderFilter` is the first, and its cutoff is macro 0. @@ -128,30 +128,44 @@ marginal share of one block on each board at construction defaults. | `BandPass` | audiodsp (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. The peak holds within 0.05 dB in 55 of 56 measured cells, both knob stops included; the one miss is -0.09 dB at f0 31.5 Hz with Q 32 (audiodsp#64 fixed the rest). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB | | `Notch` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator | | `LadderFilter` | audiodsp (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit | -| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. Above Feedback 0.5 the parked ring's period is the nearest whole number of samples to F_s/Frequency, not the fractional delay the comb was asked for: +17.4 cents at 1760 Hz / Feedback 0.8 (27 samples at 48 kHz) and at most a half-sample — about 70 cents — near 4 kHz. The first-repeat tap still lands within 0.01 cents. Below Feedback 0.5, and at half-sample tunings, the tail reaches exact zero. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | -### Time and space - `reverb.py`, `delay.py` -| Class | Notes | -|---|---| -| `Reverb` | presets `room` `chamber` `hall` `plate` `spring` (spring adds pre-flutter) | -| `ConvolutionReverb` | a real impulse response, measured or synthesized; **patches** | -| `DigitalDelay` `SlapbackDelay` | clean repeats | -| `TapeDelay` | in-loop low-pass, soft-clip and per-sample wow; **patches** | -| `AnalogDelay` | BBD: band-limited both ends, `age` over the lot; **patches** | -| `PingPongDelay` | true cross-feed - repeats alternate sides; **patches** | -| `MultiTapDelay` | `(position, level)` tap patterns | - -The delays split two ways. `DigitalDelay`, `SlapbackDelay` and -`MultiTapDelay` are clean and run on `audiodelays`, whose feedback path is -the echo times a decay. The other three are named after something that -happens *inside* that path - a filter taking a little more off each pass, a -soft-clip rounding it, a cross-feed sending it to the other speaker - so they -run on `audioecho.FeedbackDelay`, which is where audiodsp puts those. A -coloured delay's `max_time_ms` sizes its line and cannot change afterwards; -at 48 kHz a second of stereo line is 192 KB, so ask for what will be used. - -The two reverbs are not the same kind of thing. `Reverb` is `audiofreeverb`: -a fixed network of delay lines that costs the same on a Cortex-M0 as on a -workstation, and that sounds like a plausible room. `ConvolutionReverb` is +| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. Feedback is handed as set; at a stall-window centre (the 0.95 stop is one) the bound counts one landing lap, since audiodsp v0.6.3rc1 lands a stalled loop low-pass (#157). Tone off is exactly 0 whatever came before, and Tone back in from silence plays nothing (the v0.6.2 node froze the low-pass and played 15 070 LSB at 48 kHz). The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | +### Time and space - all eight adopted, served from `rebuilt/` + +Phase 5's eight classes are adopted: `DigitalDelay` and `SlapbackDelay` on +2026-09-28, the other six on 2026-09-29. `audioeffects.create()` serves the +rebuilds, from `lib/audioeffects/rebuilt/`, until they come home. All eight +are **audiodsp** tier, and none runs on a stock CircuitPython board. Cost is +the class's own share of one 256-frame stereo block at 48 kHz, measured on +both boards at every shipped patch. For the first two the S3 figures are the +same-conditions reading (the class measured beside the palette row it was +priced from); the other six were measured at audiodsp v0.6.3, where every +cell met the bar (at most 80 % of a block) and ran in real time on the P4, +and on the S3 every cell did but `MultiTapDelay`'s patch 4. Their digests +equal the desktop's, or a desktop MicroPython's built with single-precision +floats (a setting worked out in Python float, audiocomponents#75), except +`Reverb`'s patch 7 (below). + +| Class | Tier | Macros | Latency | Cost | Standout | +|---|---|---|---|---|---| +| `DigitalDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 at most 7.0 %** (0.372 ms, patch 5, rt 5.49) **/ S3 about 14.6 %** at patch 5 (about 0.78 ms corrected; 11.8 % as the tool reads it; rt 3.11), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed the S3 figure on 2026-09-28. No lean patch. RAM 155 584 B at the default 800 ms line (48 kHz); `max_time_ms` spends less | Boss DD-2 (1983), light touch: its control law and dry/wet discipline, Mix 0 a wire, Time bends the repeats in pitch at a rate Glide sets instead of clicking, every static Time on a whole frame. Repeat Tone and Repeat Cut put the pedal's 7 kHz and 40 Hz corners in the loop as knobs that default out; patch 5 is the pedal's own corners. `tail_samples` is finite with Repeat Cut out; Feedback is handed as set, and with Repeat Tone in the bound counts one landing lap at a stall-window centre (audiodsp v0.6.3rc1, #157). Both out stops are exactly 0, and either filter back in after silence plays nothing (at v0.6.2 Tone's played 26 443 LSB and Cut's 20 858). The node lands 18 of the 128 Time positions one float32 step off the whole frame at 44.1 kHz and 18 at 22.05 kHz (patch 4 at 44.1 kHz: a click's first repeat reads 19 922 and 78), none at 48 kHz. **Digests:** six of seven patches identical on the P4, the S3 and the desktop; patch 5 identical board to board, and different from the desktop through single-precision Python floats in two derived settings (audiocomponents#75): `cut_hz` 0.035 % high (39.44766 Hz against 39.43366) and `damping_hz` one float32 step. **What the default surrenders:** no band limit and no compander, no HOLD, and Glide 0's instant knob clicks. | +| `SlapbackDelay` | audiodsp (`audioecho`) | 6 | 0 samples | **P4 at most 7.4 %** (0.396 ms corrected; 6.4 % as the tool reads it; rt 5.82) **/ S3 14.6-14.7 %** at the default and patches 0-4 and **15.2 % at patch 5** (0.813 ms corrected, the one patch with Tone in; 11.6 % as the tool reads it; rt 3.08), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed patch 5 on 2026-09-28 ("It passes"). No lean patch. RAM about 50 KB (a fixed 251 ms line) | Sam Phillips' two-Ampex-350 slap at Sun Studio: one mono repeat at 135 ms, handed as a whole frame at every rate, with Saturation, Tone and Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire; Time walks rather than clicks. Tone out is byte-identical to no filter whatever came before, and a Wow move glides over 20 ms (both since audiodsp v0.6.3rc1). The node lands 21 of the 128 Time positions one float32 step off the whole frame at 44.1 kHz and 20 at 22.05 kHz, none at 48 kHz and no shipped patch. **Digests:** all seven patches identical on the P4 and the S3, and different from the desktop through single-precision Python floats in a derived setting (audiocomponents#75): `wow_depth_ms` 2.1 x 10^-5 to 6.8 x 10^-5 ms high, under 0.0001 cent (patch 4's Feedback also one float32 step). **What the default surrenders:** Tone out, so the repeat is as bright as the dry, and Wow at 1 cent makes a 15 kHz repeat breathe down to -5.11 dB at 48 kHz. | +| `TapeDelay` | audiodsp (`audioecho`) | 11 | 0 samples | **P4 13.4-14.1 %** (0.714-0.751 ms, rt 3.85-3.95) **/ S3 10.0-12.8 %** (0.531-0.683 ms, rt 2.51-2.70) at every shipped patch, audiodsp v0.6.3; the lean patch 8 is the cheapest on both. RAM 250 064 B | The RE-201's motor (`"varispeed"`, the default: a Time move bends the pitch for exactly the new time) and the EP-3's sliding head (`"sliding-head"`: the pitch bends only while the head moves, at a rate Glide sets) as characters. Each repeat darkens by a playback loss law that follows Spacing and the tape speed; Wow and Flutter drive one wow table; Record Level is the loop's squash. Mix 0 is a wire. | +| `AnalogDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 7.0-7.5 %** (0.374-0.399 ms, rt 5.19-5.31) **/ S3 14.0-14.5 %** (0.746-0.774 ms, rt 3.06-3.12), audiodsp v0.6.3. RAM 118 896 B | A bucket brigade: the Boss DM-2's one 4096-stage line (`"single-line"`) and the Deluxe Memory Man's two in series (`"double-line"`). Time is the line's clock, so the repeats darken as Time grows and a Time move bends their pitch instead of clicking; every Time lands on a whole frame. No sample-and-hold, no compander, so short Times are brighter than either pedal. | +| `PingPongDelay` | audiodsp (`audioecho`) | 9 | 0 samples | **P4 11.9-12.2 %** (0.636-0.649 ms, rt 4.20-4.22) **/ S3 16.4-16.6 %** (0.872-0.887 ms, rt 2.87-2.90), audiodsp v0.6.3; every digest equals the desktop's. RAM 194 112 B | Repeats that alternate between the speakers, on one node whose cross-feed and input pan Spread moves from two plain delays to the full bounce. The dry is each channel's own signal and the mono sum is an ordinary feedback delay exactly; Repeat Tone and Repeat Cut put a low-pass and a high-pass in the loop, defaulting out. | +| `MultiTapDelay` | audiodsp (`audioecho`, `audiodelays`) | 9 | 0 samples | **P4 36.0-72.5 %** (1.922-3.865 ms, rt 1.19-2.09) **/ S3 57.8-78.1 %** at patches 0-3, 5 and 6 (rt 1.01-1.34), audiodsp v0.6.3. **Patch 4 misses on the S3**: 5.567 ms, 104.4 % of a block, rt 0.80, so it needs a P4-class board, and patch 3 may; the docstring says so. RAM 661 376 B | The RE-201's multi-head modes, with the Echorec as a second reference: heads on a grid of whole multiples of one base time, Pattern the RE-202's twelve head sets, laps round a loop low-pass that darkens once per lap. Repeat Tone's out stop swaps to a lighter graph (patch 1, the one to stack on an S3). Mix 0 is a wire. | +| `Reverb` | audiodsp (`audioverb`) | 13 | 0 samples | **P4 19.0-23.4 %** (1.012-1.249 ms, rt 2.84-3.23) **/ S3 33.7-36.3 %** (1.799-1.935 ms, rt 1.84-1.93), audiodsp v0.6.3. RAM 49 120-149 904 B by patch | Dattorro's plate network on one `audioverb.Tank`, cut four ways by Character: an EMT 140 plate, dense at once, and a room, a chamber and a hall that build. Decay is T60 at 500 Hz within 12 % from 2 s to 10 s at the reference patches; Mix 0 is a byte-exact wire while the tank rings. The spring character is parked. **Digests:** patch 7's differs from the single-precision desktop build by nine one-LSB samples of 65536 (audiodsp#183). | +| `ConvolutionReverb` | audiodsp (`audioconvolve`) | 6 | 256 frames with an impulse loaded, 0 without | **P4 41.6-42.2 %** (2.217-2.249 ms, rt 1.85-1.88) **/ S3 71.2-73.1 %** (3.796-3.900 ms, rt 1.07-1.09) at the default 0.08 s, audiodsp v0.6.3. RAM 142 544 B | A short room synthesized from Decay, Damping, Predelay, Diffusion and one of 64 Room seeds, or your own impulse (int16 frames or a 16-bit WAV) with only Mix live. `seconds` is the allocation, from 0.06 s to the node's 512-partition ceiling. | + +The old classes (`Reverb`'s `preset`, `TapeDelay`'s `wow`, `AnalogDelay`'s +`age`, `MultiTapDelay`'s `(position, level)` taps) stay in `delay.py` and +`reverb.py` until the rebuilds come home, and `ShimmerHall` and `AirSpace` +still build on them (see [Effect racks](#effect-racks---rackpy)). A delay's `max_time_ms` sizes its line and +cannot change afterwards; at 48 kHz a second of stereo line is 192 KB, so +ask for what will be used. + +The two reverbs are not the same kind of thing. `Reverb` is `audioverb`: a +fixed network of delay lines whose cost does not depend on how long the room +rings, and that sounds like a plausible room. `ConvolutionReverb` is `audioconvolve`: it applies an actual impulse response, so it sounds like a *particular* room - and one second of stereo impulse is about 1.5 MB and ~150 MFLOPS, which is a desktop or an offline render. Reach for `Reverb` @@ -181,7 +195,7 @@ where the board stage has run, else the dossier palette budget. A | Class | Tier | Macros | Latency | Cost | Standout | |---|---|---|---|---|---| | `Chorus` | audiodsp (`audioecho`) | 5 | 0 samples; wet Delay 3–20 ms is the effect, not lookahead | **8.1 % P4 / 9.8 % S3** at patch 0 (ROW marg 0.431 / 0.524 ms), inside palette **P4 ≤ 9 % / S3 ≤ 15 %**; no lean patch | Electro-Harmonix Small Clone - one BBD voice, clock-law triangle, Mix 0 a wire. **What the default surrenders:** the within-half pitch-offset ratio is 1.98, not (d_max/d_min)² = 3.60 (T2). Tone 12 kHz is 2.4 dB down at 10 kHz, not ≥10 dB (T3). The constructor Tone 3 kHz still meets T3 | -| `Flanger` | audiodsp (`audioecho`; `audioroute` only when Through Zero is on) | 12 | 0 samples at defaults; Through Zero off by default, **10 ms / 480 samples at Range max / 48 kHz** when on | **0.672 ms/block P4 (rt 7.94) / 1.081 ms S3 (rt 4.93)** at patch 0, inside palette **9 % / 15 %** (FeedbackDelay +options; no extra looping RawSample); no lean patch | Electro-Harmonix Electric Mistress - BBD swept comb, Filter Matrix, Mix 0–2. **What the default gives up:** Color 0.55 / Matrix off is about −22 dB and 0.1 s, not the Color-0 −37 dB null or the 2 s Color-max ring; Color 0→0.9 holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) is the 2 s ring on a 200–440 Hz burst; the default is not, and a click is not that bar. | +| `Flanger` | audiodsp (`audioecho`; `audioroute` only when Through Zero is on) | 12 | 0 samples at defaults; Through Zero off by default, **10 ms / 480 samples at Range max / 48 kHz** when on | **0.672 ms/block P4 (rt 7.94) / 1.081 ms S3 (rt 4.93)** at patch 0, inside palette **9 % / 15 %** (FeedbackDelay +options; no extra looping RawSample); no lean patch | Electro-Harmonix Electric Mistress - BBD swept comb, Filter Matrix, Mix 0–2. **What the default gives up:** Color 0.55 / Matrix off is about −22 dB and 0.1 s, not the Color-0 −37 dB null or the Color-max ring; Color 0→0.9 holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 / 2.13 s measured at audiodsp v0.6.2; restated 2026-09-28 from "the 2 s ring", which the node's old rounding floor partly held up); the default does not, and a click is not that bar. | | `Phaser` | audiodsp (`audiobiquad`, `audioshaper`) | 10 | 0 samples at every setting | Palette **15 % P4 / 24 % S3** (AllPass-6 0.248 / 0.423 + Waveshaper ×1 0.223 / 0.410 + extra synthio 0.317 / 0.408 + glue 0.0). The old **9 % / 16 %** bar omitted the LFO: the palette could not price a `synthio` source. Quoted ROW **8.5 % / 15.6 %**. Patch 8 `Phaser - lean` (Drive 0) is AllPass alone → **5/8 %**. | MXR Phase 90 — four first-order all-pass stages, JFET as the variable resistor. **What the default surrenders:** Drive 0.3 is on at construction. Feedback's inter-notch peak then misses the 5 dB bar (600–1800 Hz rise −0.633 dB at 48 kHz) and the notch floor is not monotone (0.5→0.7 −1.204 dB). Both hold at Drive 0. The floor's 0.5→0.7 step also deepens at 22.05 kHz with Drive 0 (−1.286 dB). | | `Tremolo` | audiodsp (`audiomath`) | 9 | 0 samples; Lag is table shape, not a delay | Palette **7 % P4 / 9 % S3** (Multiply 0.028 / 0.047 + extra synthio 0.317 / 0.408 + glue 0.0). 1024-point board ROW **2.4 % / 4.9 %** (P4 marg 0.128 / ctrl 0.346, rt 11.23; S3 marg 0.265 / ctrl 0.532, rt 6.69). Prior 256-point ROW **2.3 / 4.9** is a shorter waveform. No lean patch | Fender Princeton 6G2 bias-vary and AB763 optical. **What the default surrenders:** Default is bias, Depth 0.5, Rate 5 Hz — not the optical standout and not L1's Depth 1. Wet peak at the default is −1.341 dB vs a 16000-LSB tone, not the ~6 dB `synthio` `>>16` ceiling; Depth 0 is still a wire. At 22.05 kHz the default's L1 bar is measured at the constructor, not assumed. Optical L1/L2 are disconfirmed; O1–O4 unmeasured at the constructor. L1 holds on sine at every rate, and misses by 0.16 dB on SQUARE material at 44.1 kHz only (−79.844 against −80; 48 kHz holds at −82.01). | | `Vibrato` | audiodsp (`audioecho`) | 8 | **mean Delay**; default **192 samples / 4.0 ms at 48 kHz** (wet alone); constructor and `program_change(0)` stay on that whole-sample bin | Palette **9 % P4 / 15 % S3** (`FeedbackDelay` +options, Mixer optioned off at Level 0 dB). Prior Mixer-in-path ROW **9.5 / 15.3 %** is stale. Board ROW of the Delay-only graph **unmeasured**. No lean patch | BBD clock-law vibrato (VB-2). Level 0 dB leaves the Mixer out of the pull. **What the default surrenders:** T1's any-2 kHz-window no-dry clause (Tone −3 dB at 17 kHz). At 22.05 kHz a 2 kHz window can tilt past 1 dB. The 17 kHz −3 dB band is at 48 kHz with a whole-sample Delay. T4 and T7 magnitude are Published defaults only. T7 quadrature is disconfirmed | @@ -248,7 +262,7 @@ micropython-vst3's soundtrack builds its custom racks: rack = audioeffects.create("Rack", source, 48000, chain=( ("Compressor", {"threshold_db": -24.0, "ratio": 3.0}), ("TapeDelay", {"time_ms": 340.0, "mix": 0.25}), - ("Reverb", {"preset": "hall", "mix": 0.3}), + ("Reverb", {"character": "hall", "mix": 0.3}), )) ``` @@ -261,7 +275,10 @@ unbounded), and its `reset()` clears every child's DSP history without reapplying the children's own patches over the options the rack built them with. `ShimmerHall` and `AirSpace` are the two racks the vst3 soundtrack shares between pieces, ported whole: fixed topologies whose -macros move their children's controls. +macros move their children's controls. They name the classes they were +ported against, `delay.TapeDelay` and `reverb.Reverb`, directly: a chain +entry may be a class in place of a `NAME`. Moving them onto the rebuilds +adopted on 2026-09-29 would re-voice them, and is not part of the adoption. ## Deliberately absent diff --git a/lib/audioeffects/_component.py b/lib/audioeffects/_component.py index 5ecfbc1..b227718 100644 --- a/lib/audioeffects/_component.py +++ b/lib/audioeffects/_component.py @@ -661,6 +661,9 @@ def __init__(self, source, *options, **keywords): self._macros = [] self._patch_index = 0 self._deinited = False + #: Whether a control has routed the output around the graph since the + #: graph last played. See `_route_around` and `_rejoin`. + self._stranded = False #: True only while `_build` is running. #: #: `_init_macros` applies a constructor `patch=` from inside @@ -740,6 +743,67 @@ def _own(self, node, reset=True, deinit=True): self._deinits.append(deinit) return node + def _clear_nodes(self, keep=(), only=None): + """Clear the nodes this class registered, tail first, the way + `reset()` does, and leave the macros and the patch alone. + + Each node is cleared by what it was registered with: `reset=True` is + `audiocore.reset_buffer`, a callable is called, `False` is skipped. + `keep` names nodes to leave untouched (a node the bypass itself + reads, which is still live). `only`, when given, limits the walk to + those nodes - a sub-branch that a control takes out and puts back. + """ + for position in range(len(self._nodes) - 1, -1, -1): + node = self._nodes[position] + if only is not None and not _named(node, only): + continue + if _named(node, keep): + continue + clear = self._resets[position] + if clear is False: + continue + if clear is True: + audiocore.reset_buffer(node) + else: + clear() + + def _route_around(self, bypass): + """Point the output at `bypass` - the borrowed source, or the node + a bypass hands back - and remember that the graph behind it has + stopped being pulled. + + The stale-block defect (audiocomponents#113): a graph nobody pulls + keeps whatever it held when the control moved - its filters' and + shapers' memory, a lookahead line, the block a mixer voice had + queued. Bring the control back after a pause and that plays out of + silence: a 5 ms fragment of whatever was sounding when the knob went + down. `_rejoin` is the other half. During `_build` nothing has + played yet, so nothing is stranded. + """ + if not self._constructing: + self._stranded = True + self._output = bypass + + def _rejoin(self, keep=()): + """The graph `_route_around` left is about to be pulled again. + + If it was routed around since it last played, every node it + registered is cleared (`_clear_nodes`, `keep` passed through) and + this returns True: the caller then arms it the way its constructor + does - the level gates, the voices, a coupling capacitor charged on + the bias - so the graph comes back as it was built rather than as it + was left. Clearing is the state silence would have decayed to for + every node whose silence answer is zero; a node that answers silence + with a constant (a biased shaper's coupling pole) is exactly what + the constructor's charge settles, which is why re-arming is the + caller's half. Returns False, and touches nothing, otherwise. + """ + if not self._stranded: + return False + self._stranded = False + self._clear_nodes(keep) + return True + def _pcm(self, buffer_size=2048): """The keyword bundle an audiodsp node wants, at this instance's format. There is no module state behind it.""" @@ -940,14 +1004,10 @@ def reset(self): """Clear every node this class built, tail first, and restore patch 0. The borrowed source is never named here.""" self._check_live() - for position in range(len(self._nodes) - 1, -1, -1): - clear = self._resets[position] - if clear is False: - continue - if clear is True: - audiocore.reset_buffer(self._nodes[position]) - else: - clear() + self._clear_nodes() + # Everything is clear now, so nothing is stranded: patch 0 coming + # back off a bypass is the reset's own re-arm, not a second clear. + self._stranded = False self.program_change(0) def deinit(self): @@ -979,6 +1039,16 @@ def deinit(self): self._deinited = True +def _named(node, nodes): + """Whether `node` is one of `nodes`, by identity. A node may define + `__eq__`, and a walk over the graph means this object, not an equal + one.""" + for other in nodes: + if other is node: + return True + return False + + # -------------------------------------------------------------------------- # MIDI-native argument checks, shared by the handlers above. diff --git a/lib/audioeffects/_core.py b/lib/audioeffects/_core.py index 401e85d..a171a8b 100644 --- a/lib/audioeffects/_core.py +++ b/lib/audioeffects/_core.py @@ -10,7 +10,7 @@ comp = audioeffects.create("Compressor", source, 48000, threshold_db=-20, ratio=3) verb = audioeffects.create("Reverb", comp.output, 48000, - preset="hall", mix=0.3) + character="hall", mix=0.3) audio_out.play(verb.output) The underlying audiodsp nodes are kept as attributes so applications can bind diff --git a/lib/audioeffects/bitcrusher.py b/lib/audioeffects/bitcrusher.py index 863c3f3..5d39658 100644 --- a/lib/audioeffects/bitcrusher.py +++ b/lib/audioeffects/bitcrusher.py @@ -15,6 +15,14 @@ are the measurements' controls, not the machine's. Mix 0 is the Tier 1 wire. +**What a switch leaves behind is cleared when it comes back** +(audiocomponents#113). Mix 0 leaves the whole crusher un-pulled, Band +Limit off leaves its low-pass, Dither off its gate; each used to keep what +it held and play it when the switch came back - the hold's last sample and +queued blocks (up to 16 896 LSB out of silence), the low-pass's memory +(16 384), the gate open on the dither (32). Each is now cleared first, and +Mix re-arms the voices the way the constructor does. + **Bits stops at 12, and that is the converter, not a shortcut.** A `Waveshaper` reads its curve by linear interpolation, so a table whose spacing in input codes reaches the step it is meant to describe hands the @@ -1561,22 +1569,49 @@ def _refresh(self): def _prime_if_wet(self): mix = getattr(self, "_mix", 1.0) if mix <= 0.0: - self._output = self._source + self._route_around(self._source) return if not getattr(self, "_ready", False): self._output = self._blend return + if self._rejoin(): + # Back off the bypass: every node is cleared - the hold's last + # value, the band limit's memory, the dither gate - and the + # voices, which still hold the blocks they had queued, are + # re-armed below the way `_build` arms them: levels, gates, + # then sources (audiocomponents#113). + self._set_levels() + _component.open_level_gates( + self._dither_mix, [self._dither_mix.voice[0], + self._dither_mix.voice[1], + self._dither_mix.voice[2]], + self._silence) + _component.open_level_gates( + self._blend, [self._blend.voice[0], self._blend.voice[1]], + self._silence) + self._dither_primed = False + self._blend_primed = False want = self._sections[-1] if getattr(self, "_band", False) \ else self._wet_in if self._hold_source is not want: # `play()` re-arms the accumulator, so it is called when the # branch actually moves and not on every macro write. + if want is not self._wet_in: + # Band Limit back on: the sections were not pulled while it + # was off and still hold what they held when it went off. + # A low-pass answers silence with silence, so cleared is + # where they would have decayed to (audiocomponents#113). + self._clear_nodes(only=self._sections) self._hold.play(want) self._hold_source = want hold = self._hold dither_lsb = getattr(self, "_dither_lsb", 0.0) if dither_lsb > 0.0: if not self._dither_primed: + # Dither back on: the gate was not pulled while it was off, + # and an open gate plays the dither out of silence until it + # closes. Closed is what silence would have left it. + self._clear_nodes(only=(self._dither_gate,)) self._dither_mix.voice[0].play(hold) self._dither_mix.voice[1].play(self._dither_a, loop=True) self._dither_mix.voice[2].play(self._dither_b, loop=True) diff --git a/lib/audioeffects/combfilter.py b/lib/audioeffects/combfilter.py index 1bd5501..568f664 100644 --- a/lib/audioeffects/combfilter.py +++ b/lib/audioeffects/combfilter.py @@ -63,32 +63,61 @@ 20 Hz. That is the comb, not latency; it is the delay you asked for by tuning it. -**Above Feedback 0.5 the tail may never reach zero, and the tuning decides -whether it does.** The node's line is int16 and `to_s16` rounds, so -`to_s16(g*c) == c` for every `|c| <= 0.5/(1-g)`: the loop has fixed points, -and after the music stops it can park on one for as long as the graph runs. -Whether it parks depends on the *fractional part* of `sample_rate / -Frequency`. Measured on ten-second renders after a 0.2 s burst, at 48 kHz: -tuned to **1000 Hz, where 48 000/f is a whole 48 frames, it parks on 10 LSB -at Feedback 0.95** - `floor(0.5/(1-g))`, the bound, hit exactly - and on 8 -LSB at 440 Hz and 6 at 220; tuned to **438.3 Hz, half a sample off the -grid, it reaches exact zero at every Feedback this class offers**, because -the interpolator averages the last LSB with a zero neighbour and rounds it -away. Below Feedback 0.5 it always reaches zero. - -**Above Feedback 0.5 the parked ring's period is the nearest whole number -of samples to F_s/Frequency, not the fractional delay the comb was asked -for: +17.4 cents at 1760 Hz / Feedback 0.8 (27 samples at 48 kHz) and at -most a half-sample — about 70 cents — near 4 kHz. The first-repeat tap -still lands within 0.01 cents. Below Feedback 0.5, and at half-sample -tunings, the tail reaches exact zero.** Nothing on this palette removes -the parked case — `cut_hz` is a DC blocker and this is not DC; a Tone -low enough to drain 1760/0.8 misses the 1 kHz park and breaks the peak -law — so `TAIL_SAMPLES` is `None`, `reset()` clears it, and the -silence-in-silence-out invariant is demonstrated inside that bound and -disconfirmed outside it. This class is a tuned resonator: Frequency is a -pitch, and the first-repeat tap is the note it plays. The parked ring is -the leftover, not the note. +**The tail reaches exact zero at every Feedback and every tuning.** Up to +audiodsp v0.6.1 it could not above Feedback 0.5: the node's int16 line +rounded the feedback write to nearest, so every `|c| <= 0.5/(1-g)` was a +fixed point of the loop, and at a tuning whose read lands near a whole +sample the tail parked on a few LSB for as long as the graph ran (10 LSB at +1000 Hz / Feedback 0.95), ringing at the nearest whole-sample period +(+17.4 cents at 1760 Hz / Feedback 0.8). audiodsp v0.6.2 (#154) truncates +the fed-back term toward zero exactly where rounding would hand it back +unchanged, so the line empties: from full scale its loudest sample falls by +the geometric law until it is within `floor(0.5/(1-g))` LSB, then by at +least one LSB a lap. Measured at v0.6.2, 48 kHz, Tone off and Trim flat, +after an impulse: 1760 Hz / 0.8 is silent after 928 frames, 1000 Hz / 0.95 +after 7 488, and 20 Hz / 0.95, the slowest corner of the surface, after +374 400 (7.8 s), each inside that lap bound times the line (511 413 frames, +10.7 s, at 20 Hz / 0.95). While it rings, it rings at the fractional delay +it was asked for: 1760 Hz / 0.8 at 27.262 frames against the asked 27.273, +0.7 cents, through its tenth repeat, and the first-repeat tap lands within +0.01 cents. This class is a tuned resonator: Frequency is a pitch, and the +first-repeat tap is the note it plays. + +**`tail_samples` is finite at every setting** (declared 2026-09-28): an +upper bound, for the settings as they stand, on how long the output takes +to reach exact zero after your input stops. It is the lap law above, each +lap one frame longer than the longest line the read head may still be at +(after a rising Frequency move with Glide on, the line it is walking down +from, until a Glide-0 move or a reset lands it), plus the Tone low-pass's +memory with Tone in, plus 1.5 s while the Trim is in circuit, because the +fixed-point shelf in front lets its last LSB out for up to 0.834 s after a +full-scale input (measured, not derived). At the defaults it is 3 219 +frames at 48 kHz; at 20 Hz / Feedback 0.95 with Tone off and the Trim flat, +398 566 (8.3 s; it counts laps with `DigitalDelay`'s `laps_to_zero`, which +is tighter than the 511 413 above and still covers the 374 400 measured). +Over every macro's stops and three interior points, every patch, +the long corners and the stall centres, at three rates, stereo and mono, +on full-scale DC, noise and a 2 LSB DC, 900 renders end inside it; the +tightest, 20 Hz at Feedback 0.7 on full-scale DC, ends 29 frames short of +69 629. + +**With Tone in, the Feedback you set is the one the node plays.** Wherever +0.5 / (1 - Feedback) is within a hair of a whole number k (0.5, 0.75, and +the knob's top, 0.95, among them), the loop low-pass can rest a hair above +k LSB and hand it back. Up to audiodsp v0.6.2 it did so for ever (1 LSB at +Feedback 0.5 and 2 at 0.75 with Tone at 2 kHz, on a 2 LSB DC), and this +class handed the node a Feedback just clear of each window. Since +v0.6.3rc1 the node sets a stalled low-pass onto its input (audiodsp#157), +nothing is moved, and the bound counts one more lap there. + +**Tone off is off, after Tone has been in too.** Bring Tone back after the +ring has died and nothing plays: 0 LSB at 48, 44.1 and 22.05 kHz, stereo +and mono, after a 300 Hz tone at 30 000 LSB, Feedback 0, Mix 2. The off +stop hands the node exactly 0, and since audiodsp v0.6.3rc1 the node keeps +an off low-pass's state on the signal (audiodsp#158). Up to v0.6.2 it froze +that state and played it back here (15 070 LSB at 48 kHz); this class +cured it with a tracking off stop on 2026-09-28, and the cure came out +when the node was fixed. **Two traits this class does not have.** The *negative* comb, whose peaks sit on the odd half-multiples and which sounds hollow rather than pitched, is @@ -108,6 +137,8 @@ VENDOR = "PyDevices" +import math + try: import audioecho except ImportError: # pragma: no cover - a stock board @@ -119,6 +150,14 @@ from . import _component +# `DigitalDelay`'s loop-tail arithmetic, reused rather than copied: the same +# node rounds the same way here. Its module moves up one level when it comes +# home, so both homes are tried. +try: + from .rebuilt.digitaldelay import laps_to_zero +except ImportError: # pragma: no cover - after it lands + from .digitaldelay import laps_to_zero + #: The line, in milliseconds. 20 Hz wants 50 ms and the node keeps one frame #: of headroom below the line's length (`audiodsp_feedback_delay.c:142-150`), @@ -155,6 +194,32 @@ TRIM_CORNER_HZ = 5.0 TRIM_Q = 0.7071067811865476 +#: How long the trim can keep a non-zero output after its input stops, +#: in seconds, while it is in circuit. The shelf is fixed point with 12 +#: fractional state bits (`audiodsp_biquad.c:163-185` at audiodsp v0.6.2) +#: and a pole pair near z = 1, and its rounding carries the last LSB past +#: the linear decay its poles give, so this term is measured, not derived: +#: over every active Trim step, DC at twelve levels and both signs, 5 and +#: 40 Hz sines stopped at eight phases, and noise, 15 621 cells, none held +#: and the longest ran 40 041 frames at 48 kHz (0.834 s; 0.825 s at 44.1, +#: 0.818 s at 22.05; `housekeeping_cf_trim_tail.py`, 2026-09-28). 1.5 s is +#: 1.8 times that. The comb's lap bound counts from where the trim stops. +TRIM_TAIL_S = 1.5 + + +def _tone_excess(damping_hz, sample_rate): + """(frames, relative excess) for the in-loop low-pass at `damping_hz`, + `DigitalDelay`'s reckoning: after `frames` frames whatever its state + held weighs under 2^-17 of it, and its single-precision state can rest + up to 2^-24 / a above the line's peak, a being the coefficient. + (0, 0.0) with Tone off.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + class CombFilter(_component.Component): """A tuned feedback comb: one delay line of 1/f seconds fed back on @@ -172,13 +237,9 @@ class CombFilter(_component.Component): CAPABILITIES = () LATENCY_SAMPLES = 0 - #: Not finitely bounded. Above Feedback 0.5 the parked ring's period - #: is the nearest whole number of samples to F_s/Frequency, not the - #: fractional delay the comb was asked for: +17.4 cents at 1760 Hz / - #: Feedback 0.8 (27 samples at 48 kHz) and at most a half-sample — - #: about 70 cents — near 4 kHz. The first-repeat tap still lands - #: within 0.01 cents. Below Feedback 0.5, and at half-sample tunings, - #: the tail reaches exact zero. + #: The class-level declaration stays `None`; the instance's + #: `tail_samples` is finite at every setting (2026-09-28), because the + #: bound depends on the setting. TAIL_SAMPLES = None MACRO_LABELS = ("Frequency", "Feedback", "Mix", "Tone", "Trim", "Glide") @@ -245,8 +306,20 @@ def _build(self, frequency=440.0, feedback=0.7, mix=1.0, upstream = node self._output = upstream + #: The longest line, in frames rounded up, the read head may still + #: sit at. A Glide walk starts from wherever the head is and the + #: class cannot see how far it has got, so after a rising move + #: (a shorter line) this keeps the old length until a jump + #: (Glide 0) or a reset lands the head. + self._reach = 1 + #: True while the node has been built or cleared and not yet + #: pulled: it snaps onto the configured delay on its first block. + self._fresh = True + self._feedback = 0.0 + self._damping = 0.0 self._init_macros( (frequency, feedback, mix, tone_hz, trim_db, glide), patch) + self._fresh = False # -- the control laws ---------------------------------------------- @@ -267,18 +340,63 @@ def _refresh(self): blend = 1.0 tone = self._value(3) trim_db = self._value(4) + damping = self._tone_damping(tone) + slew = self._value(5) + frames = int(math.ceil(self._sample_rate / frequency)) + if self._fresh or slew <= 0.0 or frames > self._reach: + self._reach = frames + # Handed as set: since audiodsp v0.6.3rc1 the node lands a stalled + # loop low-pass (#157), so no Feedback is moved clear of a window. + feedback = self._value(1) + self._feedback = feedback + self._damping = damping self._comb.set( delay_ms=1000.0 / frequency, - feedback=self._value(1), + feedback=feedback, mix=blend, - damping_hz=0.0 if tone >= TONE_OFF_HZ else self._hz(tone), - delay_slew=self._value(5)) + damping_hz=damping, + delay_slew=slew) self._trim.gain_db = trim_db self._trim.mix = (1.0 if blend > 0.0 and abs(trim_db) >= FLAT_DB else 0.0) + def _tone_damping(self, tone): + """The `damping_hz` handed to the node for a Tone of `tone` Hz: the + top of the travel is exactly 0, the filter off. Since audiodsp + v0.6.3rc1 the node keeps an off low-pass's state on the signal + (audiodsp#158), so off is off whatever came before.""" + if tone < TONE_OFF_HZ: + return self._hz(tone) + return 0.0 + def _apply_macro(self, index, position): del index, position self._refresh() + + def reset(self): + self._fresh = True + _component.Component.reset(self) + self._fresh = False + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound for the settings as they stand: `laps_to_zero` laps + of the longest line the read head may be at, each one frame longer + for the interpolated read and the Tone low-pass's memory longer, + plus `TRIM_TAIL_S` while the trim is in circuit. Finite at every + setting.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check, for subclasses (a + MicroPython `property` has no `fget`).""" + memory, excess = _tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + trim = 0 + if self._trim.mix > 0.0: + trim = int(math.ceil(TRIM_TAIL_S * self._sample_rate)) + return int(laps * (self._reach + 1 + memory)) + trim diff --git a/lib/audioeffects/compressor.py b/lib/audioeffects/compressor.py index 901f6ee..63559b2 100644 --- a/lib/audioeffects/compressor.py +++ b/lib/audioeffects/compressor.py @@ -6,6 +6,11 @@ ratio law and side-chain weighting** - not in three time constants, which is all the class this replaces had (dossier §7.2). +Mix back up from 0 comes back as the class was built: the two stages +cleared and the voices primed afresh. An instance built wet and moved to 0 +before it played used to keep the block its construction primed and play it +256 frames after the input stopped (audiocomponents#113). + * **`fet`** - the 1176. Peak detector, both time knobs live and *faster clockwise* across 800 to 20 microseconds and 1.1 seconds to 50 milliseconds, threshold rising with ratio. Turn everything up and it @@ -368,8 +373,18 @@ def _refresh_output(self): if not self._ready: return if self.macro(13) <= 0.0: - self._output = self._source + self._route_around(self._source) return + # Back off a bypass each voice still holds the block it had queued - + # on an instance built wet and moved to 0 before it played, the + # block `_prime` took at construction, which came out 256 frames + # after the input stopped. So the graph is cleared and the voices + # primed afresh (audiocomponents#113). The two Dynamics hold a gain + # and no audio, but a voice's `play()` resets what it plays, so the + # stage it reads restarts its gain either way: the class comes back + # as it was built, envelopes and all. + if self._rejoin(): + self._primed = False if not self._primed: self._prime() self._output = self._mixer diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py index 7a8fc63..2017e57 100644 --- a/lib/audioeffects/distortion.py +++ b/lib/audioeffects/distortion.py @@ -14,6 +14,12 @@ The DS-1's booster is in the circuit, not on the panel, so `scoop` starts with Boost at 35 dB; the Rat has no booster and starts at 0. +**Mix back up from 0 plays nothing from before.** At Mix 0 nothing behind +the source is pulled, so the circuit used to keep what it held and play it +when Mix came back - 24 589 LSB out of silence at shipped patch 1. It now +comes back as it was built: cleared, the output capacitor charged on the +bias at the scoop patches, the voices re-armed (audiocomponents#113). + **Portability tier: audiodsp** (`REQUIRES = ("audioshaper", "audiobiquad", "audioroute")`). The clipper is `audioshaper.Waveshaper` with a table derived from the 1N4148 pair (tools/curves/distortion_curve.py). Tone and @@ -863,15 +869,30 @@ def _push(self): volume = self.macro(VOL) if mix <= 0.0: self._latency = 0 - self._output = self._source + self._route_around(self._source) return + # Back off a bypass, the graph behind the mixer has not been pulled + # since Mix went to 0 and still holds what it held then: its + # filters' memory and the block each voice had queued. `_rejoin` + # clears it, and it is armed below exactly as `_build` arms it - + # the output capacitor charged on the bias, the level gates opened + # at the levels just pushed, every voice re-played - before the + # port is pointed at it (audiocomponents#113). + rejoin = self._rejoin() # The dry leg is not delayed and the wet leg is, so the class's own # onset is the DRY one at every Mix that lets any dry through: a # click at Mix 64 arrives at sample 0, not at sample 2. Reporting # the wet leg's 2 there was true of one point on the axis and false # of the other 126. self._latency = self._wet_latency if mix >= 1.0 else 0 - self._output = self._mixer + if not rejoin: + self._output = self._mixer + self._push_wet(mix, volume) + if rejoin: + self._connect() + self._output = self._mixer + + def _push_wet(self, mix, volume): self._dry.level = 1.0 - mix ceiling = self.macro(CEIL) if self._scoop: diff --git a/lib/audioeffects/dynamiceq.py b/lib/audioeffects/dynamiceq.py index c605597..4701513 100644 --- a/lib/audioeffects/dynamiceq.py +++ b/lib/audioeffects/dynamiceq.py @@ -35,8 +35,10 @@ upstream's Q15 level is `1.0 * 32768` and the kernel divides by 32767, so the dry tap at unity came out one LSB high at every sample from 32736 up (audiodsp#95, three of 16384 on a full-scale ramp). The mixer's voices take -their sources the first time Mix leaves 0, with their level gates opened on -one block of silence first. +their sources whenever Mix leaves 0, with their level gates opened on one +block of silence first, and the filters, cell and tail are cleared before +they do: they used to keep what they held when Mix went to 0 and play it +when it came back, up to 7 769 LSB out of silence (audiocomponents#113). **`expand=True` is a build, not a knob.** `audiodynamics.Dynamics` fixes its mode at construction, and a class that carried both a compressor and an @@ -64,7 +66,9 @@ **Cost, and it went up.** Two biquads, one detector-and-gain-cell, a three-tap splitter ring, a three-voice mixer, a block-sized guard and an -identity `MidSide`, on a graph that runs in 256-frame blocks throughout. +identity `MidSide`, on a graph that runs in 256-frame blocks throughout - +mono included since 2026-09-28, when the mixer's fixed 2048-byte buffer +(two blocks mono) became `1024 * channels`; stereo is the same bytes. Measured on the desktop against the class this replaces, five interleaved repeats of each: **1.46-1.56 ms per 256-frame stereo block against 1.10-1.15**, about **45 % more**. Three of those nodes are new - the guard, @@ -285,9 +289,18 @@ def _build(self, frequency=3000.0, q=2.0, threshold_db=-30.0, ratio=4.0, # why the audible bell is narrower than f0/Q (dossier T5). cell.play(band) + # One render is one 256-frame block at either channel count + # (`Mixer._render_size` is `buffer_size // 2 // 4 * 4` bytes). It + # was a fixed 2048, which is one block stereo but TWO mono, and then + # the tail below hands out half a render and keeps the other half + # queued: a Mix move to 0 between the halves skipped those 256 + # frames, and the move back played them - old audio out of silence + # (audiocomponents#113). Stereo is byte for byte what it was; mono + # renders the same samples in blocks half the size. mixer = audiomixer.Mixer( voice_count=3, sample_rate=rate, channel_count=channels, - bits_per_sample=16, samples_signed=True, buffer_size=2048) + bits_per_sample=16, samples_signed=True, + buffer_size=1024 * channels) # The class does **not** end in the Mixer, and that is not decoration. # On upstream CircuitPython `audiomixer.Mixer.reset_buffer` *stops* @@ -430,8 +443,17 @@ def _refresh_output(self): return if self.macro(self._MIX) <= 0.0 \ and self._macros[self._LISTEN] < 0.5: - self._output = self._head + self._route_around(self._head) return + # Back off the bypass, the filters, the cell and the identity tail + # still hold what they held when Mix went to 0, and each voice the + # block it had queued. Cleared, then the voices re-played once + # through the cleared nodes, as `_build` does (audiocomponents#113). + # The guard is the bypass's own node and still live, so it is kept; + # the mixer is kept out of the walk because its registered reset + # re-plays the voices twice over. + if self._rejoin(keep=(self._head, self._mixer)): + self._primed = False if not self._primed: self._play_voices() self._output = self._tail diff --git a/lib/audioeffects/exciter.py b/lib/audioeffects/exciter.py index c53b242..d497294 100644 --- a/lib/audioeffects/exciter.py +++ b/lib/audioeffects/exciter.py @@ -15,6 +15,12 @@ Envelope-following `Waveshaper.bias` was not granted; bias is static. Transient Time is not on the surface: T5's τ clause is disconfirmed. +**Mix back up from 0 plays nothing from before.** At Mix 0 (with Output +at 0 dB) the class hands back its source and the harmonic branch is not +pulled; it used to keep what it held and play it when Mix came back, up to +10 768 LSB out of silence. It is now cleared and its voices re-armed first +(audiocomponents#113). + **Harmonics is 0…+14 dB of drive across the diode, not 0…+24.** Measured on this class's own curve, the top ten of those twenty-four decibels buy **1.76 dB** of h2 at −6 dBFS and cost **6.7 dB** of alias: past the knee @@ -679,11 +685,22 @@ def _prime_if_wet(self): # top of the macro put the wet over the dry (audiocomponents#72). out = 10.0 ** (self._value(4) / 20.0) if mix <= 0.0 and out >= 1.0: - self._output = self._source + self._route_around(self._source) return if not getattr(self, "_ready", False): self._output = self._blend return + if self._rejoin(): + # Back off the bypass: the nodes are cleared, and the voices + # still hold the blocks they had queued when the bypass began. + # Re-armed as `_build` arms them - levels, gates, voices - and + # only then is the port pointed back (audiocomponents#113). + self._blend.voice[0].level = out + self._blend.voice[1].level = mix * out + _component.open_level_gates( + self._blend, [self._blend.voice[0], self._blend.voice[1]], + self._silence) + self._primed = False if not self._primed: self._dc.play(self._shaper) self._blend.voice[0].play(self._dry) diff --git a/lib/audioeffects/fuzz.py b/lib/audioeffects/fuzz.py index 716786d..05b3b83 100644 --- a/lib/audioeffects/fuzz.py +++ b/lib/audioeffects/fuzz.py @@ -12,6 +12,12 @@ when you hit it. There is no tone stack. `cascade` is a Ram's Head 1973 Big Muff: two silicon pairs and a mid scoop between 482 Hz and 1206 Hz. +**Mix back up from 0 plays nothing from before.** At Mix 0 the class hands +back its source and nothing behind it is pulled; it used to keep what it +held and play it when Mix came back, up to 23 153 LSB out of silence. It +now comes back as it was built: cleared, the output pole charged on the +bias, the tone mixer's voices re-armed (audiocomponents#113). + **What the default surrenders.** Mix 1, Fuzz 36 dB, Bias 0, Load 0, Tilt 0. Tone is inert on germanium. G2's 0.5 dB-per-step THD staircase is not met here (the last steps are +0.17 / +0.04 / +0.008 dB); h2/h1 still moves. @@ -980,12 +986,37 @@ def _refresh_output(self): if not self._ready: return if self._value(5) <= 0.0: - self._output = self._source + self._route_around(self._source) self._latency = 0 else: + if self._rejoin(keep=(self._tone_mix,)): + self._rearm() self._output = self._tilt self._latency = self._wet_delay + def _rearm(self): + """Arm a graph Mix 0 left un-pulled, the way `_build` arms it. + + Back off a bypass every node still holds what it held when Mix went + to 0, and on `cascade` each voice still holds the block it had + queued; `_rejoin` has cleared the nodes. The mixer is left out of + that walk on purpose: its registered reset re-plays the voices + there and then, which would take a block through a pole not yet + charged. So the order is `_build`'s: the output pole charged on the + offset, the shapers pointed back at the chain, then the gates and + the voices (audiocomponents#113). + """ + self._charge_output(rewire=True) + if self._tone_mix is not None: + _component.open_level_gates( + self._tone_mix, + [self._tone_mix.voice[0], self._tone_mix.voice[1], + self._tone_mix.voice[2]], + self._silence) + self._tone_mix.voice[0].play(self._tone_lp) + self._tone_mix.voice[1].play(self._tone_hp) + self._tone_mix.voice[2].play(self._dry) + def program_change(self, index, channel=0, note_id=-1, sample_position=0): _component.Component.program_change(self, index, channel, note_id, diff --git a/lib/audioeffects/limiter.py b/lib/audioeffects/limiter.py index abd0264..bb2070b 100644 --- a/lib/audioeffects/limiter.py +++ b/lib/audioeffects/limiter.py @@ -32,6 +32,18 @@ class that delays anything, it defaults to **0 ms**, and `latency_samples` to 480 samples (10 ms) at 48 kHz. True Peak adds **no latency at all**: the 4x reconstruction runs on the detector signal and never reaches the audio path. +**A lookahead that grows starts from silence.** The node writes its line +only as far as its delay reaches, so lengthening it used to expose what +the line held the last time it was that long: Lookahead off and back on +after a pause replayed the last note at full level (31 373 LSB), and True +Peak moving its twelve-sample reserve between the stages did the same. A +stage whose delay grows is now reset first (audiocomponents#113). The node +cannot clear its line alone, so that stage's gain restarts too: with the +shape stage's zero attack it catches the next peak on its first sample, and +the catch stage holds the ceiling throughout. Lengthening the lookahead +while audio plays therefore inserts that much silence, where it used to +insert old audio. + **True Peak costs real time, and it costs lookahead.** The palette prices each `Dynamics` with `true_peak=2` at the +options row: **1.611 ms / 3.461 ms** marginal on the P4 / S3. This class lights it on *both* stages, so the @@ -230,6 +242,10 @@ def _build(self, ceiling_db=-1.0, gain_db=0.0, lookahead_ms=0.0, low material - see the class docstring.""" self._latency = 0 self._true_peak = bool(true_peak) + #: The delay each stage was last handed, in samples. See + #: `_apply_lookahead` for why a stage's growth clears it. Made there + #: if a subclass's `_build` did not, so a planted build still runs. + self._held = {} self._shape = self._own(audiodynamics.Dynamics( audiodynamics.DYN_COMPRESS, sample_rate=self._sample_rate, @@ -337,6 +353,23 @@ def _apply_lookahead(self, position): reserve = (min(samples, type(self).TRUE_PEAK_RESERVE_SAMPLES) if self._true_peak else 0) shape = samples - reserve + # A stage whose delay GROWS is cleared first. The node writes its + # lookahead line only as far as the delay it is set to, so the rest + # of the line keeps whatever it held the last time the delay reached + # that far; lengthen it again and that plays - Lookahead off and + # back on after a pause replayed the last note at full level, 31 373 + # LSB, and True Peak's twelve-sample reserve moving between the + # stages did the same (audiocomponents#113). The node has no way to + # clear the line alone, so the stage is reset: the line goes to + # silence and the stage's gain starts afresh. A delay that shrinks + # or stays keeps its line. + held = getattr(self, "_held", None) + if held is None: + held = self._held = {} + for stage, frames in ((self._shape, shape), (self._catch, reserve)): + if frames > held.get(stage, 0) and not self._constructing: + self._clear_nodes(only=(stage,)) + held[stage] = frames self._shape.set(lookahead_ms=(0.0 if shape == 0 else (shape + 0.5) * 1000.0 / rate)) release_ms = _CATCH_RELEASE_MS diff --git a/lib/audioeffects/multibandcompressor.py b/lib/audioeffects/multibandcompressor.py index 61621e9..6edfb7d 100644 --- a/lib/audioeffects/multibandcompressor.py +++ b/lib/audioeffects/multibandcompressor.py @@ -43,7 +43,10 @@ so. Nor is the dry tap at unity enough: a mixer voice at level 1.0 scales by 32768/32767 on every target (audiodsp#95), so `_refresh_output` hands back the borrowed source itself and puts no node of this class's in the - path at all. + path at all. Mix back up from 0 comes back as the class was built: the + crossover and detectors cleared, the voices re-armed. It used to play what + the bands held when Mix went to 0, up to 22 264 LSB out of silence + (audiocomponents#113). * **How close the two crossovers may get.** The three-way parallel split has a floor that is a function of the crossover *ratio*, not of the @@ -663,8 +666,17 @@ def _refresh_output(self): if not self._ready: return if self._mix <= 0.0: - self._output = self._head + self._route_around(self._head) return + # Back off the bypass, the crossover and the detectors still hold + # what they held when Mix went to 0, and each voice the block it had + # queued. Cleared, then the voices re-played once through the + # cleared nodes, as `_build` does (audiocomponents#113). The guard + # is the bypass's own node and still live, so it is kept; the mixer + # is kept out of the walk because its registered reset re-plays the + # voices twice over. + if self._rejoin(keep=(self._head, self._mixer)): + self._primed = False if not self._primed: self._play_voices() self._output = self._mixer diff --git a/lib/audioeffects/overdrive.py b/lib/audioeffects/overdrive.py index 27481bb..3c8a656 100644 --- a/lib/audioeffects/overdrive.py +++ b/lib/audioeffects/overdrive.py @@ -17,7 +17,10 @@ of circuit, the way a true-bypass footswitch takes it out. One step above zero the tone stack is in circuit on the dry note as well, so the step off Mix 0 is a tone change, and that is the pedal switching on rather than a -fade. +fade. It switches on as it was built, not as it was left: the circuit is +cleared and armed again - the output capacitor charged on the offset - +before it is heard, where it used to play what it held when Mix went to 0, +up to 11 640 LSB out of silence (audiocomponents#113). **The clip table is a normalised shape, not volts.** `CURVE` fills Q15 to both rails and `CURVE_VOLTS` is what its full scale stands for; the class @@ -785,11 +788,14 @@ def _prime_if_wet(self): mix = self._value(3) level = self._value(2) if mix <= 0.0: - self._output = self._source + self._route_around(self._source) return if not getattr(self, "_ready", False): self._output = self._out return + if self._rejoin(): + self._rearm(level) + return if not self._primed: self._circuit.voice[0].play(self._dry) self._circuit.voice[1].play(self._dc) @@ -799,3 +805,35 @@ def _prime_if_wet(self): self._circuit.voice[0].level = 1.0 self._circuit.voice[1].level = 1.0 self._out.voice[0].level = level + + def _rearm(self, level): + """Arm a graph Mix 0 left un-pulled, the way `_build` arms it. + + Back off a bypass the filters, the shaper and the output capacitor + still hold what they held when Mix went to 0, and each mixer voice + still holds the block it had queued; `_rejoin` has just cleared the + nodes. What is left is `_build`'s own sequence: the levels, the + gates opened at them, the voices re-played with the clip branch + muted at the shaper so the block each keeps is a zero one, and the + capacitor charged on the offset before the port is pointed back at + the graph (audiocomponents#113). + """ + self._dc_charged = False + self._circuit.voice[0].level = 1.0 + self._circuit.voice[1].level = 1.0 + self._out.voice[0].level = level + if self._clip_bias: + self._shaper.set(post_gain=0.0) + _component.open_level_gates( + self._circuit, [self._circuit.voice[0], self._circuit.voice[1]], + self._silence) + _component.open_level_gates(self._out, [self._out.voice[0]], + self._silence) + self._circuit.voice[0].play(self._dry) + self._circuit.voice[1].play(self._dc) + self._out.voice[0].play(self._lp) + self._primed = True + if self._clip_bias: + self._shaper.set(post_gain=self._post_gain) + self._settle_dc() + self._output = self._out diff --git a/lib/audioeffects/rack.py b/lib/audioeffects/rack.py index efcba67..3e1d5bc 100644 --- a/lib/audioeffects/rack.py +++ b/lib/audioeffects/rack.py @@ -10,7 +10,7 @@ rack = audioeffects.create("Rack", source, 48000, chain=( ("Compressor", {"threshold_db": -24.0, "ratio": 3.0}), ("TapeDelay", {"time_ms": 340.0, "mix": 0.25}), - ("Reverb", {"preset": "hall", "mix": 0.3}), + ("Reverb", {"character": "hall", "mix": 0.3}), )) audio_out.play(rack.output) @@ -19,6 +19,13 @@ fixed topologies with a macro surface over the children, so they carry patches the way any patchable effect here does. Racks may contain and be used by other racks: a `chain` entry may itself be a `("Rack", {...})`. + +Those two racks name the classes they were ported against, `delay.py`'s +`TapeDelay` and `reverb.py`'s `Reverb`, directly rather than by NAME: the +rebuilt classes the package serves under those names since their adoption +on 2026-09-29 take different settings (no `preset`, no `wow`) and sound +different, and moving a rack onto them is a re-voicing of the rack, not a +part of the adoption. """ VENDOR = "PyDevices" @@ -27,10 +34,15 @@ from ._component import macro_position as _macro_position from ._component import midi_of_position as _midi_of_position from ._component import port_target as _port_target +from .delay import TapeDelay as _OldTapeDelay +from .reverb import Reverb as _OldReverb def _child(name, tail, options): - """One chain entry built around `tail` at the configured rate.""" + """One chain entry built around `tail` at the configured rate. `name` + is an effect NAME, or, for the soundtrack racks below, a class.""" + if not isinstance(name, str): + return name.create(tail, _core.sample_rate(), **options) import audioeffects return audioeffects.create(name, tail, _core.sample_rate(), **options) @@ -175,9 +187,9 @@ def __init__(self, source, shimmer=0.55, echo=0.36, space=0.42, # skipped for starting at zero could never come back. Rack.__init__(self, source, chain=( ("Octaver", {"down": 0.0, "up": 1.0}), - ("TapeDelay", {"time_ms": 420.0, "feedback": 0.5, "wow": 0.3, - "drive": 0.2}), - ("Reverb", {"preset": "hall"}), + (_OldTapeDelay, {"time_ms": 420.0, "feedback": 0.5, + "wow": 0.3, "drive": 0.2}), + (_OldReverb, {"preset": "hall"}), )) self.octave, self.tape, self.hall = self.effects self._init_macros((shimmer, echo, space, tone_hz), patch) @@ -215,8 +227,8 @@ def __init__(self, source, space=0.3, echo=0.25, frequency=4200.0, patch=None): Rack.__init__(self, source, chain=( ("LowPass", {"frequency": 4200.0}), - ("TapeDelay", {"time_ms": 375.0, "feedback": 0.4}), - ("Reverb", {"preset": "hall"}), + (_OldTapeDelay, {"time_ms": 375.0, "feedback": 0.4}), + (_OldReverb, {"preset": "hall"}), )) self.tone, self.tape, self.hall = self.effects self._init_macros((space, echo, frequency), patch) diff --git a/lib/audioeffects/rebuilt/__init__.py b/lib/audioeffects/rebuilt/__init__.py index 51d6006..51df1d5 100644 --- a/lib/audioeffects/rebuilt/__init__.py +++ b/lib/audioeffects/rebuilt/__init__.py @@ -90,7 +90,41 @@ def _present(): #: there is nothing left here to arbitrate for any of them. #: #: What stays parked under this directory: Phase 3's `Flanger`. -ADOPTED = () +#: +#: Phase 5's first two, adopted 2026-09-28 on Brad's rulings of that date: +#: `DigitalDelay` and `SlapbackDelay`. Both are THROUGH on the desktop half +#: (`audit-DigitalDelay-reaudit2.md`, `audit-SlapbackDelay-reaudit1.md`), +#: and their board rows met the bars (board-test-plan.md, section 2), each +#: S3 patch 5 at the corrected cost Brad passed (DigitalDelay about +#: 0.78 ms, 14.6 % of a block; SlapbackDelay 0.813 ms, 15.2 % against the +#: 15 % bar) and each patch digest identical board to board, differing +#: from the desktop only through single-precision Python floats in a +#: derived setting (audiocomponents#75). They stay under this directory for +#: now: `digitaldelay.py`'s loop-tail arithmetic is imported from here by +#: `SlapbackDelay`, `CombFilter` and the FeedbackDelay classes Phase 5 is +#: still building, and coming home moves it. That is a separate step, after +#: those classes land. +#: +#: Phase 5's last six, adopted 2026-09-29: `TapeDelay`, `AnalogDelay`, +#: `PingPongDelay`, `MultiTapDelay`, `Reverb` and `ConvolutionReverb`. The +#: board session at audiodsp v0.6.3 met the cost bar (80 % of a 5.333 ms +#: stereo block) and real time at every cell on the ESP32-P4, and on the +#: ESP32-S3 at every cell but MultiTapDelay's patch 4 (5.567 ms), which its +#: docstring discloses on Brad's ruling. Each board digest equals the +#: desktop's, or a desktop MicroPython's built with single-precision floats +#: (audiocomponents#75); Reverb's patch 7 differs from that build by nine +#: one-LSB samples of 65536 (audiodsp#183). They stay under this directory +#: with the first two, for the same reason. +ADOPTED = ( + "DigitalDelay", + "SlapbackDelay", + "TapeDelay", + "AnalogDelay", + "PingPongDelay", + "MultiTapDelay", + "Reverb", + "ConvolutionReverb", +) def load(name): diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py new file mode 100644 index 0000000..6040913 --- /dev/null +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -0,0 +1,512 @@ +"""`AnalogDelay` - a bucket-brigade delay whose Time knob is its clock. + +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/AnalogDelay.md`). +""" + +VENDOR = "PyDevices" + +from array import array +import math + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's tail and transport arithmetic, reused rather than copied: +# the same node rounds the same way in both classes. Its module moves up +# one level when it comes home, so both homes are tried. +try: + from .digitaldelay import DIVISION_BEATS, laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import DIVISION_BEATS, laps_to_zero, whole_frames + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +#: The characters and their stage counts (dossier section 6): one MN3005 +#: on the DM-2, two in series on the Deluxe Memory Man. +SINGLE_LINE = "single-line" +DOUBLE_LINE = "double-line" +STAGES = {SINGLE_LINE: 4096, DOUBLE_LINE: 8192} + +#: sinc(x) is -3 dB at x = 0.4422, so the corner is 0.4422 f_clk = +#: 0.2211 N / T (dossier Appendix A). +CORNER_PER_STAGE_SECOND = 0.2211 + +#: The Time map, fixed on every instance: 20-600 ms, log, on both +#: characters (the span is ours; dossier section 8.11). +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 600.0 + +#: Modulation's peak delay swing, ms, and Mod Rate's span, Hz. +SWING_MAX_MS = 5.0 +RATE_MIN_HZ = 0.05 +RATE_MAX_HZ = 8.0 + +#: The line over `max_time_ms`: the modulation's peak swing, plus one for +#: the node's `length - 2` clamp on the read (`audiodsp_feedback_delay.c` +#: `tap_for`). +LINE_HEADROOM_MS = SWING_MAX_MS + 1.0 + +#: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). +FEEDBACK_MAX = 0.99 + +#: One period of the modulation's triangle, borrowed by the node. +TABLE_POINTS = 256 + +#: How close a Time write must be to the constructor's seed position to +#: count as a host reading the knob back and writing it again, which keeps +#: the constructor's exact Time on the audio path. +ECHO_TOLERANCE = 1e-9 + +TIME_I, FEEDBACK_I, MIX_I, MODULATION_I, RATE_I, SPREAD_I, SYNC_I, \ + DIVISION_I = range(8) + + +def triangle_table(points=TABLE_POINTS): + """Q15 unit triangle, one period: 0 -> +1 -> 0 -> -1 -> 0.""" + values = array("h", [0] * points) + for index in range(points): + phase = index / float(points) + if phase < 0.25: + unit = 4.0 * phase + elif phase < 0.75: + unit = 2.0 - 4.0 * phase + else: + unit = 4.0 * phase - 4.0 + values[index] = int(round(unit * 32767.0)) + return values + + +def corner_hz(stages, time_ms): + """The sinc's -3 dB point for `stages` at `time_ms`, before the clamp.""" + return CORNER_PER_STAGE_SECOND * stages * 1000.0 / float(time_ms) + + +def clock_slew(from_frames, to_frames): + """The node's `delay_slew` for a clock step from a settled line: the + walk runs |dT| / T_new delay-seconds per second, so it lasts exactly + T_new and holds the ratio T_old / T_new (dossier section 6).""" + return abs(float(to_frames) - float(from_frames)) / float(to_frames) + + +def walk_floor(frames): + """The smallest `delay_slew` that still moves a read head sitting at up + to `frames`: two single-precision steps of `frames`. The node walks + `delay_current += slew` in single precision, and a slew under half a + step of the head's position rounds back to where it was, so the head + would stay put for good (a move under about T / 2048 frames).""" + mantissa, exponent = math.frexp(float(max(1, frames))) + del mantissa + return 2.0 ** (exponent - 23) + + +def _f32(value): + """`value` rounded to single precision, the same on every interpreter.""" + return array("f", (value,))[0] + + +def _f32_up(value): + """The next single-precision value above a positive single `value`.""" + mantissa, exponent = math.frexp(value) + del mantissa + return _f32(value + 2.0 ** (exponent - 24)) + + +def node_frames(value_ms, sample_rate): + """What the node makes of `delay_ms = value_ms`: the single-precision + `value * rate / 1000.0f` (`audiodsp_feedback_delay.c:148`).""" + return _f32(_f32(_f32(value_ms) * sample_rate) / 1000.0) + + +def hand_off_ms(frames, sample_rate): + """The `delay_ms` the class hands the node for whole frame `frames`: + `frames * 1000 / fs` in single precision, stepped up one single step + where the node's own arithmetic would land below `frames`, so the + read's whole part is `frames` (dossier section 6, the hand-off).""" + value = _f32(frames * 1000.0 / sample_rate) + if node_frames(value, sample_rate) < frames: + value = _f32_up(value) + return value + + +def tone_excess(damping_hz, sample_rate): + """(frames, relative excess) for the loop low-pass, by DigitalDelay's + `_tone_excess` arithmetic: after `frames` frames whatever the state + held weighs under 2^-17 LSB, and the single-precision state can rest + up to 2^-24 / a of the peak above it, a being the coefficient.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + + +def _between(value, low, high): + value = float(value) + if not value >= low: + return low + if value > high: + return high + return value + + +class AnalogDelay(_component.Component): + """A bucket-brigade delay whose Time knob is the line's clock. + + A bucket brigade has a fixed number of stages, so a longer delay is a + slower clock and a lower band limit: the repeats get darker as you turn + Time up, and your dry signal passes untouched. + + **The controls.** Time sets the delay and with it the clock. Feedback + sends each repeat round again. Mix blends the repeats in. Modulation + and Mod Rate wobble the delay, a chorus in a blend and a vibrato with + the repeats alone. Spread feeds each side's repeats into the other. + Sync locks Time to Division of the host's beat, clamped to Time's span. + Time runs from 20 to 600 ms, Feedback from 0 to 0.99 and Mix from 0 to 2. + Up to Mix 1 the dry passes untouched until the first repeat arrives. + Mix 0 is a wire. + A click comes out on the frame it went in: there is no latency. + A one-channel source gets the same effect with Spread held at 0. + + **Two characters.** `"single-line"` (the default) is one 4096-stage + line, the Boss DM-2; `"double-line"` is two in series, the Deluxe Memory + Man. Any other `character` raises `ValueError`. + The repeats' high-frequency corner is 0.2211 N / T for N stages and a + Time of T seconds, so it halves each time Time doubles. + Where the law passes 0.98 of Nyquist the corner holds there, so a + shorter Time no longer brightens the repeats. + + **Time.** Every Time lands on a whole frame at every rate. + A small Time move still lands. + Turning Time through several positions a block apart takes seconds to + settle, where one jump to the same place lands within the new Time. + + **Modulation.** The delay swings on a triangle by a fixed number of + milliseconds, whatever the Time. + A Time move leaves the swing as it is. + A Modulation move glides over 20 ms. + + **Level and tail.** Below Mix 1, an input peaking at or below + floor(32767 (1 - Mix)) - 1 does not reach the rail. + `tail_samples` is an upper bound on how long the repeats take to reach + exact zero after your input stops. + `reset()` empties the line and returns to patch 0. + With no host tempo, Time stays on the knob. + + **What it leaves out.** There is no sample-and-hold, so the repeats + have no null at the clock. + There is no fixed anti-alias pair: the repeats' one corner is the one + that moves with Time. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + The tail rings only while the source keeps feeding: feed silence to let + it ring out. A tail cut short by a source that stopped carries on when + the source comes back. + """ + + NAME = 'AnalogDelay' + DISPLAY_NAME = 'Analog Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Feedback", "Mix", "Modulation", "Mod Rate", + "Spread", "Sync", "Division") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "TOGGLE", + 7: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (0.0, 2.0), # 2 Mix; dry at unity to 1 + (0.0, SWING_MAX_MS), # 3 Modulation, peak swing ms + (RATE_MIN_HZ, RATE_MAX_HZ, "log"), # 4 Mod Rate, Hz + (0.0, 1.0), # 5 Spread (cross-feed) + (0.0, 1.0), # 6 Sync + (0.0, 15.0), # 7 Division index + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Single Line Repeats", (101, 51, 25, 0, 69, 0, 0, 51)), + 1: ("Short Bright Repeats", (41, 38, 32, 0, 69, 0, 0, 51)), + 2: ("Long Dark Repeats", (124, 71, 25, 0, 69, 0, 0, 51)), + 3: ("Modulated Repeats", (107, 58, 29, 76, 75, 0, 0, 51)), + 4: ("Wet Vibrato", (26, 0, 127, 51, 115, 0, 0, 51)), + 5: ("High Feedback Wash", (116, 109, 22, 25, 52, 64, 0, 51)), + 6: ("Dotted Eighth, Synced", (101, 58, 25, 0, 69, 0, 127, 68)), + } + + def _build(self, time_ms=300.0, feedback=0.4, mix=0.4, modulation_ms=0.0, + mod_rate_hz=0.8, spread=0.0, sync=False, division=6, + character=SINGLE_LINE, max_time_ms=TIME_MAX_MS, patch=None): + if character not in STAGES: + raise ValueError("character must be %r or %r, not %r" + % (SINGLE_LINE, DOUBLE_LINE, character)) + self._character = character + self._stages = STAGES[character] + max_time_ms = float(max_time_ms) + # `not <=` catches NaN, which would otherwise pass both clamps and + # size the line from nothing. + if not max_time_ms <= TIME_MAX_MS: + max_time_ms = TIME_MAX_MS + if max_time_ms < TIME_MIN_MS: + max_time_ms = TIME_MIN_MS + self._max_time_ms = max_time_ms + #: The whole-frame Time last handed to the node, and what was handed. + self._frames = 1 + self._node_ms = 0.0 + #: The longest whole-frame delay the read head may still sit at. A + #: walk starts from wherever the head is and the class cannot see how + #: far it has got, so after a falling move this keeps the old Time + #: until a reset lands the head. + self._reach = 1 + #: The walk rate of the last Time move; a move of another macro + #: leaves the walk in progress at its rate. + self._slew = 0.0 + #: True while the node has been built or cleared and has not yet + #: been told a second Time: it snaps onto the delay on its first pull. + self._fresh = True + #: True inside `program_change`, which applies the macros one at a + #: time; the node is refreshed once, after the last. + self._deferred = False + self._feedback = 0.0 + self._damping = 0.0 + self._corner = 0.0 + self._swing_ms = 0.0 + self._spread = 0.0 + time_ms = float(time_ms) + if not time_ms > 0.0: + time_ms = TIME_MIN_MS + #: The constructor's Time, exactly, until Time is moved: the log + #: map's round trip can move a value by a last bit, which is enough + #: to land a Time that sits on a half frame (150 ms at 22.05 kHz, + #: 3307.5 frames) on the frame below. + self._time_exact = time_ms + self._time_seed = 0.0 + self._seeding = True + mod_rate_hz = float(mod_rate_hz) + if not mod_rate_hz > 0.0: + mod_rate_hz = RATE_MIN_HZ + #: Written once; a Modulation move changes the depth, not the table. + self._table = triangle_table() + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=max_time_ms + LINE_HEADROOM_MS, + delay_ms=self._clamp_ms(time_ms), + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + wow_hz=0.0, + wow_depth_ms=0.0, + wow_shape=self._table, + delay_slew=0.0) + # `clear()` empties the line and the loop filter and re-primes the + # read head, so a reset is silent and snaps onto patch 0's Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + self._deferred = True + try: + self._init_macros((time_ms, feedback, mix, modulation_ms, + mod_rate_hz, spread, 1.0 if sync else 0.0, + float(division))) + finally: + self._deferred = False + self._seeding = False + self._refresh() + # Read after the refresh, which re-seats a Time above `max_time_ms`. + self._time_seed = self._macros[TIME_I] + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _clamp_ms(self, time_ms): + return _between(time_ms, TIME_MIN_MS, self._max_time_ms) + + def _corner_for(self, time_ms): + """The corner law for this character at `time_ms`, before the clamp. + A hook, so a planted fault can run it on another stage count.""" + return corner_hz(self._stages, time_ms) + + def _node_time_ms(self, frames): + """What the node is handed for whole frame `frames`.""" + return hand_off_ms(frames, self._sample_rate) + + def _walk_rate(self, from_frames, to_frames): + """The node's `delay_slew` for a Time move: the clock's law, floored + at two single-precision steps of the furthest the head may sit, so + a move too small for the law's rate still lands.""" + return max(clock_slew(from_frames, to_frames), + walk_floor(max(from_frames, to_frames, self._reach))) + + def _transport_state(self): + transport = self._transport + state = transport() if callable(transport) else transport + return transport, state + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one), where Time stays where the knob is.""" + transport, state = self._transport_state() + if transport is _component.static_transport: + return None + # A host whose tempo is not a finite positive number (0, None, a + # negative, NaN or infinity) leaves Time on the knob, as the static + # transport does. `not bpm > 0` catches NaN, and `bpm * 0` is NaN + # for infinity. + bpm = float(state[2] or 0.0) + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + # -- applying ------------------------------------------------------ + + def _time_ms(self): + """The Time the audio path plays, before it is clamped and landed.""" + if self._time_exact is not None: + return self._time_exact + return self._value(TIME_I) + + def _apply_macro(self, index, position): + if index == TIME_I and not self._seeding and not ( + self._time_exact is not None + and abs(position - self._time_seed) <= ECHO_TOLERANCE): + # A host that reads Time back and writes the same position keeps + # the constructor's exact Time; any other move drops it. + self._time_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh once, so Time is read + against the new patch's Sync and moves (and walks) once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + # The cleared node snapped onto patch 0's Time. + self._fresh = False + + def _refresh(self): + fs = self._sample_rate + span = self._MACRO_RANGES[TIME_I] + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + # Division quantises Time into the same map, and the clamp + # shows through get_macro(0) the same way. + self._time_exact = None + self._macros[TIME_I] = _component.macro_position( + span, self._clamp_ms(synced)) + time_ms = self._time_ms() + clamped = self._clamp_ms(time_ms) + if clamped != time_ms: + if self._time_exact is not None: + self._time_exact = clamped + self._macros[TIME_I] = _component.macro_position(span, clamped) + frames = max(1, whole_frames(clamped, fs)) + if self._fresh: + # A fresh node snaps onto the delay on its first pull; there is + # no walk to set a rate for. + self._slew = 0.0 + self._reach = frames + elif frames != self._frames: + # A clock step: the walk's rate from the Time last handed. + self._slew = self._walk_rate(self._frames, frames) + if frames > self._reach: + self._reach = frames + self._frames = frames + self._node_ms = self._node_time_ms(frames) + + # The corner law on the knob's milliseconds, clamped below Nyquist + # and pre-warped so the one-pole's -3 dB point is the corner. + self._corner = self._hz(self._corner_for(clamped)) + self._damping = nominal_damping_hz(self._corner, fs) + + # Handed as set: since audiodsp v0.6.3rc1 the node lands a loop + # low-pass that has stopped moving (#157), and since v0.6.3rc3 a + # stereo cross-feed sum too (#170), so nothing is stepped clear. + self._feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) + + self._swing_ms = _between(self._value(MODULATION_I), 0.0, + SWING_MAX_MS) + # At one channel the node's cross-feed sends the repeat nowhere. + if self._channel_count == 1: + self._spread = 0.0 + else: + self._spread = _between(self._value(SPREAD_I), 0.0, 1.0) + self._delay.set( + delay_slew=self._slew, + delay_ms=self._node_ms, + feedback=self._feedback, + mix=_between(self._value(MIX_I), 0.0, 2.0), + damping_hz=self._damping, + cut_hz=0.0, + cross_feed=self._spread, + wow_hz=_between(self._value(RATE_I), RATE_MIN_HZ, RATE_MAX_HZ), + wow_depth_ms=self._swing_ms) + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound: `laps_to_zero(f, excess)` laps of the longest delay + the read head may be at, plus the modulation's peak swing in frames + rounded up, plus one frame for the interpolated read, plus the loop + low-pass's memory. Finite at every setting the class reaches.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`.""" + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + swing = int(math.ceil(self._swing_ms * self._sample_rate / 1000.0)) + return int(laps * (self._reach + swing + 1 + memory)) diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py new file mode 100644 index 0000000..0d48b69 --- /dev/null +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -0,0 +1,570 @@ +"""`ConvolutionReverb` - a room made by convolution, synthesized or loaded. + +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/ConvolutionReverb.md`). +""" + +VENDOR = "PyDevices" + +from . import _component + +try: + import audioconvolve +except ImportError: # pragma: no cover - a stock board + audioconvolve = None + + +#: One partition, in frames; the node's `FRAMES`. +PARTITION = 256 + +#: The node's ceiling in partitions (`audiodsp_convolve.h:58-60`), and in +#: taps. It is the same count of taps at every rate. +MAX_PARTITIONS = 512 +CEILING_TAPS = MAX_PARTITIONS * PARTITION + +#: The shortest allocation the class builds: under it Decay, which runs +#: from the node's 50 ms floor, would have no travel. +FLOOR_SECONDS = 0.06 + +#: The node's shortest decay (`audiodsp_convolve.c:183` at 0d35a90) and its +#: clamps on predelay and diffusion (`:184`, `:185`), in seconds and milliseconds. +NODE_DECAY_FLOOR = 0.05 +PREDELAY_MAX_MS = 200.0 +DIFFUSION_MAX_MS = 500.0 + +#: Damping's span, log, and the clamp as a fraction of the rate: just under +#: fs / (2 pi), where the node's `exp_small` stops moving the coefficient. +DAMPING_LOW_HZ = 500.0 +DAMPING_HIGH_HZ = 7500.0 +DAMPING_CLAMP = 0.159 + +#: `ir_gain_db` and `start_ms` spans, measured mode. +IR_GAIN_MIN_DB = -24.0 +IR_GAIN_MAX_DB = 12.0 +START_MAX_MS = 200.0 + +#: Room seeds: 1 + round(position * 63), 64 rooms. +ROOMS = 64 + +DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I, MIX_I = range(6) +SYNTHESIS_MACROS = (DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I) + + +def _floor3(value): + """`value` floored to three decimal places, so the number a message + names is one that builds (2.730 s is 131 040 taps; 2.731 s raises).""" + return int(value * 1000.0) / 1000.0 + + +def _u16(data, at): + return data[at] | (data[at + 1] << 8) + + +def _u32(data, at): + return _u16(data, at) | (_u16(data, at + 2) << 16) + + +def read_wav(path, sample_rate): + """`(data, channels)` from a 16-bit PCM WAV at `sample_rate`. + + The file is read once; `data` is the `data` chunk's bytes. A WAV at + another rate raises naming both rates: an effect does not resample. + """ + with open(path, "rb") as handle: + head = handle.read(12) + if len(head) < 12 or head[0:4] != b"RIFF" or head[8:12] != b"WAVE": + raise ValueError("ConvolutionReverb: %r is not a RIFF WAVE file" + % (path,)) + channels = None + while True: + chunk = handle.read(8) + if len(chunk) < 8: + break + size = _u32(chunk, 4) + name = chunk[0:4] + if name == b"fmt ": + body = handle.read(size) + if size & 1: + handle.read(1) + if len(body) < 16: + raise ValueError("ConvolutionReverb: %r has a short fmt " + "chunk" % (path,)) + tag = _u16(body, 0) + channels = _u16(body, 2) + rate = _u32(body, 4) + bits = _u16(body, 14) + if tag not in (1, 0xFFFE) or bits != 16: + raise ValueError( + "ConvolutionReverb: %r is not 16-bit PCM (format %d, " + "%d bits)" % (path, tag, bits)) + if channels not in (1, 2): + raise ValueError( + "ConvolutionReverb: %r has %d channels; an impulse " + "is mono or stereo" % (path, channels)) + if rate != sample_rate: + raise ValueError( + "ConvolutionReverb: %r is a %d Hz impulse and the " + "graph runs at %d Hz; an effect does not resample" + % (path, rate, sample_rate)) + elif name == b"data": + if channels is None: + raise ValueError("ConvolutionReverb: %r has its data " + "before its fmt chunk" % (path,)) + return handle.read(size), channels + else: + handle.seek(size + (size & 1), 1) + raise ValueError("ConvolutionReverb: %r has no data chunk" % (path,)) + + +def _byte_width(impulse, view): + """Bytes per item of `view`: 1 for bytes-likes, 2 for an int16 array.""" + width = getattr(view, "itemsize", None) + if width is None: + typecode = getattr(impulse, "typecode", None) + width = 2 if typecode in ("h", "H") else 1 + if width not in (1, 2): + raise TypeError("ConvolutionReverb: impulse must be bytes-like int16 " + "frames, or an int16 array") + return width + + +def _sum_squares(view, width, frames, channels, lane): + """sum_k (h[k, lane] / 32768)^2 over `frames` frames of `view`. + + Every term is an exact binary fraction and, on a desktop, so is the + sum, so every interpreter computes the same number from the same file. + """ + total = 0.0 + if width == 2: + for index in range(lane, frames * channels, channels): + value = view[index] + if value >= 32768: + value -= 65536 + value = value / 32768.0 + total += value * value + return total + step = 2 * channels + for index in range(2 * lane, frames * step, step): + value = view[index] | (view[index + 1] << 8) + if value >= 32768: + value -= 65536 + value = value / 32768.0 + total += value * value + return total + + +class ConvolutionReverb(_component.Component): + """A room behind your dry signal, made by convolution: synthesized from + five knobs, or the room your own impulse was recorded in. + + With nothing loaded the class synthesizes a short room from noise. + By default the room is 0.08 s long. + + **The controls.** Decay is how long the room rings, Damping darkens its + tail, Predelay puts silence between the dry and the room, and Diffusion + fades the room in instead of starting it as a burst. + Room picks one of 64 rooms of the same size. + Damping runs from 500 Hz at its bottom stop to out at its top stop. + At 22.05 kHz its brightest positions below the top stop clamp and all + make the same room. + Mix runs from 0 to 2: the dry at unity up to 1, the room alone at 2. + With Damping out, each of the 64 Rooms falls 60 dB within 3 % of the + Decay time. + With Damping in, the 64 Rooms fall 60 dB within 2 % of the Decay time on + average. + A single Room with Damping in can take more than 15 % longer. + At Damping's 500 Hz stop, low material comes back louder than it went + in. + Each side of a stereo room is normalised on its own, so the room sits in + the middle. + + **Your own impulse.** Hand it `impulse=`, int16 frames or the path to a + 16-bit PCM WAV at the graph's rate, and the room is that recording. + The room is then your source convolved with the impulse at unit + energy, within 1 LSB. + `ir_gain_db` trims it from -24 to +12 dB, and `start_ms` cuts up to + 200 ms from its start. + Only Mix is live then: the other five knobs raise `IndexError`. + A WAV at another rate raises `ValueError`, and so does an impulse with + no energy or a `start_ms` that trims away every frame. + An empty impulse, `impulse=b""`, is an undelayed wire whose Mix does + nothing and whose `reset()` silences nothing. + + **The allocation.** `seconds` is the longest room the instance can hold, + carved once when you build it: from 0.06 s up to 131 072 frames, and + outside that the constructor raises `ValueError`. + + **Latency and tail.** `latency_samples` reads 256 while an impulse is + loaded and 0 on the empty impulse. + Held at Mix 0, the output is your source, byte for byte, + `latency_samples` late, while the source keeps feeding it and nothing + resets it. + `tail_samples` is `latency_samples` plus the loaded room rounded up to + a whole block of 256 frames. + More than `tail_samples` frames after your input's last non-zero frame, + the output is exact zero. + + **Moving the knobs.** No frame of your dry signal drops or repeats when + you move a room knob, at any Mix, however many moves you make. + From the end of the block in flight, the output is that of an instance + that always had the new settings. + A Mix move acts from the end of the block in flight, so Mix 0 reaches + the plain source up to 256 frames late. + `reset()` empties the room and returns to patch 0. + With an impulse loaded, `reset()` in the middle of a stream silences the + block in flight, 256 frames, dry included, and with Mix set back to 0 + your source carries on on time after it. + A host that calls `audiocore.reset_buffer` on the output silences the + block in flight too, but also drops the frames the node holds from a + source buffer it had not finished. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + The tail rings only while the source keeps feeding: feed silence to let + it ring out. A tail cut short by a source that stopped carries on when + the source comes back. + """ + + NAME = 'ConvolutionReverb' + DISPLAY_NAME = 'Convolution Reverb' + CATEGORIES = ('Reverb',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioconvolve",) + + CAPABILITIES = () + #: One partition, the loaded state. An instance reports what its node + #: holds (`latency_samples` below): 0 on an empty impulse. + LATENCY_SAMPLES = PARTITION + TAIL_SAMPLES = None + + MACRO_LABELS = ("Decay", "Damping", "Predelay", "Diffusion", "Room", + "Mix") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + } + _MACRO_RANGES = ( + (0.0, 1.0), # 0 Decay, law position + (DAMPING_LOW_HZ, DAMPING_HIGH_HZ, "log"), # 1 Damping; top = out + (0.0, 1.0), # 2 Predelay, position + (0.0, 1.0), # 3 Diffusion, position + (1.0, float(ROOMS)), # 4 Room, seed 1..64 + (0.0, 2.0), # 5 Mix; dry unity to 1 + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Full Room", (127, 117, 0, 64, 0, 38)), + 1: ("Short Room", (38, 98, 13, 64, 4, 44)), + 2: ("Late Room", (127, 124, 76, 64, 12, 51)), + 3: ("Dark Room", (102, 52, 25, 64, 20, 44)), + 4: ("Bright Room", (102, 127, 0, 32, 8, 51)), + 5: ("Soft Onset", (127, 108, 38, 127, 24, 63)), + 6: ("Tight Room", (0, 124, 25, 0, 2, 76)), + 7: ("Wet Only", (127, 117, 0, 64, 0, 127)), + } + + def _build(self, decay=1.0, damping_hz=6000.0, predelay=0.0, + diffusion=0.5, room=1, mix=0.6, seconds=0.08, impulse=None, + impulse_channels=1, ir_gain_db=0.0, start_ms=0.0, + patch=None): + #: True while several macros are applied at once (the constructor, + #: `program_change`, `reset`); the room is synthesized once, after. + self._deferred = True + #: The synthesis tuple the node holds, or None. + self._loaded = None + self._measured = impulse is not None + rate = self._sample_rate + if self._measured: + self._node = self._load_impulse(impulse, impulse_channels, + ir_gain_db, start_ms) + else: + seconds = float(seconds) + taps = int(round(seconds * rate)) + self._check_allocation(taps, seconds) + self._seconds = seconds + self._node = audioconvolve.Convolver( + max_taps=self._partitions(taps) * PARTITION, + ir_channels=self._channel_count, + sample_rate=rate, + channel_count=self._channel_count) + # `clear()` drops the history and the block in flight and keeps the + # impulse (`Convolver.c:230` at 0d35a90): a reset empties the room, + # it does not rebuild it. + self._own(self._node, reset=self._node.clear) + self._node.play(self._source) + self._output = self._node + damping_hz = float(damping_hz) + if not 0.0 < damping_hz < DAMPING_HIGH_HZ: + damping_hz = DAMPING_HIGH_HZ # 0 (or the top) is out + try: + self._init_macros((decay, damping_hz, predelay, diffusion, + room, mix), patch) + finally: + self._deferred = False + self._refresh() + + # -- the allocation (D2) ------------------------------------------- + + def _partitions(self, taps): + """Partitions for `taps`: the law, and never fewer than one.""" + return max(1, (taps + PARTITION - 1) // PARTITION) + + def _check_allocation(self, taps, seconds=None): + """Raise, naming the class and the limit, over the ceiling or (in + synthesized mode) under the floor. The node's own `impulse is too + long` never reaches the caller.""" + rate = self._sample_rate + if taps > CEILING_TAPS: + raise ValueError( + "%s: %d taps at %d Hz is over the ceiling of %d taps (%d " + "partitions), %.3f s at this rate" + % (self.NAME, taps, rate, CEILING_TAPS, MAX_PARTITIONS, + _floor3(CEILING_TAPS / float(rate)))) + if seconds is not None and not seconds >= FLOOR_SECONDS: + raise ValueError( + "%s: seconds=%r is under the floor of %.3f s, where Decay " + "would have no travel" % (self.NAME, seconds, FLOOR_SECONDS)) + + # -- measured mode (D1) -------------------------------------------- + + def _trim_frames(self, start_ms): + """Frames `start_ms` trims: int(start_ms * fs / 1000), truncated, + with the product formed first so a whole-ms trim is exact.""" + return int(start_ms * self._sample_rate / 1000.0) + + def _load_impulse(self, impulse, impulse_channels, ir_gain_db, + start_ms): + rate = self._sample_rate + if isinstance(impulse, str): + impulse, impulse_channels = read_wav(impulse, rate) + if impulse_channels not in (1, 2): + raise ValueError("%s: impulse_channels must be 1 or 2" + % self.NAME) + ir_gain_db = float(ir_gain_db) + if not IR_GAIN_MIN_DB <= ir_gain_db <= IR_GAIN_MAX_DB: + raise ValueError("%s: ir_gain_db=%r is outside %.0f..%+.0f dB" + % (self.NAME, ir_gain_db, IR_GAIN_MIN_DB, + IR_GAIN_MAX_DB)) + start_ms = float(start_ms) + if not 0.0 <= start_ms <= START_MAX_MS: + raise ValueError("%s: start_ms=%r is outside 0..%.0f ms" + % (self.NAME, start_ms, START_MAX_MS)) + view = memoryview(impulse) + if getattr(view, "ndim", 1) != 1: + # A (frames, channels) array would otherwise fail at the trim's + # slice with Python's bare NotImplementedError. + raise TypeError("%s: impulse must be one-dimensional int16 " + "frames; flatten a (frames, channels) array " + "first" % self.NAME) + width = _byte_width(impulse, view) + size = len(view) * width + if size % (2 * impulse_channels): + raise ValueError("%s: impulse length must be whole int16 frames" + % self.NAME) + frames = size // (2 * impulse_channels) + trim = self._trim_frames(start_ms) + if frames and trim >= frames: + # Clamping would build the unloaded wire, a Mix that does + # nothing, with no error (ruling (o)). Only `impulse=b""` is + # the deliberate empty room. + raise ValueError( + "%s: start_ms=%r trims %d frames at %d Hz and the impulse " + "has %d; the trim leaves no room, and a Mix that does " + "nothing" % (self.NAME, start_ms, trim, rate, frames)) + if trim > frames: + trim = frames + frames -= trim + per_frame = impulse_channels * (2 // width) + view = view[trim * per_frame:] + self._check_allocation(frames) + self._seconds = frames / float(rate) + room_channels = min(impulse_channels, self._channel_count) + node = audioconvolve.Convolver( + max_taps=self._partitions(frames) * PARTITION, + ir_channels=room_channels, + sample_rate=rate, + channel_count=self._channel_count) + if frames: + energy = 0.0 + for lane in range(room_channels): + energy += _sum_squares(view, width, frames, + impulse_channels, lane) + if not energy > 0.0: + node.deinit() + raise ValueError( + "%s: the impulse has %d frames and no energy; a silent " + "room is a dead Mix" % (self.NAME, frames)) + gain = (10.0 ** (ir_gain_db / 20.0) + / (energy / room_channels) ** 0.5) + node.load(view, impulse_channels, gain) + return node + + # -- the laws (section 6) ------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _predelay_ms(self): + span = (self._seconds - NODE_DECAY_FLOOR) * 1000.0 / 2.0 + if span > PREDELAY_MAX_MS: + span = PREDELAY_MAX_MS + return self._macros[PREDELAY_I] * span + + def _decay_seconds(self, predelay_ms): + room = self._seconds - predelay_ms / 1000.0 + return NODE_DECAY_FLOOR * (room / NODE_DECAY_FLOOR) ** \ + self._macros[DECAY_I] + + def _damping_hz(self): + position = self._macros[DAMPING_I] + if position >= 1.0: + return 0.0 + hz = _component.macro_value(self._MACRO_RANGES[DAMPING_I], position) + ceiling = DAMPING_CLAMP * self._sample_rate + if hz > ceiling: + hz = ceiling + return self._hz(hz) + + def _seed(self): + return 1 + int(round(self._macros[ROOM_I] * (ROOMS - 1))) + + def _synthesis(self): + """(decay s, damping Hz, predelay ms, diffusion ms, seed): what the + node is handed for the current positions.""" + predelay = self._predelay_ms() + t60 = self._decay_seconds(predelay) + span = t60 * 1000.0 / 4.0 + if span > DIFFUSION_MAX_MS: + span = DIFFUSION_MAX_MS + return (t60, self._damping_hz(), predelay, + self._macros[DIFFUSION_I] * span, self._seed()) + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del position + if self._deferred: + return + if index == MIX_I: + self._node.set(mix=self._value(MIX_I) * 0.5) + return + self._refresh() + + def _refresh(self): + """Hand the node the room and the Mix. The room is re-synthesized + only when its tuple differs from the one it holds.""" + if not self._measured: + room = self._synthesis() + if room != self._loaded: + self._node.synthesize(decay=room[0], damping_hz=room[1], + predelay_ms=room[2], + diffusion_ms=room[3], seed=room[4]) + self._loaded = room + # The node takes 0..1 and doubles it (`audiodsp_convolve.c:88`); + # halving is a power of two, so it adds no rounding of its own. + self._node.set(mix=self._value(MIX_I) * 0.5) + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then synthesize once. In measured + mode only its Mix is audible; the synthesis positions are stored + and inert. Inside the constructor the one synthesis is the + constructor's own.""" + outer = self._deferred + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = outer + if not outer and type(self).PATCHES.get(index) is not None: + self._refresh() + + def _macro_index(self, index): + index = _component.Component._macro_index(self, index) + if self._measured and index != MIX_I: + raise IndexError( + "%s holds a measured impulse, so macro %d %r is not live: the " + "loaded impulse is the room, and only Mix (macro %d) is" + % (self.NAME, index, self.MACRO_LABELS[index], MIX_I)) + return index + + # -- the reads ----------------------------------------------------- + + @property + def node(self): + """The one `audioconvolve.Convolver` this class built.""" + self._check_live() + return self._node + + @property + def measured(self): + """True when an impulse was given: the room is that impulse.""" + self._check_live() + return self._measured + + @property + def live_macros(self): + """The macro indexes this instance has: all six synthesized, only + Mix (5) in measured mode.""" + self._check_live() + if self._measured: + return (MIX_I,) + return (DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I, MIX_I) + + @property + def seconds(self): + """The allocation asked for; in measured mode, the trimmed + impulse's own length.""" + self._check_live() + return self._seconds + + @property + def allocated_seconds(self): + """The allocation actually held: partitions x 256 / fs.""" + self._check_live() + taps = self._partitions(int(round(self._seconds + * self._sample_rate))) + return taps * PARTITION / float(self._sample_rate) + + @property + def decay_seconds(self): + """The room's T60 by the Decay law, or None in measured mode.""" + self._check_live() + if self._measured: + return None + return self._synthesis()[0] + + @property + def latency_samples(self): + """256 whenever the node holds an impulse, 0 when it holds none, + read from the node.""" + self._check_live() + return self._latency() + + def _latency(self): + """`latency_samples` as a plain method, so a subclass can reach it + on MicroPython, whose `property` has no `fget`.""" + return int(self._node.latency) + + @property + def tail_samples(self): + """`latency_samples` plus the loaded impulse rounded up to a + partition; 0 when nothing is loaded.""" + self._check_live() + taps = int(self._node.taps) + if not taps: + return 0 + return self._latency() + taps diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py new file mode 100644 index 0000000..b9287e7 --- /dev/null +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -0,0 +1,624 @@ +"""`DigitalDelay` - a clean digital delay with the Boss DD-2's control law. + +Your dry signal passes untouched, and one clean repeat follows it, fed back +for more. + +**Controls.** Time is the delay, from 12.5 to 800 ms, and Feedback is how +much of each repeat goes round again, up to 0.99. Mix is the echo level: the +dry stays at unity up to Mix 1, Mix 2 is the repeats alone, and at Mix 0 the +output is the input. Turn Time while it plays and the repeats bend in pitch +and settle, instead of clicking. Glide is how long a full-range Time move +takes, from 800 ms to 8 s. Glide 0 is an instant knob, and its price is a +click. Repeat Tone is a low-pass and Repeat Cut a high-pass inside the loop, +so each repeat is a little darker or thinner than the last. Repeat Tone's +top stop and Repeat Cut's bottom stop take them out. With Sync on, Time is +Division of the host's beat, up to 800 ms; with no host tempo, Time stays +where the knob is. The class reads the tempo only when a control moves or a +patch loads, so after a tempo change Time keeps the old beat until you move +a control. + +**The pedal.** Patch 5 puts the DD-2's 7 kHz and 40 Hz corners in the +loop. The DD-2's compander and its HOLD are not here. + +**Where it stops.** At 48 kHz the repeats of a Time you have stopped +turning do not darken. At 44.1 and 22.05 kHz a few Times land a hair off +the whole frame, and at those each repeat spills a little onto the frame +beside it. A rising Time move at the fastest Glides can read more than 10 +cents off the ideal bend, because the node walks its read head in single +precision. At 22.05 kHz the top positions of Repeat Tone sit on one clamp +below Nyquist and sound the same. The dry sits at unity and the repeats add +to it, so a hot input can reach the int16 rail. With Repeat Cut out and Mix +below 1, an input that peaks at or below floor(32767 (1 - Mix)) - 1 cannot +reach the rail, at any Time or Feedback. + +**Limits shared by the family.** A control that jumps makes the output step: +move it in small steps from the host if you need it smooth. The tail rings +only while the source keeps feeding: feed silence to let it ring out. A tail +cut short by a source that stopped carries on when the source comes back. + +**Latency, tail, portability.** Latency is zero samples: nothing looks +ahead. `tail_samples` is an upper bound on how long the output takes to +reach exact zero once your input stops, at every Feedback, with Repeat Tone +in or out. With Repeat Cut in circuit, as at patch 5, `tail_samples` is +`None`: the class gives no bound there. Pass a lower `max_time_ms` for a +shorter line: Time then stops at that ceiling, and `get_macro(0)` shows +where it stopped. A constructor Time of 0 is the bottom of its span, and a +Repeat Tone or Repeat Cut of 0 is that filter out. `reset()` empties the +line and returns to patch 0. The class reads the host's transport only +while Sync is on. The class needs audiodsp's `audioecho`, and on a board +without it construction raises `ImportError`. +""" + +VENDOR = "PyDevices" + +import math + +from .. import _component +from ..chorus import nominal_damping_hz + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +#: The Time map, fixed on every instance (dossier T4): 12.5-800 ms, log. +TIME_MIN_MS = 12.5 +TIME_MAX_MS = 800.0 + +#: Glide is the time a full-range Time move takes; the node's `delay_slew` +#: (delay-seconds per second) is FULL_RANGE_MS / glide_ms (dossier section 6). +FULL_RANGE_MS = TIME_MAX_MS - TIME_MIN_MS + +#: The Glide knob's span, log, with grid 0 the jump. A constructor Glide +#: slower than the top is clamped to it, the way Time clamps at +#: `max_time_ms`, so `get_macro(3)` always names the Glide that plays. +GLIDE_MIN_MS = 800.0 +GLIDE_MAX_MS = 8000.0 + +#: At slew 1 a rising Time stands the read head still, and past it the line +#: plays backwards. A constructor Glide under 795.45 ms is pinned here; no +#: grid position gets there (grid 1 is slew 0.967). +SLEW_PIN = 0.99 + +#: The Glide knob's position for a constructor Glide faster than grid 1: +#: grid 1 itself (814.6 ms, slew 0.967), the knob's fastest walk. Grid 0 is +#: the jump, and a position just above it reads back as a MIDI value that +#: rounds to 0 on any 7-bit path (fix round 2). +GLIDE_FLOOR = 1.0 / 127.0 + +#: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). +FEEDBACK_MAX = 0.99 + +#: The line's headroom over `max_time_ms`: the node clamps a delay at +#: `line_frames - 2`, so a line of exactly `max_time_ms` could not reach it. +LINE_HEADROOM_MS = 1.0 + +#: The largest magnitude one line sample can hold (int16). +LINE_PEAK = 32768 + +#: `laps_to_zero` reckons with a Feedback this much larger, relatively, so +#: the node's single-precision feedback and product, and a board's +#: single-precision Python, can only make the bound longer, never shorter. +FEEDBACK_MARGIN = 2.0 ** -16 + +#: How far outside a Repeat Tone stall window `clear_of_stalls` puts the +#: Feedback it hands the node, relative to the window's edge: many times a +#: single-precision float's step (2^-24), so a board's arithmetic lands on +#: the same side, and far under the window's own width (2-4 x 10^-5) and +#: the knob's 7-bit step (0.0078). This class stopped stepping at audiodsp +#: v0.6.3rc1, whose node lands a stalled damping state (audiodsp#157). +STALL_CLEARANCE = 2.0 ** -20 + +#: `stall_window` widens each window by this much, relatively, either side, +#: so a Feedback on an edge that `laps_to_zero`'s own rounding puts inside +#: (a constructor value that comes back through the macro 10^-17 away) is +#: moved too. Four times under `STALL_CLEARANCE`, so a moved value is +#: never itself on the widened edge. +STALL_FUZZ = 2.0 ** -22 + +#: Division's sixteen note values, in quarter-note beats, rising: 1/32, +#: 1/16T, 1/32., 1/16, 1/8T, 1/16., 1/8, 1/4T, 1/8., 1/4, 1/2T, 1/4., 1/2, +#: 1/1T, 1/2., 1/1. +DIVISION_BEATS = (0.125, 1.0 / 6.0, 0.1875, 0.25, 1.0 / 3.0, 0.375, 0.5, + 2.0 / 3.0, 0.75, 1.0, 4.0 / 3.0, 1.5, 2.0, 8.0 / 3.0, 3.0, + 4.0) + +TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, SYNC_I, DIVISION_I, TONE_I, CUT_I = \ + range(8) + + +def nominal_cut_hz(corner_hz, sample_rate): + """`cut_hz` whose one-pole high-pass -3 dB is `corner_hz` at + `sample_rate`. 0 stays 0, which is the filter out of circuit. + + The node's high-pass is `y -= lp(y)` with the low-pass coefficient + a = 1 - exp(-2 pi hz / fs) (`audiodsp_feedback_delay.c:33-40`, + `:498-501` at v0.6.2), so with b = 1 - a it is + H = b (1 - z^-1) / (1 - b z^-1). + |H|^2 = 1/2 gives b^2 (3 - 4 cos w) + 2 b cos w - 1 = 0; the root in + (0, 1) is the pole, and `cut_hz = -fs ln b / 2 pi`. + """ + fs = float(sample_rate) + fc = float(corner_hz) + if fc <= 0.0: + return 0.0 + c = math.cos(2.0 * math.pi * fc / fs) + qa = 3.0 - 4.0 * c + qb = 2.0 * c + if abs(qa) < 1e-12: + roots = (1.0 / qb,) if qb != 0.0 else () + else: + disc = qb * qb + 4.0 * qa + if disc < 0.0: + disc = 0.0 + root = math.sqrt(disc) + roots = ((-qb + root) / (2.0 * qa), (-qb - root) / (2.0 * qa)) + pole = None + for candidate in roots: + if 0.0 < candidate < 1.0: + pole = candidate + if pole is None: + return fc + return -fs * math.log(pole) / (2.0 * math.pi) + + +def slew_of(glide_ms): + """The node's `delay_slew` for a Glide: 0 (the jump) at Glide 0, + otherwise 787.5 ms over `glide_ms`, pinned at 0.99.""" + glide_ms = float(glide_ms) + if glide_ms <= 0.0: + return 0.0 + slew = FULL_RANGE_MS / glide_ms + if slew > SLEW_PIN: + slew = SLEW_PIN + return slew + + +def whole_frames(time_ms, sample_rate): + """The nearest whole frame at the running rate (dossier section 6).""" + return int(math.floor(float(time_ms) * sample_rate / 1000.0 + 0.5)) + + +def laps_to_zero(feedback, excess=0.0): + """How many laps of the line can still hold a non-zero sample once the + input stops. Finite at every Feedback since audiodsp v0.6.3rc1. + + Once the input stops, the node writes `to_s16(recirculated(s, f))` for + each value s it sends round (`audiodsp_feedback_delay.c:529`). Since + audiodsp v0.6.2 (#154) `recirculated` (`:344`) rounds f s to nearest + where |s| - |f s| > 0.5 and truncates it toward zero otherwise. + + With Repeat Tone out (`excess` 0) s is a line sample, or a mix of two + at a fractional read, so |s| <= x, the line's peak, and the node + guarantees |write| < |s|: a lap maps x to at most + min(x - 1, floor(f x + 0.5)). Both halves only grow with x, so the peak + of one lap bounds every sample of the next, and iterating from full + scale counts the laps to exact zero. That is finite at every Feedback; + 0.99 takes 685 laps. + + With Repeat Tone in, s is the loop low-pass's state, which can sit + above the line's peak by up to `excess` times it (`DigitalDelay`'s + `_tone_excess`). Then a lap maps x to the node's write at + s = x (1 + excess), whichever branch it takes, and the guarantee is + gone: where that write is still x, the peak is handed back. Up to + audiodsp v0.6.2 it stayed there for ever (1 LSB at Feedback 0.5, 5 at + 0.9, Tone 800 Hz, on a DC input), and this count was `None` there. + From v0.6.3rc1 (audiodsp#157) the node sets a damping state that has + stopped moving onto its input, once a block, at or below 64 LSB, which + covers every such x (at most 50, at the node's 0.99). So the peak is + counted one more lap there and then leaves as with the filter out, + min(x - 1, floor(f x + 0.5)). The extra lap is measured, not derived + (the landing waits for a block's end, and a host picks the block): + at every window centre k = 1 ... 50, Repeat Tone grid 0, 64 and 126, + on a 2 LSB DC and on full scale, in blocks of 64 to 4 096 frames at + Time 12.5 ms, each tail ends inside the count with the extra lap left + out, 239 frames or more short of it (`pin063_stall_landing.py`). + """ + feedback = float(feedback) + if feedback < 0.0: + feedback = 0.0 + if feedback > FEEDBACK_MAX: + feedback = FEEDBACK_MAX + up = feedback * (1.0 + FEEDBACK_MARGIN) + low = feedback * (1.0 - FEEDBACK_MARGIN) + laps = 0 + peak = LINE_PEAK + while peak > 0: + laps += 1 + if excess <= 0.0: + image = int(math.floor(up * peak + 0.5)) + if image >= peak: + image = peak - 1 + else: + sent = peak * (1.0 + excess) + if sent * (1.0 - low) > 0.5: + image = int(math.floor(up * sent + 0.5)) + else: + image = int(math.floor(up * sent)) + if image >= peak: + # The node's landing (audiodsp#157): one more lap, then the + # peak leaves as it does with the filter out. + laps += 1 + image = int(math.floor(up * peak + 0.5)) + if image >= peak: + image = peak - 1 + peak = image + return laps + + +# `stall_window` and `clear_of_stalls` are no longer called by this class +# (the node lands a stalled damping state since audiodsp v0.6.3rc1, #157). +# Only planted faults use them: the faults in this class's test file and in +# other classes' test files (SlapbackDelay, PingPongDelay, CombFilter, +# TapeDelay, MultiTapDelay) that hand the node the old stepped Feedback. +def stall_window(feedback, excess): + """The Repeat Tone stall window `feedback` sits in, as (low, high), or + `None` outside every window. + + `laps_to_zero(f, excess)` takes its extra landing lap exactly when + some whole peak x it reaches is handed back: the rounding branch taken, + x (1 + excess)(1 - f (1 - m)) > 0.5, and the image not below x, + f (1 + m) x (1 + excess) + 0.5 >= x, m being `FEEDBACK_MARGIN`. For + each x that is one window, [(1 - 0.5 / x) / ((1 + m)(1 + excess)), + (1 - 0.5 / (x (1 + excess))) / (1 - m)), 2-4 x 10^-5 wide around + 1 - 0.5 / x; x = 1 ... 50 are the ones under the node's 0.99. Each is + returned widened by `STALL_FUZZ` either side. With Repeat Tone out + (`excess` 0) no lap hands a value back, and there is no window.""" + if excess <= 0.0 or feedback <= 0.0: + return None + grow = (1.0 + FEEDBACK_MARGIN) * (1.0 + excess) + centre = int(math.floor(0.5 / (1.0 - feedback) + 0.5)) if feedback < 1.0 \ + else 50 + for x in (centre - 1, centre, centre + 1): + if x < 1: + continue + low = (1.0 - 0.5 / x) / grow * (1.0 - STALL_FUZZ) + high = ((1.0 - 0.5 / (x * (1.0 + excess))) / (1.0 - FEEDBACK_MARGIN) + * (1.0 + STALL_FUZZ)) + if low <= feedback < high: + return low, high + return None + + +def clear_of_stalls(feedback, excess): + """`feedback` moved to the nearer edge of the Repeat Tone stall window + it sits in (`stall_window`), just outside it, or unchanged outside + every window. The top window's upper edge is above the node's 0.99, so + there it always moves down. Every move is under 3 x 10^-5 of Feedback + (2.6 x 10^-5 at the 0.99 stop), far inside one step of the 7-bit knob, + so `get_macro(1)` still names the setting that plays. + + Not called by this class since audiodsp v0.6.3rc1 (the node lands a + stalled damping state, audiodsp#157); only planted faults use it.""" + window = stall_window(feedback, excess) + if window is None: + return feedback + low, high = window + below = low * (1.0 - STALL_CLEARANCE) + above = high * (1.0 + STALL_CLEARANCE) + if above > FEEDBACK_MAX or feedback - below <= above - feedback: + return below + return above + + +class DigitalDelay(_component.Component): + """A clean digital delay with the DD-2's control law: the dry path is a + wire, and turning Time pitch-bends the repeats instead of clicking. + audiodsp tier; zero latency. The module docstring has the rest.""" + + NAME = 'DigitalDelay' + DISPLAY_NAME = 'Digital Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Feedback", "Mix", "Glide", "Sync", "Division", + "Repeat Tone", "Repeat Cut") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "TOGGLE", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (0.0, 2.0), # 2 Mix; dry at unity to 1 + (GLIDE_MIN_MS, GLIDE_MAX_MS, "log"), # 3 Glide, ms; grid 0 = jump + (0.0, 1.0), # 4 Sync + (0.0, 15.0), # 5 Division index + (800.0, 16000.0, "log"), # 6 Repeat Tone, Hz; top = out + (20.0, 400.0, "log"), # 7 Repeat Cut, Hz; bottom = out + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Clean Repeats", (102, 45, 19, 89, 0, 51, 127, 0)), + 1: ("Eighth Notes", (102, 51, 19, 89, 127, 51, 127, 0)), + 2: ("Dotted Eighths", (102, 58, 19, 89, 127, 68, 127, 0)), + 3: ("Short Doubling", (45, 0, 32, 89, 0, 51, 127, 0)), + 4: ("Long Ambient", (125, 90, 16, 89, 0, 51, 127, 0)), + 5: ("Band Limited Repeats", (102, 77, 19, 89, 0, 51, 92, 29)), + } + + def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, + sync=False, division=6, tone_hz=16000.0, cut_hz=20.0, + max_time_ms=TIME_MAX_MS, patch=None): + max_time_ms = float(max_time_ms) + # `not <=` catches NaN, which would otherwise pass both clamps and + # size the line from nothing. + if not max_time_ms <= TIME_MAX_MS: + max_time_ms = TIME_MAX_MS + if max_time_ms < TIME_MIN_MS: + max_time_ms = TIME_MIN_MS + self._max_time_ms = max_time_ms + self._frames = 1 + #: The longest delay, in whole frames, the read head may still sit + #: at. A Glide walk starts from wherever the head is and the class + #: cannot see how far it has got, so after a falling move this keeps + #: the old Time until a jump (Glide 0) or a clear lands the head. + self._reach = 1 + #: True while the node has been built or cleared and not yet told a + #: second Time: it snaps onto the configured delay on its first pull. + self._fresh = True + #: True inside `program_change`, which applies the macros one at a + #: time; the node is refreshed once, after the last. + self._deferred = False + self._feedback = 0.0 + self._damping = 0.0 + self._node_ms = 0.0 + # 0 (or less) is how the node spells a filter out of circuit, so + # Repeat Tone and Repeat Cut at 0 are their out stops; Time at 0 + # is the bottom of its span. Each is a value a log knob cannot seed. + time_ms = float(time_ms) + if not time_ms > 0.0: + time_ms = TIME_MIN_MS + tone_hz = float(tone_hz) + if not tone_hz > 0.0: + tone_hz = self._MACRO_RANGES[TONE_I][1] + cut_hz = float(cut_hz) + if not cut_hz > 0.0: + cut_hz = self._MACRO_RANGES[CUT_I][0] + #: A constructor Glide stays on the audio path until macro 3 moves: + #: the Glide span starts at 800 ms, so a faster constructor Glide + #: (down to the 0.99 pin) or an exact 0 has no knob position. A + #: slower one is clamped to the span's top, 8 s. + glide_ms = float(glide_ms) + if glide_ms > GLIDE_MAX_MS: + glide_ms = GLIDE_MAX_MS + if not glide_ms > 0.0: + # 0, a negative and NaN are the jump; a NaN slew would neither + # walk nor jump, and the Time knob would do nothing. + glide_ms = 0.0 + self._glide_exact = glide_ms + self._seeding = True + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=max_time_ms + LINE_HEADROOM_MS, + delay_ms=self._clamp_ms(time_ms), + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + delay_slew=0.0) + # `clear()` empties the line and the loop filters and re-primes the + # read head, so a reset is silent and snaps onto patch 0's Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + # The knob is seeded where the constructor's Glide is, or at grid 1 + # for a faster one; `_glide_exact` carries the real value. Position + # 0 is the jump, so a Glide that is not 0 is never seeded below grid + # 1, and a get_macro / set_macro round trip keeps it gliding even + # when a host rounds it to a MIDI value. + self._init_macros((time_ms, feedback, mix, + max(GLIDE_MIN_MS, glide_ms), + 1.0 if sync else 0.0, float(division), tone_hz, + cut_hz)) + if self._glide_exact > 0.0 and self._macros[GLIDE_I] < GLIDE_FLOOR: + self._macros[GLIDE_I] = GLIDE_FLOOR + self._seeding = False + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _time_map(self, position): + """Macro 0's position -> milliseconds; the same on every instance.""" + return _component.macro_value(self._MACRO_RANGES[TIME_I], position) + + def _clamp_ms(self, time_ms): + time_ms = float(time_ms) + if time_ms > self._max_time_ms: + return self._max_time_ms + if time_ms < TIME_MIN_MS: + return TIME_MIN_MS + return time_ms + + def _glide_ms(self): + if self._glide_exact is not None: + return self._glide_exact + if self._macros[GLIDE_I] <= 0.0: + return 0.0 + return self._value(GLIDE_I) + + def _tone_damping(self, position): + """Macro 6's position -> the node's `damping_hz`. The top stop is + exactly 0 (out of circuit) and is never pre-warped.""" + if position >= 1.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[TONE_I], position) + return nominal_damping_hz(self._hz(corner), self._sample_rate) + + def _cut_hz(self, position): + """Macro 7's position -> the node's `cut_hz`. The bottom stop is + exactly 0 (out of circuit).""" + if position <= 0.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[CUT_I], position) + return nominal_cut_hz(self._hz(corner), self._sample_rate) + + def _transport_state(self): + transport = self._transport + state = transport() if callable(transport) else transport + return transport, state + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one), where Time stays where the knob is.""" + transport, state = self._transport_state() + if transport is _component.static_transport: + return None + # A host whose tempo is not a finite positive number (0, None, a + # negative, NaN or infinity) leaves Time on the knob, as the static + # transport does; it is never read as 120 bpm. `not bpm > 0` catches + # NaN, and `bpm * 0` is NaN for infinity. + bpm = float(state[2] or 0.0) + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + def _node_time_ms(self, frames): + """What the node is handed for a whole-frame Time.""" + return frames * 1000.0 / self._sample_rate + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del position + if index == GLIDE_I and not self._seeding: + self._glide_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh once. The base applies + the macros in index order, so a refresh per macro would read Time + against the outgoing patch's Sync and, with a host transport, keep + its synced Time instead of the new patch's own.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + self._fresh = False + + def _refresh(self): + span = self._MACRO_RANGES[TIME_I] + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + # Division quantises Time into the same map, and the clamp + # shows through get_macro(0) the same way (dossier T4). + self._macros[TIME_I] = _component.macro_position( + span, self._clamp_ms(synced)) + time_ms = self._time_map(self._macros[TIME_I]) + clamped = self._clamp_ms(time_ms) + if clamped != time_ms: + self._macros[TIME_I] = _component.macro_position(span, clamped) + self._frames = max(1, whole_frames(clamped, self._sample_rate)) + self._node_ms = self._node_time_ms(self._frames) + slew = slew_of(self._glide_ms()) + if self._fresh or slew <= 0.0: + # A fresh node snaps onto the target, and with the slew off the + # read head jumps there on the next frame. + self._reach = self._frames + elif self._frames > self._reach: + self._reach = self._frames + feedback = self._value(FEEDBACK_I) + if feedback > FEEDBACK_MAX: + feedback = FEEDBACK_MAX + if feedback < 0.0: + feedback = 0.0 + # Both out stops are exactly 0. Since audiodsp v0.6.3rc1 the node + # keeps an out filter's state live (#158, #159), and lands a damping + # state that has stopped moving (#157), so nothing is moved here. + damping = self._tone_damping(self._macros[TONE_I]) + self._damping = damping + self._feedback = feedback + self._delay.set( + delay_slew=slew, + delay_ms=self._node_ms, + feedback=feedback, + mix=self._value(MIX_I), + damping_hz=damping, + cut_hz=self._cut_hz(self._macros[CUT_I])) + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound, or `None` where no bound is derived. + + `laps_to_zero(f)` laps, each at most one frame longer than the + longest delay the read head may be at (the read interpolates + towards the next older frame). While a Glide walk falls, that is + the Time it is walking from, not the target, and it stays so until + a Glide-0 move or a reset lands the head, because the class cannot + see how far the walk has got. + + Finite at every Feedback with Repeat Cut out. With Repeat Tone in, + each lap is `memory` frames longer, the time the low-pass takes to + forget the lap before, and near the Feedback values where the node + once held a small value for ever the count takes one more lap + (`laps_to_zero`). `None` with Repeat Cut in circuit: this class + derives no bound there. + """ + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget` and whose `super()` hands back the property itself.""" + if self._macros[CUT_I] > 0.0: + return None + memory, excess = self._tone_excess() + laps = laps_to_zero(self._feedback, excess) + return int(laps * (self._reach + 1 + memory)) + + def _tone_excess(self): + """(frames, relative excess) for Repeat Tone's low-pass. After + `frames` frames whatever the state held before, full scale at + most, weighs under 2^-17 LSB, which is under 2^-17 of any non-zero + peak; and the single-precision state can rest up to 2^-24 / a of + the peak above it, a being the coefficient, because a step + a (v - y) under half an ulp rounds away. Read from the `damping_hz` + the node was handed. (0, 0.0) with the filter out.""" + damping = self._damping + if damping <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping / self._sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient diff --git a/lib/audioeffects/rebuilt/flanger.py b/lib/audioeffects/rebuilt/flanger.py index e320b1a..49b2c1a 100644 --- a/lib/audioeffects/rebuilt/flanger.py +++ b/lib/audioeffects/rebuilt/flanger.py @@ -18,10 +18,17 @@ class in `modulation.py` is not consulted except for the eight defects **What the default gives up.** At the constructor default (Color 0.55, Filter Matrix off) the comb floor is about −22 dB, not the Color-0 -−37 dB null, and the −60 dB tail is about 0.1 s, not 2 s. Color 0→0.9 -peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, -Matrix on). Color max (0.99, 3 ms, Matrix on) is the 2 s ring on a -200–440 Hz burst; the default is not, and a click is not that bar. +−37 dB null, and the −60 dB tail is about 0.1 s, not Color max's ring. +Color 0→0.9 peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on noise +(held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to +−60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one +(1.91 s and 2.13 s, 20 ms RMS windows after a 50 ms burst at −6 dBFS, +48 kHz, audiodsp v0.6.2); the default does not, and a click is not that +bar. *Restated 2026-09-28:* this read "the 2 s ring on a 200–440 Hz +burst" until audiodsp v0.6.2, where the node stopped holding a few LSB +going round for ever (audiodsp#154) and the ring at 440 Hz fell from +2.13 s to 1.91 s. The sound is unchanged; the claim now says what the +node does. **Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`). `audioroute` is only built when Through Zero is on. On a stock @@ -74,7 +81,9 @@ class in `modulation.py` is not consulted except for the eight defects # 20 Hz yields Color-max audio t60 1.63 s; 1 Hz yields 2.02 s at 440 Hz # but a 200 Hz burst was 1.96 s (indep3). Color-max cut is 0.4 Hz so # the 2 s bar has margin across those bursts. Color 0 stays 20 Hz. -# Same one-pole, not a second node. +# Same one-pole, not a second node. (Those figures are from before +# audiodsp v0.6.2. There, at 0.4 Hz, the ring reads 1.91 s at 440 Hz and +# 2.13 s at 200 Hz, and F8 is restated to those values, 2026-09-28.) CUT_HZ = 20.0 CUT_HZ_COLOR_MAX = 0.4 # Color 0.9 as raw feedback misses ≥15 dB at 44.1 / 22.05 kHz on some @@ -147,10 +156,12 @@ class Flanger(_component.Component): What the default gives up. At the constructor default (Color 0.55, Filter Matrix off) the comb floor is about −22 dB, not the Color-0 - −37 dB null, and the −60 dB tail is about 0.1 s, not 2 s. Color 0→0.9 - peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, - Matrix on). Color max (0.99, 3 ms, Matrix on) is the 2 s ring on a - 200–440 Hz burst; the default is not, and a click is not that bar. + −37 dB null, and the −60 dB tail is about 0.1 s, not Color max's + ring. Color 0→0.9 peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on + noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings + to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a + 200 Hz one (restated 2026-09-28 at audiodsp v0.6.2); the default does + not, and a click is not that bar. """ NAME = 'Flanger' diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py new file mode 100644 index 0000000..74a797e --- /dev/null +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -0,0 +1,869 @@ +"""`MultiTapDelay` - one recording read by several heads on a fixed grid. + +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/MultiTapDelay.md`), and in the +docstrings of `_route`, `_wire` and `_resync` below. +""" + +VENDOR = "PyDevices" + +from array import array +import math +import struct + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's tail arithmetic, reused rather than copied: the lap node +# is the same node and rounds the same way. Its module moves up one level +# when it comes home, so both homes are tried. +try: + from .digitaldelay import laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import laps_to_zero, whole_frames + +import audiocore + +try: + import audiodelays + import audiomixer +except ImportError: # pragma: no cover - a bare build + audiodelays = None + audiomixer = None + +try: + import audioecho + import audioroute +except ImportError: # pragma: no cover - a stock board + audioecho = None + audioroute = None + + +#: Section 6's spans, fixed on every instance. +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 400.0 +HEADS_MIN = 3 +HEADS_MAX = 8 +FEEDBACK_MAX = 0.95 +TONE_MIN_HZ = 800.0 +TONE_MAX_HZ = 16000.0 + +#: Tilt's span in dB from head 1 to head K at |Tilt| = 1 (ours). +TILT_DB = 12.0 + +#: `max_lap_ms`: 400 ms x four heads by default, and never under 540 ms, +#: where every Heads keeps Time alive and its span at 3.33 : 1 or more. +MAX_LAP_MS = 1600.0 +MIN_LAP_MS = 540.0 + +#: The block every node renders, in frames. +BLOCK = 256 + +#: How far the tap node's own timeline runs behind the dry, in frames, and +#: so how much shorter than k n1 each head's offset is handed (`_route`). +#: Every offset k n1 - LAG must be at least one frame: an offset of 0 reads +#: the line a whole lap back, and a negative one is a position the node +#: refuses. The shortest head is Time's 20 ms, and the lap clamp never +#: lands n1 below it, so that holds exactly where 20 ms lands on more than +#: LAG frames: at MIN_SAMPLE_RATE and above (257 frames at 12 825 Hz, 441 +#: at 22.05 kHz). The class refuses a lower rate at construction. +LAG = BLOCK + +#: The lowest sample rate the class accepts: the least whole rate at which +#: Time's 20 ms lands on more than LAG frames, floor(20 fs / 1000 + 0.5). +MIN_SAMPLE_RATE = 12825 + +#: S1's `Head Combinations for Each Mode`, modes 1-11. Mode 12 is every +#: head on the plain grid (section 8.3), so it is not in the table. +HEAD_SETS = ((1,), (2,), (3,), (1, 2), (2, 3), (1, 3), (1, 2, 3), (1, 4), + (3, 4), (1, 3, 4), (1, 2, 4)) + +#: Division's sixteen note values, in quarter-note beats, rising: 1/32, +#: 1/16T, 1/32., 1/16, 1/8T, 1/16., 1/8, 1/4T, 1/8., 1/4, 1/2T, 1/4., 1/2, +#: 1/1T, 1/2., 1/1 (`DigitalDelay`'s). +DIVISION_BEATS = (0.125, 1.0 / 6.0, 0.1875, 0.25, 1.0 / 3.0, 0.375, 0.5, + 2.0 / 3.0, 0.75, 1.0, 4.0 / 3.0, 1.5, 2.0, 8.0 / 3.0, 3.0, + 4.0) + +#: The tap node truncates every sample it sends round toward zero; the +#: tail bound reckons with a decay this much larger, relatively, so a +#: board's single-precision arithmetic can only lengthen it. +DECAY_MARGIN = 2.0 ** -16 + +(TIME_I, PATTERN_I, HEADS_I, FEEDBACK_I, MIX_I, TILT_I, TONE_I, SYNC_I, + DIVISION_I) = range(9) + + +def f32(value): + """`value` rounded to single precision: exact emulation on a desktop, + the identity on a board, whose float already is one.""" + return struct.unpack(" lap: + lower = _f32_step(value, False) + if lap_node_frames(lower, sample_rate) < lap: + break + value = lower + frames = lap_node_frames(value, sample_rate) + return value + + +def head_set(mode, heads, table=HEAD_SETS): + """Pattern mode 1-12 on a grid of `heads` heads -> the heads that + sound, rising. Mode 12 is every head 1 ... K.""" + if mode >= 12: + return tuple(range(1, heads + 1)) + return tuple(k for k in table[mode - 1] if k <= heads) + + +def head_levels(selected, heads, tilt): + """Tilt's law: a straight line in dB across the grid, `TILT_DB` x Tilt + from head 1 to head K, far heads louder for Tilt > 0, with the loudest + sounding head at exactly 1.0.""" + db = [TILT_DB * tilt * (k - 1) / (heads - 1) for k in selected] + top = max(db) + return tuple(1.0 if value == top else 10.0 ** ((value - top) / 20.0) + for value in db) + + +def landed(time_ms, heads, sample_rate, max_lap_ms): + """(n1, P) in frames: the base on the nearest whole frame, clamped so + the lap P = K n1 fits `max_lap_ms`.""" + n1 = max(1, whole_frames(time_ms, sample_rate)) + cap = int(math.floor(max_lap_ms * sample_rate / 1000.0)) // heads + if n1 > cap: + n1 = cap + return n1, heads * n1 + + +def trunc_laps(decay): + """Laps of the tap node's own loop to exact zero from full scale. Once + the input stops it writes `(int32_t)(delayed * decay)` + (`audiodsp_multitap.c:32` at v0.6.3rc1), which truncates toward zero, so a peak x + goes to floor(x d) < x and the count is finite at every decay.""" + decay = min(max(float(decay), 0.0), 1.0) * (1.0 + DECAY_MARGIN) + laps = 0 + peak = 32768 + while peak > 0: + laps += 1 + image = int(math.floor(decay * peak)) + if image >= peak: + image = peak - 1 + peak = image + return laps + + +def tone_excess(damping_hz, sample_rate): + """`DigitalDelay._tone_excess` for a pre-warped `damping_hz`: (memory + frames, relative excess) of the lap node's loop low-pass, (0, 0.0) + with it out.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + + +def _number(value, default): + """`value` as a float, or `default` for NaN or something that is not a + number.""" + try: + value = float(value) + except (TypeError, ValueError): + return float(default) + if value != value: + return float(default) + return value + + +class MultiTapDelay(_component.Component): + """A multi-head echo: one recording read by several heads on a grid. + + The heads of a multi-head tape echo, after the Roland Space Echo's + multi-head modes: your dry signal passes untouched, and a pattern of + echoes follows it. + + **The controls.** + Time is the gap to head 1, landed on a whole frame, and head k sounds at + exactly k times that gap. + Heads is how many heads the grid has, and the pattern repeats once per + trip past the farthest of them. + Pattern picks which heads sound, from the twelve positions of the Roland + RE-202's mode selector. + Its last position sounds every head on the grid, not the RE-202's + unpublished positions. + The lap is the farthest head on the grid, sounded or not, so heads past + the ones a position sounds lengthen the lap without sounding. + Feedback sends the pattern round again, each lap quieter. + Repeat Tone darkens each lap a little more than the one before, once per + lap, never once per head. + Tilt leans the pattern's levels towards the near heads or the far ones. + Up to Mix 1 the dry passes at unity, and at Mix 2 the echoes play alone. + On CircuitPython alone, a stereo dry's right lane reads one LSB hot on + source values within 32 LSB of the rails. + Mix 0 is a wire. + Sync locks Time to Division of the host's beat, clamped to Time's span. + With no host tempo, Time stays on the knob. + Time runs from 20 to 400 ms, Heads from 3 to 8, Feedback from 0 to 0.95 + and Mix from 0 to 2. + `max_lap_ms` (1600 by default, 540 at least) caps the lap: where a grid + would pass it, Time comes down to fit. + A click comes out on the frame it went in: there is no latency. + A one-channel source gets the same effect on its one channel. + The sample rate must be at least 12 825 Hz: below it the constructor + raises `ValueError`. + + **Reset, Mix at zero and the tail.** + The lines are not fed while Mix is 0, so a return from Mix 0 starts both + lines empty. + `reset()` empties both lines and returns to patch 0. + Neither a reset nor a trip to Mix 0 drops a frame of your source or + plays one twice, whatever size of buffer it hands out. + `tail_samples` is an upper bound on how long the echoes take to reach + exact zero once your input stops. + The tail rings on while your source hands back nothing. + + **On a board.** + Measured at v0.6.3, patch 4 costs 5.567 ms a block on the ESP32-S3, + where a block lasts 5.333 ms, so it needs a P4-class board: the ESP32-P4 + runs it in 3.865 ms. + Patch 3 costs 4.163 ms on the ESP32-S3, inside the 4.267 ms budget, and + may need a P4-class board too, especially when the board is running + anything else. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + """ + + NAME = 'MultiTapDelay' + DISPLAY_NAME = 'Multi-Tap Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho", "audioroute") + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Pattern", "Heads", "Feedback", "Mix", "Tilt", + "Repeat Tone", "Sync", "Division") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "BIPOLAR", + 6: "UNIPOLAR", + 7: "TOGGLE", + 8: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, the base head t1, ms + (0.0, 11.0), # 1 Pattern index -> mode 1-12 + (float(HEADS_MIN), float(HEADS_MAX)), # 2 Heads K + (0.0, FEEDBACK_MAX), # 3 Feedback, the lap level + (0.0, 2.0), # 4 Mix; dry at unity to 1 + (-1.0, 1.0), # 5 Tilt + (TONE_MIN_HZ, TONE_MAX_HZ, "log"), # 6 Repeat Tone, Hz; top = out + (0.0, 1.0), # 7 Sync + (0.0, 15.0), # 8 Division index + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 + #: is the constructor's defaults on the grid. + PATCHES = { + 0: ("Three Heads, Even", (85, 69, 0, 60, 22, 64, 68, 0, 25)), + 1: ("Three Heads, Even - lean", (85, 69, 0, 60, 22, 64, 127, 0, 25)), + 2: ("Two Heads, Near Loudest", (93, 35, 25, 67, 22, 32, 68, 0, 25)), + 3: ("Four Heads, Far Loudest", (55, 127, 25, 67, 22, 96, 78, 0, 25)), + 4: ("Eight Heads, Dense", (29, 127, 127, 74, 19, 64, 56, 0, 25)), + 5: ("One Head, Long Repeats", (76, 23, 0, 94, 22, 64, 48, 0, 25)), + 6: ("Triplet Grid, Synced", (85, 69, 0, 60, 22, 64, 68, 127, 34)), + } + + #: The head-set table a Pattern position reads (S1's, modes 1-11). + _SETS = HEAD_SETS + + def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, + mix=0.35, tilt=0.0, tone_hz=4000.0, sync=False, division=3, + max_lap_ms=MAX_LAP_MS, patch=None): + rate = self._sample_rate + channels = self._channel_count + self._check_rate(rate) + max_lap_ms = _number(max_lap_ms, MAX_LAP_MS) + if max_lap_ms > MAX_LAP_MS: + max_lap_ms = MAX_LAP_MS + if max_lap_ms < MIN_LAP_MS: + max_lap_ms = MIN_LAP_MS + self._max_lap_ms = max_lap_ms + + # Constructor values in the macros' own units. A value outside a + # span clamps to the nearer stop (`macro_position` does that), NaN + # takes the default, and a Time of 0 or less is the bottom of its + # span, which a log knob cannot seed. Repeat Tone at 0 or less is + # its out stop, as it is on the node. + time_ms = _number(time_ms, 150.0) + if not time_ms > 0.0: + time_ms = TIME_MIN_MS + tone_hz = _number(tone_hz, 4000.0) + if not tone_hz > 0.0: + tone_hz = TONE_MAX_HZ + pattern = int(math.floor(_number(pattern, 7) + 0.5)) + pattern = min(12, max(1, pattern)) + heads = int(math.floor(_number(heads, HEADS_MIN) + 0.5)) + heads = min(HEADS_MAX, max(HEADS_MIN, heads)) + + #: What the nodes were last handed, so a move writes only what it + #: changes (a `delay_ms` write re-bases the tap node's line). + self._tap_ms = None + self._taps = None + self._n1 = 1 + self._lap = HEADS_MIN + self._heads = heads + self._selected = () + self._levels = () + self._lap_ms = 0.0 + self._feedback = 0.0 + self._lap_mix = 0.0 + self._decay = 0.0 + self._damping = 0.0 + self._lean = False + #: Which node the tap node is playing: None until the graph is + #: wired, then True (the Splitter's tap, the lean graph) or False + #: (the lap node). + self._plugged = None + #: True once the Mixer's voices and the tap node have been played, + #: which happens the first time Mix is above 0. + self._wired = False + #: True while Mix 0 has the output port on the borrowed source. + self._at_source = False + self._deferred = False + #: True inside `reset()`, where a `play()` must not reach the + #: borrowed source (`_route`). + self._resetting = False + + # The input adapter re-blocks whatever the app hands the class into + # the palette's own blocks, which `audioroute.Splitter` needs: a + # bare `audiocore.RawSample` hands back its whole array at once. It + # is also what Mix 0 hands out (`_route`), so the source is read + # through it, and only through it, at every Mix: whatever part of a + # source buffer it holds plays next, whichever way the output goes. + adapter = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + adapter.play(self._source) + # A MidSide with no source hands out one block of zeros per pull and + # never finishes: what the tap node primes from, and what the + # Splitter reads while the graph is wired and while `_resync` swaps + # tap 1's pending block for zeros (`_route`). + self._hush = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + # The Splitter reads the adapter through a port, so `_wire` and + # `_resync` can point it at `_hush` without touching the adapter: + # a MidSide's `play()` drops the frames it holds, and the unread + # rest of a long source buffer has to stay where it is. + self._in = audioroute.Port(adapter) + split = audioroute.Splitter(self._in, taps=2) + dry = split.tap(0) + tap1 = split.tap(1) + self._silence = audiocore.RawSample( + array("h", bytes(2 * 2 * channels)), + sample_rate=rate, channel_count=channels) + # The tap node always plays this port; the graph's two shapes are + # the port pointed at the lap node or at the Splitter's tap, and a + # re-point is one store that pulls nothing. + self._feed = audioroute.Port(self._hush) + + # The laps: the lap node recirculates at P with the darkening in + # its loop. Its `mix` is the Feedback, so its output is the dry plus + # Feedback x the loop: lap 1 at unity, lap n at Feedback^(n - 1). + self._fd = audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, + max_delay_ms=max_lap_ms + 1.0, + delay_ms=max_lap_ms * 0.5, feedback=0.0, mix=0.0, + damping_hz=0.0, cut_hz=0.0, delay_slew=0.0) + # The heads: wet only (`mix` 1.0 in its 0..1 convention, doubled to + # 2 in the node), 256 frames a pull. + self._tapnode = audiodelays.MultiTapDelay( + max_delay_ms=int(math.ceil(max_lap_ms)) + 1, + delay_ms=max_lap_ms * 0.5, decay=0.0, mix=1.0, + taps=((1.0, 1.0),), buffer_size=BLOCK * channels * 2, + sample_rate=rate, bits_per_sample=16, samples_signed=True, + channel_count=channels) + self._mixer = audiomixer.Mixer( + voice_count=2, buffer_size=BLOCK * channels * 4, + channel_count=channels, bits_per_sample=16, samples_signed=True, + sample_rate=rate) + # The tail: a MidSide at width 1 is the identity, byte for byte, and + # its reset forwards nothing upstream. A host that resets the + # output (a mixer voice's `play()` does) would otherwise reach the + # Mixer, whose voices re-prime from the Splitter's taps and throw + # away the source's block they already hold (`_route`). + self._tail = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + self._tail.play(self._mixer) + # What Mix 0 hands out (`_bypass`): the input adapter behind one + # more width-1 MidSide, byte for byte, whose reset forwards nothing, + # so a host resetting the output at Mix 0 leaves the adapter's + # unread frames where they are, as the tail does above Mix 0. The + # adapter hands it whole 256-frame blocks, so it never holds any. + self._through = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + self._through.play(adapter) + + self._adapter = adapter + self._split = split + self._dry = dry + self._tap1 = tap1 + + # The Mixer and the Splitter's side are not reset: a Mixer voice + # resets its source recursively, which would reach the borrowed + # source. The tap node's reset empties its line through + # `audiocore.reset_buffer`; the lap node's `clear` empties its line + # and loop filters. The feed port is not reset either: it would + # forward the reset to whatever it points at. Nor is the input + # adapter: what it holds is the source's own audio, not yet heard, + # and a reset that dropped it would put every later frame early (a + # source in 512-frame buffers, 256 frames) or play a bare + # `RawSample` again from its first frame (`DeEsser` owns its + # adapter the same way). + self._own(self._through) + self._own(self._tail) + self._own(self._mixer, reset=False) + self._own(self._tapnode) + self._own(self._feed, reset=False) + self._own(self._fd, reset=self._fd.clear) + self._own(dry, reset=False) + self._own(tap1, reset=False) + self._own(split, reset=False) + self._own(self._in, reset=False) + self._own(adapter, reset=False) + self._own(self._hush, reset=False) + self._own(self._silence, reset=False) + self._output = self._tail + + self._ready = False + self._init_macros((time_ms, float(pattern - 1), float(heads), + feedback, mix, tilt, tone_hz, + 1.0 if sync else 0.0, _number(division, 3)), + patch) + + self._ready = True + self._route() + + def _check_rate(self, rate): + """Refuse, before anything is built, a rate at which the shortest + head would be handed an offset of 0 or less (`LAG`).""" + if whole_frames(TIME_MIN_MS, rate) <= LAG: + raise ValueError( + "MultiTapDelay needs a sample rate of at least %d Hz (got " + "%d): below it the 20 ms head is %d frames or fewer, which " + "the tap node's one-block lag cannot place" + % (MIN_SAMPLE_RATE, rate, LAG)) + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _pattern_mode(self): + """Pattern's position -> mode 1-12: index floor(11 p + 0.5).""" + index = int(math.floor(11.0 * self._macros[PATTERN_I] + 0.5)) + return min(12, max(1, index + 1)) + + def _heads_count(self): + """Heads' position -> K = floor(3 + 5 p + 0.5), 3-8.""" + count = int(math.floor(HEADS_MIN + (HEADS_MAX - HEADS_MIN) + * self._macros[HEADS_I] + 0.5)) + return min(HEADS_MAX, max(HEADS_MIN, count)) + + def _head_set(self, mode, heads=None): + return head_set(mode, self._heads_count() if heads is None + else heads, type(self)._SETS) + + def _tone_damping(self): + """Repeat Tone -> the lap node's `damping_hz`, pre-warped so one + pass is -3 dB at the knob's corner after the Nyquist clamp. The + top stop is exactly 0: out of circuit, the lean graph.""" + if self._macros[TONE_I] >= 1.0: + return 0.0 + return nominal_damping_hz(self._hz(self._value(TONE_I)), + self._sample_rate) + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one) or a tempo that is not a finite positive number, + where Time stays on the knob.""" + transport = self._transport + if transport is _component.static_transport: + return None + state = transport() if callable(transport) else transport + try: + bpm = float(state[2] or 0.0) + except (TypeError, ValueError, IndexError): + return None + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + def _tap_node_ms(self, lap): + """The tap node's `delay_ms` for a lap of `lap` frames: half a + frame over, so its truncation lands on `lap` in either float + width.""" + return (lap + 0.5) * 1000.0 / self._sample_rate + + def _tap_positions(self, selected, n1, lap): + """Head k at (k n1 - LAG + 0.5) / P, so the node's truncation of + the offset lands on k n1 - LAG: the tap node reads its input one + block after the dry has played it (`_route`), so each head sounds + at exactly k n1 against the dry.""" + return tuple((k * n1 - LAG + 0.5) / lap for k in selected) + + def _lap_node_ms(self, lap): + return lap_node_ms(lap, self._sample_rate) + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del index, position + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh once, so Time is never + read against the outgoing patch's Sync and the graph re-plugs at + most once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def _refresh(self): + rate = self._sample_rate + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + synced = min(TIME_MAX_MS, max(TIME_MIN_MS, synced)) + self._macros[TIME_I] = _component.macro_position( + self._MACRO_RANGES[TIME_I], synced) + heads = self._heads_count() + n1, lap = landed(self._value(TIME_I), heads, rate, self._max_lap_ms) + selected = self._head_set(self._pattern_mode(), heads) + levels = head_levels(selected, heads, self._value(TILT_I)) + taps = tuple(zip(self._tap_positions(selected, n1, lap), levels)) + + feedback = self._value(FEEDBACK_I) + feedback = min(FEEDBACK_MAX, max(0.0, feedback)) + lean = self._macros[TONE_I] >= 1.0 + damping = self._tone_damping() + # The Feedback is handed as set. Up to audiodsp v0.6.2 the lap + # node's loop low-pass could hold a small value for ever a hair + # either side of 1 - 0.5 / k, and this class stepped the Feedback + # clear of those windows; since v0.6.3rc1 the node lands a damping + # state that has stopped moving (audiodsp#157), and `laps_to_zero` + # counts that landing's one extra lap. + loop = feedback + decay = feedback if lean else 0.0 + + tap_ms = self._tap_node_ms(lap) + lap_ms = self._lap_node_ms(lap) + if tap_ms != self._tap_ms: + self._tapnode.delay_ms = tap_ms + self._tap_ms = tap_ms + if taps != self._taps: + self._tapnode.taps = taps + self._taps = taps + self._tapnode.decay = decay + self._fd.set(delay_ms=lap_ms, feedback=loop, mix=loop, + damping_hz=damping, cut_hz=0.0, delay_slew=0.0) + #: The lap node's `mix`, which is its Feedback (dossier section 4). + self._lap_mix = loop + mix = self._value(MIX_I) + self._mixer.voice[0].level = self._dry_level(mix) + self._mixer.voice[1].level = min(1.0, mix) + + self._n1 = n1 + self._lap = lap + self._heads = heads + self._selected = selected + self._levels = levels + self._lap_ms = lap_ms + self._feedback = loop + self._decay = decay + self._damping = damping + self._lean = lean + self._route() + + def _dry_level(self, mix): + """The dry voice's level, min(1, 2 - Mix). At one channel unity is + handed as 1 - 2^-15, which every Mixer this class meets passes + through exactly: CircuitPython's stock `audiomixer` turns a level + of 1.0 into 32768 / 32767 and puts the samples nearest the rails + one LSB hot, while 32767 / 32767 is 1. Two channels cannot do that + (the stock Mixer's pan law hands the left lane 32767 x level >> 15, + so 1 - 2^-15 is one LSB cold there), so they keep 1.0, and on + CircuitPython alone their right lane is the hot one.""" + level = min(1.0, 2.0 - mix) + if level >= 1.0 and self._channel_count == 1: + return 1.0 - 2.0 ** -15 + return level + + def _route(self): + """Where the output port points, and what the tap node reads. + + Mix 0 hands the input adapter out through the port (`_bypass`, by + way of `_through`), a width-1 MidSide that passes the source byte + for byte, so it is a + wire on every interpreter: CircuitPython's stock `audiomixer` + scales a voice at level 1.0 by 32768 / 32767, which puts every + sample at |value| >= 32736 one LSB out (audiodsp's own Mixer passes + unity through). The source is read through the adapter at every + Mix, so whatever part of a buffer it holds plays next either way. + Nothing is pulled through the graph while the port is on the + adapter, so nothing here may prime from it then: a `play()` would + take a block away from the port. `_route_around` marks the graph + stranded, and the return clears it through `_rejoin` (the base's + stale-block helper) before `_resync`; the adapter is not reset by + either, since it is registered without one. + + **The tap node runs one block behind the dry.** A Mixer voice's + `play()` primes one block from its source, and the voices hand + that block out on the first pull, so whatever the tap node renders + at wiring is rendered with the settings of that moment. If that + were the source's first block, a Time or Heads move before the + first pull would re-base the tap node's planar line under it, and + in stereo the right lane would lose its first block's heads. So + the tap node primes a block of zeros instead (it plays `_feed`, + pointed at `_hush`), renders those zeros for the wet voice's + prime, and only then is the port pointed at the lap node or the + Splitter's tap, which pulls nothing. The source's first block + reaches the tap node's line on the second pull, with whatever the + settings are by then; every head is handed LAG frames short + (`_tap_positions`), so it still sounds at exactly k n1 against the + dry, and the Splitter's tap 1 always holds the one block the tap + node has not reached yet. + + Crossing Repeat Tone's out stop re-points the port (and empties + the lap node going in), which pulls nothing, so any number of + crossings between two pulls leaves the wet where it was against the + dry. Coming back from Mix 0, and in `reset()`, `_resync` empties + both lines and swaps that one pending block on tap 1 for a block of + zeros (the Splitter reads `_hush` for one pull of each tap), so no + audio from before either reaches the lines, nothing is taken from + the source, and a second or third `_resync` before the next pull + leaves exactly what the first did. + + **Every wiring is quiet** where `audiocore.get_buffer` exists + (`_wire`): the Splitter reads `_hush` while the voices prime, and + one pull of the Mixer hands the primed zeros out, so the graph is + left as it is between two pulls mid-stream and nothing is taken + from the source. Up to re-audit fix round 1 a wiring outside + `reset()` primed the dry voice with the source's first block, and + a `_resync` before the first pull then dropped that block's heads + (tap 1 held it, not yet heard) and, in mono on a native build, + refilled the tap's own buffer the voice was pointing into, so the + dry's first 256 samples played as zeros. Inside `reset()` the + wiring is quiet on every build, because a reset must take nothing + from the source. + + Where `get_buffer` is left out, a wiring outside `reset()` primes + the source's first block into the dry voice, and nothing here can + pull it out. The first pull plays it on time. A Mix-0 route before + that pull hands out the adapter, which has already given that block + away, so the run plays the source one block early without it; the + voice keeps the block, and plays it at the return (or the reset) + with tap 1's copy going into the lines, since `_resync` cannot swap + it, so its heads follow it. A wiring inside `reset()` opens the + output with the one silent block it primed, and a reset or a return + from Mix 0 lets tap 1's pending block into the lines, its heads + late by any Mix-0 run between. The module docstring says all of + this in a host's terms. + """ + if not self._ready: + return + if self._macros[MIX_I] <= 0.0: + self._route_around(self._bypass()) + self._at_source = True + return + if not self._wired: + self._wire(self._quiet_wiring()) + self._wired = True + elif self._at_source or self._resetting: + # The base's stale-block helper clears what `_route_around` + # left (both lines and the tail); `_resync` clears the lines + # again, which changes nothing, and swaps tap 1's block. + self._rejoin() + self._resync() + elif self._lean != self._plugged: + self._plug(self._lean) + self._at_source = False + self._output = self._tail + + def reset(self): + """Empty both lines and restore patch 0, without pulling the + borrowed source (`_route`).""" + self._check_live() + self._resetting = True + try: + _component.Component.reset(self) + finally: + self._resetting = False + + def _target(self, lean): + """What the feed port points at: the Splitter's tap (the lean + graph, the tap node's own `decay` making the laps) or the lap + node.""" + return self._tap1 if lean else self._fd + + def _bypass(self): + """What Mix 0 hands out: the input adapter, a width-1 MidSide that + passes the source byte for byte in the palette's blocks, behind + `_through`, which keeps a host's reset off it. Pointing the output + at the source itself would skip whatever part of a source buffer + the adapter holds (a source in 512-frame buffers jumps 256 frames + ahead) and hand a bare `RawSample` out again from its first + frame.""" + return self._through + + def _quiet_wiring(self): + """Whether `_wire` primes the voices from `_hush`: wherever + `audiocore.get_buffer` can hand the primed zeros out, and always + inside `reset()`, which must take nothing from the source.""" + return self._resetting or \ + getattr(audiocore, "get_buffer", None) is not None + + def _wire(self, quiet=False): + """Play every node once, the first time Mix is above 0 (`_route`). + + `quiet`: the Splitter reads `_hush` while the voices prime, so the + dry voice primes zeros and tap 1's pending block is zeros, and one + pull of the Mixer hands those zeros out. Nothing is taken from the + source, and the graph is left as it is between two pulls + mid-stream: no voice holds a block, the dry tap is caught up, and + tap 1 is one block behind it. A voice that still held its primed + block would point into a mono `SplitterTap`'s own buffer, which + `_resync` refills, and tap 1 would hold audio whose dry had not + played yet, which `_resync` drops.""" + pull = getattr(audiocore, "get_buffer", None) + self._fd.play(self._tap1) + self._feed.play(self._hush) + self._tapnode.play(self._feed, loop=False) + if quiet: + self._in.play(self._hush) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry, loop=False) + self._mixer.voice[1].play(self._tapnode, loop=False) + self._feed.play(self._target(self._lean)) + self._plugged = self._lean + if quiet: + self._in.play(self._adapter) + if pull is not None: + pull(self._mixer) + + def _resync(self): + """Empty both lines and swap the block tap 1 holds for the tap node + for a block of zeros, the same however many times it runs between + two pulls (`_route`). + + With the Splitter reading `_hush`, one pull of tap 1 then one of the + dry tap always leaves tap 0 caught up and tap 1 one block of zeros + behind it. From the usual state (tap 1 holding the source's last + block, tap 0 caught up) the first pull drops that block and the + second writes the zeros. From the state this leaves, a second call + reads those zeros back and writes them again, so nothing of the + source is taken and the tap node stays one block behind the dry. + A quiet wiring (`_wire`) leaves tap 1 holding zeros and no voice + holding a block, so right after construction this is that state + too: the dry tap's pull refills no buffer a voice still points + into, and what tap 1 drops was never audio.""" + pull = getattr(audiocore, "get_buffer", None) + self._fd.clear() + audiocore.reset_buffer(self._tapnode) + if pull is not None: + self._in.play(self._hush) + try: + pull(self._tap1) + pull(self._dry) + finally: + self._in.play(self._adapter) + self._feed.play(self._target(self._lean)) + self._plugged = self._lean + + def _plug(self, lean): + """Point the feed port at the Splitter's tap or at the lap node, + emptying the lap node going in. A store: nothing is pulled, so the + dry is never disturbed and the wet does not move against it.""" + if not lean: + self._fd.clear() + self._feed.play(self._target(lean)) + self._plugged = lean + + # -- the contract's reads ------------------------------------------ + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, + as an upper bound. + + Repeat Tone in: `laps_to_zero(f', excess) x (P + 1 + memory) + P`, + `DigitalDelay`'s bound for the lap node at the Feedback it is + handed, plus one lap for the tap node's line to be read out. + Repeat Tone out: `(trunc_laps(f) + 1) x P`, the tap node's own + truncating loop. Finite at every setting. + """ + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`.""" + lap = self._lap + if self._macros[MIX_I] <= 0.0: + return 0 + if self._lean: + return int((trunc_laps(self._decay) + 1) * lap) + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + return int(laps * (lap + 1 + memory) + lap) diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py new file mode 100644 index 0000000..143ecde --- /dev/null +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -0,0 +1,473 @@ +"""`PingPongDelay` - repeats that alternate between the speakers. + +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/PingPongDelay.md`). +""" + +VENDOR = "PyDevices" + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's arithmetic, reused rather than copied: the same node rounds +# the same way in both classes. Its module moves up one level when it comes +# home, so both homes are tried; the same for SlapbackDelay's `tone_excess`. +try: + from .digitaldelay import (DIVISION_BEATS, laps_to_zero, nominal_cut_hz, + whole_frames) +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import (DIVISION_BEATS, laps_to_zero, + nominal_cut_hz, whole_frames) +try: + from .slapbackdelay import tone_excess +except ImportError: # pragma: no cover - after it lands + from ..slapbackdelay import tone_excess + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +#: The Time span, fixed on every instance (dossier section 6): 20-1000 ms, +#: log. +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 1000.0 + +#: The line's headroom over `max_time_ms`: the node clamps a delay at +#: `line_frames - 2` (`audiodsp_feedback_delay.c:148-150`), so a line of +#: exactly `max_time_ms` could not reach it. +LINE_HEADROOM_MS = 1.0 + +#: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). +FEEDBACK_MAX = 0.99 + +#: The Repeat Tone and Repeat Cut spans' corners, Hz. Tone's top stop and +#: Cut's bottom stop are the filters out of circuit. +TONE_MIN_HZ = 800.0 +TONE_MAX_HZ = 16000.0 +CUT_MIN_HZ = 20.0 +CUT_MAX_HZ = 400.0 + +#: The fixed Time walk, delay-seconds per second: 3/16, exact in float32 +#: (dossier section 8.5, `SlapbackDelay`'s section 8 answer 6). +SLEW = 0.1875 + +#: How close, as a 0..1 knob position, a Time write must come to the +#: constructor's own seeded position to count as a host echoing it back: +#: 1e-6 is 1/7 874 of one MIDI step. +ECHO_TOLERANCE = 1e-6 + +(TIME_I, FEEDBACK_I, MIX_I, SPREAD_I, SIDE_I, SYNC_I, DIVISION_I, TONE_I, + CUT_I) = range(9) + +#: The constructor defaults NaN falls back to: Time, Feedback, Mix, Spread, +#: Division. +DEFAULT_TIME_MS = 280.0 +DEFAULT_FEEDBACK = 0.45 +DEFAULT_MIX = 0.3 +DEFAULT_SPREAD = 1.0 +DEFAULT_DIVISION = 6.0 + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +def _side(value): + """`first_side` -> 0.0 (left) or 1.0 (right).""" + if isinstance(value, str): + word = value.lower() + if word == "left": + return 0.0 + if word == "right": + return 1.0 + raise ValueError("first_side must be 'left' or 'right', not %r" + % (value,)) + return 1.0 if float(value) >= 0.5 else 0.0 + + +class PingPongDelay(_component.Component): + """Two delay lines crossed into each other: the repeats bounce between the + speakers. + + Your dry signal passes untouched on both sides, and the repeats come + back one side and then the other, all the way down. + + **The controls.** The first repeat comes back Time later on the side + First Side names, the next Time after that on the other side, and they + keep bouncing, each a Feedback's worth quieter than the last. Time runs + from 20 to 1000 ms and Feedback from 0 to 0.99. Mix is the echo level: + the dry stays at unity up to Mix 1, Mix 2 is the repeats alone, and at + Mix 0 the output is the input. Spread moves between two plain delays + with the same repeats on both sides, at 0, and the full bounce, at 1. At + Spread 1 each repeat is on one side only, and the other side is exact + zero. With Sync on, Time is Division of the host's beat, up to 1000 ms; + with no host tempo, Time stays where the knob is. The class reads the + host's transport only while Sync is on, and then only when a control + moves or a patch loads, never while it plays. Repeat Tone is a low-pass + and Repeat Cut a high-pass inside the loop, so each bounce is a little + darker or thinner than the last. Repeat Tone's top stop and Repeat Cut's + bottom stop take them out, and a filter taken out is out. At 22.05 kHz + the top positions of Repeat Tone sit on one clamp below Nyquist and + sound the same. Turning Time walks the repeats to the new Time, bending + their pitch, instead of clicking. + + **Stereo and mono.** At Spread 1 the loop hears the average of the two + input channels, so what differs between them never repeats. The dry is + always each channel's own signal, never swapped or summed. A one-channel + source gets an ordinary feedback delay at the same Time, Feedback and + Mix, and Spread and First Side do nothing there. + + **Where it stops.** At 48 kHz every Time position lands on the nearest + whole frame. At 44.1 and 22.05 kHz the node lands some positions a + fraction of a frame off, and a sliver of each repeat falls on the frame + beside it. The dry sits at unity and the repeats add to it, so a hot + input can reach the int16 rail. With Repeat Cut out and Mix below 1, an + input that peaks at or below floor(32767 (1 - Mix)) - 1 cannot reach the + rail, at any Time, Feedback or Spread. Repeat Cut's high-pass + overshoots, so with it in leave more room. + + **Limits shared by the family.** A control that jumps makes the output + step: move it in small steps from the host if you need it smooth. The + tail rings only while the source keeps feeding: feed silence to let it + ring out. A tail cut short by a source that stopped carries on when the + source comes back. + + **Latency, tail, portability.** A click comes out on the frame it went + in: there is no latency. `tail_samples` is an upper bound on how many + frames the output takes to reach exact zero, counted from when your + input stops or from when you read it if that is later, for the settings + as they stand when you read it. With Repeat Cut in circuit + `tail_samples` is `None`: the class gives no bound there. Pass a lower + `max_time_ms` for a shorter line: Time then stops at that ceiling, and + `get_macro(0)` shows where it stopped. A constructor value outside a + knob's span clamps to the nearer stop, a `tone_hz` or `cut_hz` of 0 or + less is that filter out, and NaN takes the option's default. `reset()` + empties the line and returns to patch 0. The class needs audiodsp's + `audioecho`, and on a board without it construction raises + `ImportError`. + """ + + NAME = 'PingPongDelay' + DISPLAY_NAME = 'Ping-Pong Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Feedback", "Mix", "Spread", "First Side", + "Sync", "Division", "Repeat Tone", "Repeat Cut") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "TOGGLE", + 5: "TOGGLE", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + 8: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (0.0, 2.0), # 2 Mix; dry at unity to 1 + (0.0, 1.0), # 3 Spread + (0.0, 1.0), # 4 First Side: left, right + (0.0, 1.0), # 5 Sync + (0.0, 15.0), # 6 Division index + (TONE_MIN_HZ, TONE_MAX_HZ, "log"), # 7 Repeat Tone, Hz; top = out + (CUT_MIN_HZ, CUT_MAX_HZ, "log"), # 8 Repeat Cut, Hz; bottom = out + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Wide Bounce", (86, 58, 19, 127, 0, 0, 51, 127, 0)), + 1: ("Eighth Note Bounce", (86, 64, 19, 127, 0, 127, 51, 127, 0)), + 2: ("Quarter Note Bounce", (86, 51, 19, 127, 0, 127, 76, 127, 0)), + 3: ("Narrow Bounce", (86, 58, 19, 64, 0, 0, 51, 127, 0)), + 4: ("Two Delays", (86, 58, 19, 0, 0, 0, 51, 127, 0)), + 5: ("Dark Bounce", (99, 83, 19, 127, 0, 0, 51, 48, 0)), + 6: ("Right First", (86, 58, 19, 127, 127, 0, 51, 127, 0)), + } + + def _build(self, time_ms=DEFAULT_TIME_MS, feedback=DEFAULT_FEEDBACK, + mix=DEFAULT_MIX, spread=DEFAULT_SPREAD, first_side="left", + sync=False, division=DEFAULT_DIVISION, tone_hz=TONE_MAX_HZ, + cut_hz=CUT_MIN_HZ, max_time_ms=TIME_MAX_MS, patch=None): + max_time_ms = float(max_time_ms) + # `not <=` catches NaN, which would otherwise pass both clamps and + # size the line from nothing. + if not max_time_ms <= TIME_MAX_MS: + max_time_ms = TIME_MAX_MS + if max_time_ms < TIME_MIN_MS: + max_time_ms = TIME_MIN_MS + self._max_time_ms = max_time_ms + self._frames = 1 + #: The longest delay, in whole frames, the read head may still sit + #: at. The walk starts from wherever the head is and the class cannot + #: see how far it has got, so after a falling move this keeps the old + #: Time until a reset lands the head. + self._reach = 1 + #: True while the node has been built or cleared and not yet told a + #: second Time: it snaps onto the configured delay on its first pull. + self._fresh = True + #: True while several macros are applied at once (the constructor, + #: `program_change`); the node is refreshed once, after the last. + self._deferred = False + self._feedback = 0.0 + self._damping = 0.0 + self._cut = 0.0 + self._node_ms = 0.0 + #: The constructor's Time, exactly, until macro 0 moves. Seeding a + #: log knob and reading it back is not exact, and a few ulps under a + #: half frame lands one frame short. + self._time_exact = None + self._time_seed = -1.0 + self._seeding = True + # A log knob cannot seed 0 or a negative, so those clamp to the + # nearer stop here; 0 or less is how the node spells a filter out. + time_ms = _between(_option(time_ms, DEFAULT_TIME_MS), + TIME_MIN_MS, max_time_ms) + tone_hz = _option(tone_hz, TONE_MAX_HZ) + if not tone_hz > 0.0: + tone_hz = TONE_MAX_HZ + tone_hz = _between(tone_hz, TONE_MIN_HZ, TONE_MAX_HZ) + cut_hz = _option(cut_hz, CUT_MIN_HZ) + if not cut_hz > 0.0: + cut_hz = CUT_MIN_HZ + cut_hz = _between(cut_hz, CUT_MIN_HZ, CUT_MAX_HZ) + values = (time_ms, + _between(_option(feedback, DEFAULT_FEEDBACK), + 0.0, FEEDBACK_MAX), + _between(_option(mix, DEFAULT_MIX), 0.0, 2.0), + _between(_option(spread, DEFAULT_SPREAD), 0.0, 1.0), + _side(first_side), + 1.0 if sync else 0.0, + _between(_option(division, DEFAULT_DIVISION), 0.0, 15.0), + tone_hz, + cut_hz) + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=max_time_ms + LINE_HEADROOM_MS, + delay_ms=time_ms, + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + cross_feed=0.0, + input_pan=0.0, + delay_slew=0.0) + # `clear()` empties both lanes and the loop filters and re-primes the + # read head (`audiodsp_feedback_delay.c:295-303`, `:286-288`), so a + # reset is silent and snaps onto patch 0's Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + self._time_exact = time_ms + self._deferred = True + try: + self._init_macros(values) + finally: + self._deferred = False + self._seeding = False + self._time_seed = self._macros[TIME_I] + self._refresh() + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _clamp_ms(self, time_ms): + time_ms = float(time_ms) + if time_ms > self._max_time_ms: + return self._max_time_ms + if time_ms < TIME_MIN_MS: + return TIME_MIN_MS + return time_ms + + def _node_time_ms(self, frames): + """What the node is handed for a whole-frame Time.""" + return frames * 1000.0 / self._sample_rate + + def _tone_damping(self, position): + """Macro 7's position -> the node's `damping_hz`: the corner the + loop low-pass achieves, clamped below Nyquist and pre-warped. The + top stop is exactly 0 (out of circuit) and is never pre-warped.""" + if position >= 1.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[TONE_I], position) + return nominal_damping_hz(self._hz(corner), self._sample_rate) + + def _cut_hz(self, position): + """Macro 8's position -> the node's `cut_hz`: the corner the loop + high-pass achieves, clamped and pre-warped. The bottom stop is + exactly 0 (out of circuit).""" + if position <= 0.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[CUT_I], position) + return nominal_cut_hz(self._hz(corner), self._sample_rate) + + def _cross_and_pan(self): + """(cross_feed, input_pan) for Spread and First Side. A one-channel + node has no second lane: anything but (0, 0) there silences its loop + (dossier section 7.1), so Spread and First Side are inert.""" + if self._channel_count == 1: + return 0.0, 0.0 + spread = _between(self._value(SPREAD_I), 0.0, 1.0) + if self._macros[SIDE_I] >= 0.5: + return spread, spread + return spread, -spread + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one), where Time stays where the knob is.""" + transport = self._transport + if transport is _component.static_transport: + return None + state = transport() if callable(transport) else transport + # A host whose tempo is not a finite positive number (0, None, a + # negative, NaN or infinity) leaves Time on the knob, as the static + # transport does. `not bpm > 0` catches NaN, and `bpm * 0` is NaN + # for infinity. + bpm = float(state[2] or 0.0) + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + if index == TIME_I and not self._seeding and not ( + self._time_exact is not None + and abs(position - self._time_seed) <= ECHO_TOLERANCE): + # A host that reads Time back and writes the same position + # (`set_macro(0, get_macro(0))`) keeps the constructor's exact + # Time; any other position drops it. + self._time_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh the node once. A refresh + per macro would read Time against the outgoing patch's Sync.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + self._fresh = False + + def _time_ms(self): + """The Time the audio path plays, before it is landed: Division x + the beat with Sync on and a host tempo, else the constructor's exact + Time until macro 0 moves, else the knob. Clamped at `max_time_ms`, + and the knob moved to show the clamp.""" + span = self._MACRO_RANGES[TIME_I] + synced = None + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + self._time_exact = None + time_ms = self._clamp_ms(synced) + self._macros[TIME_I] = _component.macro_position(span, time_ms) + return time_ms + if self._time_exact is not None: + return self._time_exact + time_ms = _component.macro_value(span, self._macros[TIME_I]) + clamped = self._clamp_ms(time_ms) + if clamped != time_ms: + self._macros[TIME_I] = _component.macro_position(span, clamped) + return clamped + + def _refresh(self): + rate = self._sample_rate + self._frames = max(1, whole_frames(self._time_ms(), rate)) + self._node_ms = self._node_time_ms(self._frames) + if self._fresh or self._frames > self._reach: + self._reach = self._frames + # Both out stops are exactly 0, and the Feedback is handed as set. + # Since audiodsp v0.6.3rc1 the node keeps an out low-pass's state on + # the tap and an out high-pass's at zero (#158, #159), and lands a + # low-pass that has stopped moving (#157). + damping = self._tone_damping(self._macros[TONE_I]) + cut = self._cut_hz(self._macros[CUT_I]) + feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) + self._feedback = feedback + self._damping = damping + self._cut = cut + cross, pan = self._cross_and_pan() + self._delay.set( + delay_slew=SLEW, + delay_ms=self._node_ms, + feedback=feedback, + mix=_between(self._value(MIX_I), 0.0, 2.0), + damping_hz=damping, + cut_hz=cut, + cross_feed=cross, + input_pan=pan) + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound, or `None` with Repeat Cut in circuit. + + `laps_to_zero(f, excess)` laps, each at most one frame longer than + the longest delay the read head may be at (the read interpolates + towards the next older frame), plus the Tone low-pass's memory. + While a walk falls, that is the Time it is walking from, until a + reset lands the head. The cross-feed hands each lane a convex mix + of the two lanes' loop values (`audiodsp_feedback_delay.c:581-589`), + so the per-lap argument holds at every Spread.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`.""" + if self._cut > 0.0: + return None + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + return int(laps * (self._reach + 1 + memory)) diff --git a/lib/audioeffects/rebuilt/reverb.py b/lib/audioeffects/rebuilt/reverb.py new file mode 100644 index 0000000..17b528a --- /dev/null +++ b/lib/audioeffects/rebuilt/reverb.py @@ -0,0 +1,561 @@ +"""`Reverb` - Dattorro's plate network, cut four ways: a plate, a room, a +chamber and a hall. + +Your dry signal passes untouched and a reverb tail rises behind it. +Character picks the machine: `plate` is dense from the first milliseconds, +the way the EMT 140's steel sheet is, while `room`, `chamber` and `hall` +start sparse and build. Decay (0.3 to 10 s) sets how long the tail rings, +Size (0.5 to 1.5) stretches every line of the network, and Predelay (0 to +200 ms) holds the tail back from the dry. Diffusion (0 to 0.9) smears the +early echoes, Damping (500 Hz to 16 kHz) darkens the tail as it rings, and +Bandwidth (500 Hz to 20 kHz) darkens what goes in. Low Cut (20 to 500 Hz) +keeps the bass out of the tank while the dry keeps it. Mod Depth (0 to 2 ms) +and Mod Rate (0.1 to 5 Hz) wobble two lines inside the tank, Width (0 to 1) +sets the stereo spread, and Tone (-12 to +12 dB) tilts the tail. Mix (0 to +2) is `audiodelays.Echo`'s: the dry at unity until 1, the tail alone at 2. +Mix 0 is a byte-exact wire while the tank keeps ringing behind it. Latency +is zero: nothing looks ahead, and Predelay delays only the tail. + +On the plate, Decay also moves the tail's loss corner, the way the EMT +140's damping panel does: open at Decay 8 s and above, at the Damping +setting at 1 s and below. + +A Character or Size move re-cuts the tank: the tail drops to nothing at the +move, and the dry carries on without losing a frame. `reset()` empties the +tank the same way, keeps the dry, and restores patch 0. + +**Decay.** At each character's reference patch (Steel Plate, Live Room, +Dark Chamber, Concert Hall) at Size 1.0, the tail falls 60 dB at 500 Hz +within 12 % of Decay at 2, 3, 4, 6, 8 and 10 s, and on the hall from 4 s. +Shorter Decays, other Sizes and the other patches are not claimed: Damped +Plate, Small Room and Live Room ring longer than their Decay reads. With +Damping at 1 kHz and Size 0.5, the room, chamber and hall at Decay 8 and +10 s ring more than 12 % short of it: the class holds the bass to 1.5 x +Decay. + +**Modulation.** With Mod Depth at 0, a 1 kHz tone on Steel Plate or Concert +Hall comes out as one line, its sidebands more than 60 dB under it. On those +two patches as shipped, at every grid position from 17 to 64 of Mod Depth +(about 0.27 to 1 ms) and from 45 to 81 of Mod Rate (about 0.4 to 1.2 Hz), +tones at 300 Hz, 1 kHz and 3 kHz spread into sidebands within 20 dB of the +tone. Outside that it is not claimed: on Steel Plate a 3 kHz tone at Mod +Depth position 81 (about 1.28 ms) and Mod Rate position 121 (about 4.16 Hz) +reads more than 20 dB under. + +**Input ceiling.** There is no input gain, and the tank's lines clamp at the +rail on every write whatever Mix is: a steady 362 Hz tone at 8 000 LSB RMS +comes back more than 1 dB quieter in the tail than at 4 000. On 2 s of +uniform noise at 4 000 LSB RMS at Mix 1, no shipped patch reaches the rail +at 48, 44.1 or 22.05 kHz; Bright Chamber at 44.1 kHz is not claimed. + +**The patches:** Steel Plate (the defaults), Short Plate, Damped Plate, +Bass-Free Plate, Small Room, Live Room, Concert Hall, Dark Chamber, Bright +Chamber, Slow Bloom. + +`tail_samples` bounds the frames until the output is exactly zero once +your input stops: 222 868 frames at the defaults at 48 kHz. One int16 +allocation holds the lines and 200 ms of predelay: 89 714 B for Steel Plate +at 48 kHz, and 146 914 B for the hall at Size 1.5, the most it takes. + +**Limits shared by the family** + +A control that jumps makes the output step: move it in small steps from +the host if you need it smooth. + +The tail rings only while the source keeps feeding: feed silence to let +it ring out. A tail cut short by a source that stopped carries on when +the source comes back. + +Asking for `character="spring"` says it is parked: the tank has no +dispersive chain yet. A value outside a macro's span clamps to the nearer +stop, and NaN takes the option's default. On a board without `audioverb`, +construction raises `ImportError`. +""" + +VENDOR = "PyDevices" + +import math + +from .. import _component + +try: + import audioverb +except ImportError: # pragma: no cover - a stock board + audioverb = None + + +#: Dattorro's reference rate: his Fig. 1 lengths are frames at 29 761 Hz. +REF_RATE = 29761.0 + +#: Dattorro Fig. 1's twelve lines, in the Tank's order: four input +#: diffusers, then half A (modulated all-pass, delay, all-pass, delay) and +#: half B the same. +DATTORRO_LINES = (142, 107, 379, 277, 672, 4453, 1800, 3720, + 908, 4217, 2656, 3163) + +#: Dattorro Table 2's fourteen output taps, (channel, line, offset, gain). +DATTORRO_TAPS = ( + (0, 9, 266, 0.6), (0, 9, 2974, 0.6), (0, 10, 1913, -0.6), + (0, 11, 1996, 0.6), (0, 5, 1990, -0.6), (0, 6, 187, -0.6), + (0, 7, 1066, -0.6), + (1, 5, 353, 0.6), (1, 5, 3627, 0.6), (1, 6, 1228, -0.6), + (1, 7, 2673, 0.6), (1, 9, 2111, -0.6), (1, 10, 335, -0.6), + (1, 11, 121, -0.6), +) + +CHARACTERS = ("plate", "room", "chamber", "hall") +PLATE, ROOM, CHAMBER, HALL = range(4) + +#: Each character's ratio over Dattorro's twelve lines (dossier App. A8.8). +#: The plate is his tank with its input diffusers at 0.3 of his, so the +#: density is there by 20 ms (T1); the others stretch the diffusers so the +#: density builds (T8) and re-proportion the tank. No two rows are +#: proportional, so no Size makes two characters' line sets equal (T9). +RATIOS = ( + (0.30, 0.30, 0.30, 0.30, 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00), + (4.50, 4.20, 3.40, 3.70, 0.50, 0.36, 0.44, 0.40, + 0.52, 0.38, 0.42, 0.37), + (4.00, 4.30, 3.00, 3.40, 0.80, 0.62, 0.70, 0.66, + 0.78, 0.64, 0.72, 0.60), + (2.00, 1.86, 1.60, 1.78, 1.30, 1.12, 1.24, 1.08, + 1.26, 1.16, 1.20, 1.10), +) + +#: The Decay law's per-character constant (dossier section 4), each a +#: multiple of 1/64 so every float format holds it exactly. +KAPPA = (1.078125, 1.046875, 1.078125, 1.046875) + +#: The Decay law's low-frequency ceiling: the loop's bass never rings longer +#: than LF_CAP x Decay (dossier section 4; the 1.5 is the design's). +LF_CAP = 1.5 + +#: The node refuses a line under 4 frames (`audiodsp_tank.c:145-146`); the +#: shortest the class cuts anywhere on its span is 12. +MIN_LINE = 4 + +#: The Tank's predelay allocation, fixed at construction; the Predelay +#: macro's top. +MAX_PREDELAY_MS = 200.0 + +#: Where each character's zone sits on the 0-127 grid when the constructor +#: or a patch names it: plate 0-31, room 32-63, chamber 64-95, hall 96-127. +CHARACTER_MIDI = (0, 42, 85, 127) + +#: The plate's damper law: open at DAMPER_LONG_S, the Damping setting at +#: DAMPER_SHORT_S, geometric in log Decay between (dossier section 4, T3). +DAMPER_LONG_S = 8.0 +DAMPER_SHORT_S = 1.0 + +#: The frequency Decay is stated at. +DECAY_HZ = 500.0 + +(CHARACTER_I, DECAY_I, SIZE_I, PREDELAY_I, DIFFUSION_I, DAMPING_I, + BANDWIDTH_I, LOW_CUT_I, MOD_DEPTH_I, MOD_RATE_I, WIDTH_I, TONE_I, + MIX_I) = range(13) + +#: The constructor defaults, in macro order, which NaN falls back to. +DEFAULTS = (0.0, 2.4, 1.0, 0.0, 0.75, 1000.0, 12000.0, 40.0, 0.27, 1.0, + 1.0, 0.0, 0.35) + + +# -- the cut ----------------------------------------------------------------- + +def line_set(index, size, sample_rate, ratios=None): + """The twelve line lengths, in frames, of character `index` at `size` + and `sample_rate`: round(n x fs / 29 761 x size x r) per line.""" + ratios = RATIOS[index] if ratios is None else ratios + fs = float(sample_rate) + size = float(size) + out = [] + for n, r in zip(DATTORRO_LINES, ratios): + frames = int(round(n * fs / REF_RATE * size * r)) + out.append(frames if frames > MIN_LINE else MIN_LINE) + return out + + +def tap_table(index, size, sample_rate, ratios=None, lines=None): + """The fourteen taps, flattened as the Tank takes them: each offset + scaled by its own line's factor and held under that line's length.""" + ratios = RATIOS[index] if ratios is None else ratios + if lines is None: + lines = line_set(index, size, sample_rate, ratios) + fs = float(sample_rate) + size = float(size) + out = [] + for channel, line, offset, gain in DATTORRO_TAPS: + at = int(round(offset * fs / REF_RATE * size * ratios[line])) + if at > lines[line] - 1: + at = lines[line] - 1 + out.extend((channel, line, at, gain)) + return out + + +def half_periods(lines): + """Frames around each half of the figure-eight: lines 4-7 and 8-11.""" + return (lines[4] + lines[5] + lines[6] + lines[7], + lines[8] + lines[9] + lines[10] + lines[11]) + + +# -- the laws ---------------------------------------------------------------- + +def one_pole_mag(corner_hz, frequency, sample_rate): + """|H| at `frequency` of the node's loop one-pole, whose coefficient is + 1 - exp(-2 pi corner / fs).""" + if corner_hz <= 0.0: + return 1.0 + a = 1.0 - math.exp(-2.0 * math.pi * corner_hz / sample_rate) + w = 2.0 * math.pi * frequency / sample_rate + b = 1.0 - a + re = 1.0 - b * math.cos(w) + im = b * math.sin(w) + return a / math.sqrt(re * re + im * im) + + +def damper_hz(decay_s, damping_hz, sample_rate): + """The plate's loop corner at a Decay: 0.98 x Nyquist at 8 s and above, + `damping_hz` at 1 s and below, geometric in log Decay between.""" + top = 0.98 * sample_rate * 0.5 + t = decay_s + if t < DAMPER_SHORT_S: + t = DAMPER_SHORT_S + if t > DAMPER_LONG_S: + t = DAMPER_LONG_S + x = math.log(DAMPER_LONG_S / t) / math.log(DAMPER_LONG_S / DAMPER_SHORT_S) + return top * (damping_hz / top) ** x + + +def decay_law(kappa, lines, sample_rate, decay_s, loop_hz): + """The Tank's `decay` for a T60 of `decay_s` at 500 Hz. + + One pass through a half multiplies by `decay` twice, the cross-feed + read and the in-loop multiply (`audiodsp_tank.c:537-538`, `:552`), and + the loop one-pole once, so + + decay = min(10^(-3 k P / (2 T fs)) / sqrt|H(500 Hz)|, + 10^(-3 k P / (2 x 1.5 T fs))) + + with P the mean half period in frames. The first term lifts `decay` by + the one-pole's loss at 500 Hz; the second caps the bass (where the + one-pole is 1) at 1.5 x T. Returns (decay, capped, t_lf): `t_lf` is the + low-frequency T60 the handed decay gives, which bounds the tail.""" + a, b = half_periods(lines) + p = 0.5 * (a + b) + fs = float(sample_rate) + mag = one_pole_mag(loop_hz, DECAY_HZ, fs) + d = 10.0 ** (-3.0 * kappa * p / (2.0 * decay_s * fs)) / math.sqrt(mag) + ceiling = 10.0 ** (-3.0 * kappa * p / (2.0 * LF_CAP * decay_s * fs)) + capped = d > ceiling + if capped: + d = ceiling + if d > 0.999: + d = 0.999 + if d < 0.0: + d = 0.0 + if 0.0 < d < 1.0: + t_lf = -3.0 * kappa * p / (2.0 * fs * math.log10(d)) + else: # pragma: no cover - never on the span + t_lf = float("inf") + return d, capped, t_lf + + +def tail_frames(sample_rate, predelay_ms, t_lf, size): + """Tier 3's bound: ceil(fs x (Predelay + 1.6 x max(1.2 x T_lf, + 1.5 x Size))).""" + longest = 1.2 * t_lf + floor = 1.5 * size + if floor > longest: + longest = floor + return int(math.ceil(sample_rate * (predelay_ms / 1000.0 + + 1.6 * longest))) + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +class Reverb(_component.Component): + """Dattorro's network as four machines: an EMT plate that is dense at + once and darkens as Decay shortens, and a room, chamber and hall that + build. The module docstring is the player's page.""" + + NAME = 'Reverb' + DISPLAY_NAME = 'Reverb' + CATEGORIES = ('Reverb',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioverb",) + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Character", "Decay", "Size", "Predelay", "Diffusion", + "Damping", "Bandwidth", "Low Cut", "Mod Depth", + "Mod Rate", "Width", "Tone", "Mix") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + 8: "UNIPOLAR", + 9: "UNIPOLAR", + 10: "UNIPOLAR", + 11: "BIPOLAR", + 12: "UNIPOLAR", + } + _MACRO_RANGES = ( + (0.0, 4.0), # 0 Character, zone min(3, int(v)) + (0.3, 10.0, "log"), # 1 Decay, T60 at 500 Hz, s + (0.5, 1.5), # 2 Size, x the character's lines + (0.0, MAX_PREDELAY_MS), # 3 Predelay, ms + (0.0, 0.9), # 4 Diffusion + (500.0, 16000.0, "log"), # 5 Damping, Hz + (500.0, 20000.0, "log"), # 6 Bandwidth, Hz + (20.0, 500.0, "log"), # 7 Low Cut, Hz + (0.0, 2.0), # 8 Mod Depth, ms half swing + (0.1, 5.0, "log"), # 9 Mod Rate, Hz + (0.0, 1.0), # 10 Width + (-12.0, 12.0), # 11 Tone, dB end to end + (0.0, 2.0), # 12 Mix; dry at unity to 1 + ) + + #: `_component.macro_of` of the dossier's section 6 settings, Character + #: at 0 / 42 / 85 / 127; patch 0 is the constructor's defaults on the + #: grid. + PATCHES = { + 0: ("Steel Plate", + (0, 75, 64, 0, 106, 25, 109, 27, 17, 75, 127, 64, 22)), + 1: ("Short Plate", + (0, 50, 64, 0, 106, 40, 115, 43, 13, 81, 114, 74, 19)), + 2: ("Damped Plate", + (0, 44, 95, 0, 106, 12, 95, 55, 13, 75, 102, 48, 19)), + 3: ("Bass-Free Plate", + (0, 65, 64, 0, 106, 25, 103, 114, 17, 75, 102, 74, 25)), + 4: ("Small Room", + (42, 15, 25, 1, 85, 91, 103, 43, 13, 68, 102, 64, 16)), + 5: ("Live Room", + (42, 44, 95, 3, 85, 102, 109, 36, 19, 63, 114, 74, 19)), + 6: ("Concert Hall", + (127, 86, 95, 16, 99, 84, 100, 32, 32, 58, 127, 64, 22)), + 7: ("Dark Chamber", + (85, 65, 64, 5, 99, 59, 86, 49, 19, 71, 102, 43, 20)), + 8: ("Bright Chamber", + (85, 61, 64, 5, 99, 116, 119, 59, 19, 71, 102, 85, 20)), + 9: ("Slow Bloom", + (127, 98, 127, 25, 113, 76, 95, 32, 64, 45, 127, 64, 29)), + } + + def _build(self, character="plate", decay=2.4, size=1.0, + predelay_ms=0.0, diffusion=0.75, damping_hz=1000.0, + bandwidth_hz=12000.0, low_cut_hz=40.0, mod_depth_ms=0.27, + mod_rate_hz=1.0, width=1.0, tone_db=0.0, mix=0.35, + patch=None): + if character == "spring": + raise ValueError( + "the spring character is parked: audioverb.Tank has no " + "dispersive chain yet, so Reverb ships plate, room, chamber " + "and hall") + if character not in CHARACTERS: + raise ValueError("character must be 'plate', 'room', 'chamber' " + "or 'hall'") + index = CHARACTERS.index(character) + self._tank = None + self._lines = None + self._taps = None + self._index = index + self._handed = {} + self._capped = False + self._t_lf = 0.0 + self._tail = 0 + #: True while several macros are applied at once (the constructor, + #: `program_change`); the Tank is refreshed once, after the last, so + #: a patch that moves Character and Size rebuilds it once. + self._deferred = False + spans = self._MACRO_RANGES + values = [4.0 * CHARACTER_MIDI[index] / 127.0] + options = (decay, size, predelay_ms, diffusion, damping_hz, + bandwidth_hz, low_cut_hz, mod_depth_ms, mod_rate_hz, + width, tone_db, mix) + for macro, value in zip(range(1, 13), options): + span = spans[macro] + values.append(_between(_option(value, DEFAULTS[macro]), + span[0], span[1])) + self._deferred = True + try: + self._init_macros(tuple(values)) + finally: + self._deferred = False + self._refresh() + if patch is not None: + self.program_change(patch) + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _character(self): + """The zone Character's position sits in: 0 plate .. 3 hall.""" + zone = int(self._value(CHARACTER_I)) + if zone > HALL: + return HALL + if zone < PLATE: + return PLATE + return zone + + # -- hooks a planted fault overrides -------------------------------- + + def _cut(self, index, size): + """(lines, taps) the Tank is built on.""" + lines = line_set(index, size, self._sample_rate) + return lines, tap_table(index, size, self._sample_rate, lines=lines) + + def _loop_hz(self, index, decay_s, damping): + """The loop one-pole's corner: the damper law on the plate, the + Damping setting elsewhere, clamped below Nyquist.""" + if index == PLATE: + return self._hz(damper_hz(decay_s, damping, self._sample_rate)) + return self._hz(damping) + + def _decay(self, index, lines, decay_s, loop_hz): + """(decay, capped, t_lf) by the Decay law.""" + return decay_law(KAPPA[index], lines, self._sample_rate, decay_s, + loop_hz) + + def _low_cut_hz(self, value): + return self._hz(value) + + def _tone_db(self, value): + """The tilt handed to the Tank, as set: the node keeps its tilt + tracking at 0 dB (audiodsp#168).""" + return value + + def _mod_rate_hz(self, value): + return value + + def _mod_depth_ms(self, value, lines): + del lines + return value + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del index, position + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh the Tank once, so a + patch that moves Character and Size rebuilds it once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def _refresh(self): + fs = self._sample_rate + index = self._character() + size = _between(self._value(SIZE_I), 0.5, 1.5) + lines, taps = self._cut(index, size) + decay_s = _between(self._value(DECAY_I), 0.3, 10.0) + loop = self._loop_hz(index, decay_s, + _between(self._value(DAMPING_I), 500.0, + 16000.0)) + decay, capped, t_lf = self._decay(index, lines, decay_s, loop) + predelay = _between(self._value(PREDELAY_I), 0.0, MAX_PREDELAY_MS) + handed = { + "decay": decay, + "diffusion": _between(self._value(DIFFUSION_I), 0.0, 0.9), + "damping_hz": loop, + "bandwidth_hz": self._hz(_between(self._value(BANDWIDTH_I), + 500.0, 20000.0)), + "low_cut_hz": self._low_cut_hz( + _between(self._value(LOW_CUT_I), 20.0, 500.0)), + "predelay_ms": predelay, + "mod_depth_ms": self._mod_depth_ms( + _between(self._value(MOD_DEPTH_I), 0.0, 2.0), lines), + "mod_rate_hz": self._mod_rate_hz( + _between(self._value(MOD_RATE_I), 0.1, 5.0)), + "drive": 0.0, + "width": _between(self._value(WIDTH_I), 0.0, 1.0), + "tone_db": self._tone_db( + _between(self._value(TONE_I), -12.0, 12.0)), + "mix": _between(self._value(MIX_I), 0.0, 2.0), + } + if self._tank is None: + self._build_tank(index, lines, taps, handed) + elif index != self._index or lines != self._lines \ + or taps != self._taps: + self._recut(index, lines, taps, handed) + else: + self._tank.set(**handed) + self._handed = handed + self._capped = capped + self._t_lf = t_lf + self._tail = tail_frames(fs, predelay, t_lf, size) + + def _build_tank(self, index, lines, taps, handed): + """Build the one Tank, at construction. Its sample rate, channel + count and predelay allocation never change after, so every later + Character or Size move is a re-cut of this node (`_recut`).""" + tank = audioverb.Tank( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_predelay_ms=MAX_PREDELAY_MS, + delays=lines, + taps=taps, + **handed) + # `clear` empties every line and filter and keeps the source frames + # the Tank has pulled and not yet played, so `reset()` does not skip + # the dry; `audiocore.reset_buffer` would drop them. + self._tank = self._own(tank, reset=tank.clear) + self._index = index + self._lines = lines + self._taps = taps + tank.play(self._source) + self._output = tank + + def _recut(self, index, lines, taps, handed): + """Re-cut the playing Tank in place (audiodsp#169): every line and + filter starts empty, as a new Tank's would, and the source frames it + holds stay, so the dry does not skip. The node allocates the new + lines before it frees the old, and a refused allocation leaves it + as it was.""" + self._tank.set(delays=lines, taps=taps, **handed) + self._index = index + self._lines = lines + self._taps = taps + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound (the dossier's Tier 3): the low-frequency T60 the + handed `decay` gives, x 1.2 for the law's error, or 1.5 x Size for + the character's own ringing, whichever is longer, x 1.6, plus the + predelay.""" + self._check_live() + return int(self._tail) diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py new file mode 100644 index 0000000..51b0fec --- /dev/null +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -0,0 +1,384 @@ +"""`SlapbackDelay` - one tape repeat after the dry, in mono: the Sun Studio slap. + +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/SlapbackDelay.md`). +""" + +VENDOR = "PyDevices" + +import math + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's tail arithmetic, reused rather than copied: the same node +# rounds the same way in both classes. Its module moves up one level when +# it comes home, so both homes are tried. +try: + from .digitaldelay import laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import laps_to_zero, whole_frames + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +#: The Time span, fixed on every instance (dossier section 6): 40-250 ms, log. +TIME_MIN_MS = 40.0 +TIME_MAX_MS = 250.0 + +#: The line: Time's top plus 1 ms. The node clamps a delay at +#: `line_frames - 2` (`audiodsp_feedback_delay.c:148-150`), and the read +#: reaches 250 ms plus the wow's 22.1 frames at 3.5 cents; 251 ms clears +#: that at every rate (dossier Tier 3, A8.6). +LINE_MS = TIME_MAX_MS + 1.0 + +#: The Tone span's corners; the top stop is exactly `damping_hz = 0`. +TONE_MIN_HZ = 2000.0 +TONE_MAX_HZ = 20000.0 + +#: The wow's fixed rate and the Wow knob's ceiling, in cents peak. +WOW_HZ = 0.7 +WOW_MAX_CENTS = 3.5 + +#: The Repeats knob's top, handed to the node's `feedback`. +REPEATS_MAX = 0.6 + +#: The fixed Time walk, delay-seconds per second: 3/16, exact in float32, +#: so every step of the walk is exact at 48 kHz (dossier section 8.6). +SLEW = 0.1875 + +#: How close, as a 0..1 knob position, a Time write must come to the +#: constructor's own seeded position to count as a host echoing it back: +#: 1e-6 is 1/7 874 of one MIDI step, and holds a single-precision board's +#: round trip through the 0-127 scale. +ECHO_TOLERANCE = 1e-6 + +TIME_I, LEVEL_I, SATURATION_I, TONE_I, WOW_I, REPEATS_I = range(6) + +#: The constructor defaults, which NaN falls back to. +DEFAULTS = (135.0, 0.35, 0.15, TONE_MAX_HZ, 1.0, 0.0) + + +def wow_depth_ms(cents): + """The Wow knob's cents -> the node's `wow_depth_ms` at 0.7 Hz. + + A delay swinging D sin(2 pi f t) ms moves pitch by a peak ratio of + 1 + 2 pi f D / 1000, so the rising side reaches `cents` at + D = (2^(cents/1200) - 1) / (2 pi 0.7) x 1000 (dossier A4): 1.0 cent is + 0.1314 ms, 3.5 cents 0.4601 ms. Clamped to 0..3.5 cents.""" + cents = float(cents) + if not cents > 0.0: + return 0.0 + if cents > WOW_MAX_CENTS: + cents = WOW_MAX_CENTS + return ((2.0 ** (cents / 1200.0) - 1.0) / (2.0 * math.pi * WOW_HZ) + * 1000.0) + + +def tone_excess(damping_hz, sample_rate): + """(frames, relative excess) for a loop low-pass at `damping_hz` (already + pre-warped): after `frames` frames whatever its state held weighs under + 2^-17 of it, and its single-precision state can rest up to + 2^-24 / a above the line's peak, a being the coefficient. `DigitalDelay`'s + `_tone_excess`, as a function of the handed value. (0, 0.0) with Tone + out.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +class SlapbackDelay(_component.Component): + """One tape repeat after the dry, in the same place: the Sun Studio slap. + + Your dry signal passes untouched, and one copy of it comes back a moment + later, a little quieter: the two-machine tape echo on Sam Phillips' Sun + sides. + By default the repeat comes 135 ms after the dry, once. + + **The controls.** Time is the gap between the dry and the repeat. Level + is the console's return. Saturation is how hard the return drove the + record amplifier. Tone is the tape path's top end. Wow is the + transport's slow wobble. Repeats sends the slap round again. + Time runs from 40 to 250 ms, Level from 0 to 2, Saturation from 0 to 1, + Tone from 2 kHz to out at its top stop, Wow from 0 to 3.5 cents and + Repeats from 0 to 0.6. + Level 0 is a wire. + Up to Level 1 the dry passes untouched until the repeat arrives, however + hard Saturation drives the repeat. + A hot input can reach the rail, since the repeat adds to a dry at unity. + At Repeats 0 there is one repeat and no second. + Repeats above 0 sends the repeat round for more. + Wow swings the repeat's pitch by the cents the knob reads, at a slow + fixed rate. + The default Wow takes the repeat's very top more than 4 dB down at + Nyquist, where Wow 0 leaves it within half a dB. + At 22.05 kHz the last Tone positions below the top stop clamp below + Nyquist and all do the same thing. + + **Time.** Every Time position lands on the nearest whole frame at + 48 kHz. + At 44.1 and 22.05 kHz the node lands some positions a fraction of a + frame off, and a sliver of the repeat falls on the frame beside it. + A host that writes back `get_macro(0)` keeps the constructor's exact + Time. + Turning Time walks the repeat to the new Time, bending its pitch, + instead of clicking. + A Wow move glides instead of stepping. + + **Mono, latency, tail.** A source the same in both channels comes out + the same in both channels, and a one-channel source gets the stereo + render's left channel. + A click comes out on the frame it went in: there is no latency. + `tail_samples` is an upper bound on how many frames the output takes to + reach exact zero, counted from when your input stops or from when you + read it if that is later, for the settings as they stand when you read + it. + `reset()` empties the line and returns to patch 0. + The class never reads the host's tempo. + A constructor value outside a knob's span clamps to the nearer stop, a + `tone_hz` of 0 or less is Tone out, and NaN takes the option's default. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + The tail rings only while the source keeps feeding: feed silence to let + it ring out. A tail cut short by a source that stopped carries on when + the source comes back. + """ + + NAME = 'SlapbackDelay' + DISPLAY_NAME = 'Slapback Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Level", "Saturation", "Tone", "Wow", "Repeats") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, 2.0), # 1 Level; dry at unity to 1 + (0.0, 1.0), # 2 Saturation, loop_drive + (TONE_MIN_HZ, TONE_MAX_HZ, "log"), # 3 Tone, Hz; top = out + (0.0, WOW_MAX_CENTS), # 4 Wow, cents peak at 0.7 Hz + (0.0, REPEATS_MAX), # 5 Repeats, feedback + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Single Slap", (84, 22, 19, 127, 36, 0)), + 1: ("Short Slap", (52, 22, 19, 127, 36, 0)), + 2: ("Doubling", (0, 32, 19, 127, 73, 0)), + 3: ("Hot Return", (84, 22, 89, 127, 36, 0)), + 4: ("Two Repeats", (84, 22, 19, 127, 36, 74)), + 5: ("Dark Slap", (84, 22, 19, 51, 36, 0)), + } + + def _build(self, time_ms=135.0, level=0.35, saturation=0.15, + tone_hz=TONE_MAX_HZ, wow_cents=1.0, repeats=0.0, patch=None): + self._frames = 1 + #: The longest delay, in whole frames, the read head may still sit + #: at. The walk starts from wherever the head is and the class cannot + #: see how far it has got, so after a falling move this keeps the old + #: Time until a reset lands the head (dossier section 8.7). + self._reach = 1 + #: True while the node has been built or cleared and not yet told a + #: second Time: it snaps onto the configured delay on its first pull. + self._fresh = True + #: True while several macros are applied at once (the constructor, + #: `program_change`); the node is refreshed once, after the last. + self._deferred = False + self._feedback = 0.0 + self._damping = 0.0 + self._wow_ms = 0.0 + self._node_ms = 0.0 + #: The constructor's Time, exactly, until macro 0 moves. Seeding a + #: log knob and reading it back is not exact: 135.0 ms comes back a + #: few ulps under, which at 44.1 kHz (5 953.5 frames) lands on 5 953 + #: instead of the 5 954 the whole-frame law gives 135.0. + self._time_exact = None + #: The knob position the constructor's Time seeded, which a host's + #: echo of `get_macro(0)` comes back to within `ECHO_TOLERANCE`. + self._time_seed = -1.0 + self._seeding = True + # A log knob cannot seed 0 or a negative, so those clamp to the + # nearer stop here; 0 or less is how the node spells Tone out. + time_ms = _between(_option(time_ms, DEFAULTS[TIME_I]), + TIME_MIN_MS, TIME_MAX_MS) + tone_hz = _option(tone_hz, DEFAULTS[TONE_I]) + if not tone_hz > 0.0: + tone_hz = TONE_MAX_HZ + tone_hz = _between(tone_hz, TONE_MIN_HZ, TONE_MAX_HZ) + values = (time_ms, + _option(level, DEFAULTS[LEVEL_I]), + _option(saturation, DEFAULTS[SATURATION_I]), + tone_hz, + _option(wow_cents, DEFAULTS[WOW_I]), + _option(repeats, DEFAULTS[REPEATS_I])) + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=LINE_MS, + delay_ms=time_ms, + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + delay_slew=0.0) + # `clear()` empties the line and the loop filters and re-primes the + # read head (`audiodsp_feedback_delay.c:291-299`), so a reset is + # silent and snaps onto patch 0's Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + self._time_exact = time_ms + self._deferred = True + try: + self._init_macros(values) + finally: + self._deferred = False + self._seeding = False + self._time_seed = self._macros[TIME_I] + self._refresh() + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _node_time_ms(self, frames): + """What the node is handed for a whole-frame Time.""" + return frames * 1000.0 / self._sample_rate + + def _tone_damping(self, position): + """Macro 3's position -> the node's `damping_hz`: the corner the + loop low-pass achieves, clamped below Nyquist and pre-warped. The + top stop is exactly 0 (out of circuit) and is never pre-warped.""" + if position >= 1.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[TONE_I], position) + return nominal_damping_hz(self._hz(corner), self._sample_rate) + + def _wow_depth_ms(self, cents): + return wow_depth_ms(cents) + + # -- applying ------------------------------------------------------ + + def _time_ms(self): + """The Time the audio path plays, before it is landed.""" + if self._time_exact is not None: + return self._time_exact + return self._value(TIME_I) + + def _apply_macro(self, index, position): + if index == TIME_I and not self._seeding and not ( + self._time_exact is not None + and abs(position - self._time_seed) <= ECHO_TOLERANCE): + # A host that reads Time back and writes the same position + # (`set_macro(0, get_macro(0))`) keeps the constructor's exact + # Time; any other position drops it. + self._time_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh the node once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + self._fresh = False + + def _refresh(self): + self._frames = max(1, whole_frames(self._time_ms(), + self._sample_rate)) + self._node_ms = self._node_time_ms(self._frames) + if self._fresh or self._frames > self._reach: + self._reach = self._frames + # Tone out is exactly 0, and Repeats is handed as set: since + # audiodsp v0.6.3rc1 the node keeps an out low-pass on the signal + # (#158) and lands a stalled one (#157). + damping = self._tone_damping(self._macros[TONE_I]) + feedback = _between(self._value(REPEATS_I), 0.0, REPEATS_MAX) + self._feedback = feedback + self._damping = damping + self._wow_ms = self._wow_depth_ms(self._value(WOW_I)) + self._delay.set( + delay_slew=SLEW, + delay_ms=self._node_ms, + feedback=feedback, + mix=_between(self._value(LEVEL_I), 0.0, 2.0), + loop_drive=_between(self._value(SATURATION_I), 0.0, 1.0), + damping_hz=damping, + cut_hz=0.0, + wow_hz=WOW_HZ, + wow_depth_ms=self._wow_ms) + + @property + def tail_samples(self): + """What this bound promises is in the class docstring; how it is + built is in the dossier.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`.""" + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + wow =int(math.ceil(self._wow_ms * self._sample_rate / 1000.0)) + return int(laps * (self._reach + wow + 1 + memory)) diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py new file mode 100644 index 0000000..209e2f2 --- /dev/null +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -0,0 +1,730 @@ +"""`TapeDelay` - a tape echo with two transports: the Roland RE-201's motor +and the Maestro EP-3's sliding head. + +Your dry signal passes untouched, and the repeats follow it off a loop of +tape, each one a little darker than the last. + +**Controls.** Time is the delay, from 20 to 1200 ms, and Feedback is how +much of each repeat goes round again, up to 0.99. Mix is the echo return: +the dry stays at unity up to Mix 1, Mix 2 is the repeats alone, and at Mix 0 +the output is the input. Spacing is how far the worn head sits off the tape: +more spacing, darker repeats. Wow and Flutter are the transport's slow and +fast wobble. Record Level drives the tape harder, a soft squash on every +pass. Spread feeds each channel's repeats into the other, and does nothing +on a mono source. With Sync on, Time is Division of the host's beat; with no +host tempo, Time stays where the knob is. + +**Two characters.** `character="varispeed"`, the default, is the RE-201: +Time moves the motor, so a Time move bends the pitch of everything on the +tape instead of clicking, then settles. Glide does nothing on this +character. `character="sliding-head"` is the EP-3: Time slides a head, so the pitch +bends only while the head moves, at the rate Glide sets, and the tape runs +at one speed, so the darkening does not follow Time. Glide 0 is an instant +slide, and its price is a click. + +**Where the pitch bend stops.** A Time move made while the last one is still +bending takes its rate from the last Time you set, not from where the tape +has got to, so it does not telescope as a real motor would. A long rising +move, or a rising slide at the fastest Glides, can read more than 10 cents +off the ideal bend, because the node walks its read head in single +precision. + +**The tape.** The wobble is periodic, not random. A Wow or Flutter move that +changes only how deep the wobble is glides in, and one that changes their +balance steps, so set the balance before you play. The darkening follows +the tape's loss law only up to a band top that rises with the tape's speed. +Record Level has no memory: tape hysteresis is not modelled. The RE-201's +Bass and Treble are not here. + +**Limits shared by the family.** A control that jumps makes the output step: +move it in small steps from the host if you need it smooth. The tail rings +only while the source keeps feeding: feed silence to let it ring out. A tail +cut short by a source that stopped carries on when the source comes back. + +**Latency, tail, portability.** Latency is zero samples: nothing looks ahead. +`tail_samples` is an upper bound on how long the output takes to reach exact +zero once your input stops, at every Feedback and Spread, stereo and mono. +Pass a lower `max_time_ms` for a shorter line: Time then stops at that +ceiling, and `get_macro(0)` shows where it stopped. The class needs +audiodsp's `audioecho`, and on a board without it construction raises +`ImportError`. `character` must be `"varispeed"` or `"sliding-head"`. + +**Cost.** Measured at v0.6.2 at the default and every patch, the full class +costs 0.551-0.608 ms a block on the P4 and 1.056-1.093 ms on the S3, over the +budgets of 0.480 ms and 0.800 ms. Measured at v0.6.2, patch 8 on a class +built with `max_time_ms=800` costs 0.445-0.455 ms on the P4 and 0.781-0.797 +ms on the S3, inside both budgets. +`reset()` returns to patch 0, so a host that wants the lean patch sets it +again after a reset. +""" + +VENDOR = "PyDevices" + +from array import array +import math + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's tail and transport arithmetic, reused rather than copied: +# the same node rounds the same way in both classes. Its module moves up +# one level when it comes home, so both homes are tried. +try: + from .digitaldelay import DIVISION_BEATS, laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import DIVISION_BEATS, laps_to_zero, whole_frames + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +CHARACTERS = ("varispeed", "sliding-head") +VARISPEED, SLIDING_HEAD = CHARACTERS + +#: The Time map, fixed on every instance (dossier section 6): 20-1 200 ms. +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 1200.0 + +#: Glide is the time a full-range Time move takes on sliding-head; the +#: node's `delay_slew` is FULL_RANGE_MS / glide_ms (dossier section 6). +FULL_RANGE_MS = TIME_MAX_MS - TIME_MIN_MS + +#: The Glide knob's span, log, with grid 0 the jump. +GLIDE_MIN_MS = 1200.0 +GLIDE_MAX_MS = 12000.0 + +#: At slew 1 a rising Time stands the read head still. A constructor Glide +#: under 1 191.9 ms is pinned here; no grid position gets there (grid 1 is +#: 1 222.0 ms, slew 0.966). +SLEW_PIN = 0.99 + +#: The Glide knob's position for a constructor Glide faster than grid 1: +#: grid 1 itself, never grid 0, the jump (DigitalDelay's `GLIDE_FLOOR`). +GLIDE_FLOOR = 1.0 / 127.0 + +#: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). +FEEDBACK_MAX = 0.99 + +#: The line's headroom over `max_time_ms`: one frame for the node's +#: `line_frames - 2` clamp (`audiodsp_feedback_delay.c:148-150`) plus the +#: wow table's largest excursion, 3.096 ms at the Wow and Flutter stops, +#: rounded up (dossier Tier 3). +LINE_HEADROOM_MS = 5.0 + +#: S3 eq. (13)'s play gap and tape thickness, metres (dossier section 6). +GAP_M = 5e-6 +THICK_M = 35e-6 + +#: The Spacing knob, micrometres, log. +SPACING_MIN_UM = 2.0 +SPACING_MAX_UM = 20.0 + +#: The speed law (dossier section 6). Varispeed: 40 cm/s at Time 180 ms and +#: below, 12 cm/s at 600 ms and above, 40 x 180 / T between (fixed heads: +#: T is inversely v). Sliding-head: the Echoplex's roughly 8 ips. +V_FAST = 0.40 +V_SLOW = 0.12 +T_FAST_MS = 180.0 +T_SLOW_MS = 600.0 +V_SLIDING = 0.2032 + +#: The wow table: one period of TABLE_POINTS Q15 points at WOW_HZ, holding +#: the wow line at harmonic WOW_HARMONIC, the flutter line at +#: FLUTTER_HARMONIC and the drift at harmonics 1-9, amplitude 1/k, at +#: DRIFT_PHASES (dossier section 6; `tapedelay_stationA_common.py:59`). +#: TABLE_POINTS is not a cost lever. The node reads any length the same way, +#: and a shorter table silently kills the flutter: at 1 024 points harmonic +#: 512 sits on the table's Nyquist, at 256 it folds onto DC, and the Flutter +#: knob then writes the same table as Flutter 0 while T4 still reads two +#: lines, an interpolation image of the wow line (the 2026-09-28 cost study, +#: `audits/phase5/tapedelay-cost-options.md`). +WOW_HZ = 0.01 +TABLE_POINTS = 4096 +WOW_HARMONIC = 72 +FLUTTER_HARMONIC = 512 +WOW_LINE_HZ = 0.72 +FLUTTER_LINE_HZ = 5.12 +DRIFT_PHASES = (0.37, 2.91, 5.02, 1.18, 4.40, 3.33, 0.84, 5.71, 2.26) +#: The drift's peak excursion, ms per cent of Wow. +DRIFT_PER_WOW_CENT_MS = 0.25 +WOW_MAX_CENTS = 8.0 +FLUTTER_MAX_CENTS = 4.0 + +(TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, WOW_I, FLUTTER_I, RECORD_I, SPACING_I, + SPREAD_I, SYNC_I, DIVISION_I) = range(11) + +#: How close, as a 0..1 knob position, a Time write must come to the +#: constructor's own seeded position to count as a host echoing it back. +ECHO_TOLERANCE = 1e-6 + +#: The constructor defaults, which NaN falls back to. +DEFAULTS = (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 0.0, 6.0) + + +# -- the loss law ------------------------------------------------------------- + +def eq13_gain(k, spacing_m): + """S3 eq. (13), one pass, as a linear gain at wavenumber `k` (rad/m): + spacing e^(-k d), thickness (1 - e^(-k delta)) / (k delta) and gap + |sinc(k g / 2)|, at `GAP_M` and `THICK_M`.""" + spacing = math.exp(-k * spacing_m) + kd = k * THICK_M + thickness = (1.0 - math.exp(-kd)) / kd + half = k * GAP_M / 2.0 + gap = abs(math.sin(half) / half) + return spacing * thickness * gap + + +def k3_of(spacing_m): + """The wavenumber at which eq. (13) is at half power (-3.01 dB), by + bisection in log k: 15 967 rad/m (393.5 um) at 5 um.""" + lo, hi = 1.0, 1e7 + for _ in range(200): + mid = math.sqrt(lo * hi) + gain = eq13_gain(mid, spacing_m) + if gain * gain > 0.5: + lo = mid + else: + hi = mid + return math.sqrt(lo * hi) + + +def speed(character, time_ms): + """Tape speed, m/s, for a character at a Time (dossier section 6).""" + if character == SLIDING_HEAD: + return V_SLIDING + t = float(time_ms) + if t < T_FAST_MS: + t = T_FAST_MS + if t > T_SLOW_MS: + t = T_SLOW_MS + return V_FAST * T_FAST_MS / t + + +def tone_excess(damping_hz, sample_rate): + """(frames, relative excess) for the loop low-pass at `damping_hz` + (already pre-warped): after `frames` frames whatever its state held + weighs under 2^-17 of it, and its single-precision state can rest up to + 2^-24 / a above the line's peak, a being the coefficient. + `DigitalDelay`'s `_tone_excess`, as a function of the handed value.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + + +# -- the glide laws ----------------------------------------------------------- + +def slew_of(glide_ms): + """Sliding-head's `delay_slew` for a Glide: 0 (the jump) at Glide 0, + otherwise 1 180 ms over `glide_ms`, pinned at 0.99.""" + glide_ms = float(glide_ms) + if not glide_ms > 0.0: + return 0.0 + slew = FULL_RANGE_MS / glide_ms + if slew > SLEW_PIN: + slew = SLEW_PIN + return slew + + +def varispeed_slew(from_ms, to_ms): + """The tape equation for a speed step from a settled transport: + dT/dt = 1 - T_old / T_new, so the walk runs |dT| / T_new + delay-seconds per second and lasts exactly T_new (S4 eq. 3).""" + return abs(float(to_ms) - float(from_ms)) / float(to_ms) + + +# -- the wow table ------------------------------------------------------------ + +_SINE = None +_DRIFT = None + + +def _shapes(): + """(one period of sine, the unit drift) at `TABLE_POINTS`, float32, + computed once and shared by every instance. The drift is harmonics 1-9 + at amplitude 1/k and `DRIFT_PHASES`, scaled to a peak of 1.""" + global _SINE, _DRIFT + if _SINE is None: + points = TABLE_POINTS + mask = points - 1 + quarter = points // 4 + sine = array("f", [0.0] * points) + for n in range(points): + sine[n] = math.sin(2.0 * math.pi * n / points) + weights = [(k, math.cos(p) / k, math.sin(p) / k) + for k, p in zip(range(1, 10), DRIFT_PHASES)] + drift = array("f", [0.0] * points) + peak = 0.0 + for n in range(points): + total = 0.0 + for k, c, s in weights: + i = k * n + total += (sine[i & mask] * c + sine[(i + quarter) & mask] * s) + drift[n] = total + if abs(total) > peak: + peak = abs(total) + for n in range(points): + drift[n] = drift[n] / peak + _DRIFT = drift + _SINE = sine + return _SINE, _DRIFT + + +def cents_to_depth_ms(cents, line_hz): + """A delay D sin(2 pi f t) ms moves pitch by a peak ratio of + 1 + 2 pi f D / 1000, so `cents` peak is + D = (2^(cents/1200) - 1) / (2 pi f) x 1000: 8 cents of the 0.72 Hz line + is 1.024 ms, 4 cents of the 5.12 Hz line 0.072 ms.""" + cents = float(cents) + if not cents > 0.0: + return 0.0 + return ((2.0 ** (cents / 1200.0) - 1.0) / (2.0 * math.pi * line_hz) + * 1000.0) + + +def wow_table(wow_cents, flutter_cents, out, flutter_harmonic=FLUTTER_HARMONIC, + drift=True): + """Write the wow table for (Wow, Flutter) into `out`, an int16 array of + `TABLE_POINTS`, and return its peak excursion in ms, which is the + node's `wow_depth_ms`; 0.0 (and `out` untouched) with nothing to write. + + The wow line's peak is `cents_to_depth_ms(wow, 0.72 Hz)`, the flutter + line's `cents_to_depth_ms(flutter, 5.12 Hz)` and the drift's 0.25 ms per + cent of Wow; the table is their sum normalised to its own peak.""" + w = cents_to_depth_ms(wow_cents, WOW_LINE_HZ) + f = cents_to_depth_ms(flutter_cents, FLUTTER_LINE_HZ) + d = DRIFT_PER_WOW_CENT_MS * float(wow_cents) if drift else 0.0 + if not d > 0.0: + d = 0.0 + if w <= 0.0 and f <= 0.0 and d <= 0.0: + return 0.0 + sine, walk = _shapes() + mask = TABLE_POINTS - 1 + peak = 0.0 + for n in range(TABLE_POINTS): + x = (w * sine[(WOW_HARMONIC * n) & mask] + + f * sine[(flutter_harmonic * n) & mask] + d * walk[n]) + if abs(x) > peak: + peak = abs(x) + scale = 32767.0 / peak + for n in range(TABLE_POINTS): + x = (w * sine[(WOW_HARMONIC * n) & mask] + + f * sine[(flutter_harmonic * n) & mask] + d * walk[n]) + out[n] = int(math.floor(x * scale + 0.5)) + return peak + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +class TapeDelay(_component.Component): + """A tape echo with the RE-201's motor or the EP-3's sliding head. + The module docstring is the player's manual.""" + + NAME = 'TapeDelay' + DISPLAY_NAME = 'Tape Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Feedback", "Mix", "Glide", "Wow", "Flutter", + "Record Level", "Spacing", "Spread", "Sync", "Division") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + 8: "UNIPOLAR", + 9: "TOGGLE", + 10: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (0.0, 2.0), # 2 Mix; dry at unity to 1 + (GLIDE_MIN_MS, GLIDE_MAX_MS, "log"), # 3 Glide, ms; grid 0 = jump + (0.0, WOW_MAX_CENTS), # 4 Wow, cents at 0.72 Hz + (0.0, FLUTTER_MAX_CENTS), # 5 Flutter, cents at 5.12 Hz + (0.0, 1.0), # 6 Record Level, loop_drive + (SPACING_MIN_UM, SPACING_MAX_UM, "log"), # 7 Spacing, um + (0.0, 1.0), # 8 Spread, cross_feed + (0.0, 1.0), # 9 Sync + (0.0, 15.0), # 10 Division index + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. Patch 8 is patch 0 with Record + #: Level at 0, the board's lean patch (with `max_time_ms=800`; the module + #: docstring's Cost). + PATCHES = { + 0: ("Warm Repeats", (89, 58, 22, 89, 32, 32, 25, 51, 0, 0, 51)), + 1: ("Long Repeats, Slow Glide", + (114, 71, 22, 127, 32, 32, 25, 51, 0, 0, 51)), + 2: ("Short Slap", (47, 19, 32, 89, 16, 16, 25, 51, 0, 0, 51)), + 3: ("Dark Repeats, Heavy Wow", + (97, 71, 22, 89, 95, 64, 25, 111, 0, 0, 51)), + 4: ("High Intensity", (93, 115, 25, 89, 32, 32, 76, 51, 0, 0, 51)), + 5: ("Worn Heads", (89, 58, 22, 89, 64, 95, 64, 127, 0, 0, 51)), + 6: ("Clean Transport", (89, 58, 22, 89, 0, 0, 0, 0, 0, 0, 51)), + 7: ("Dotted Eighth, Synced", + (89, 58, 22, 89, 32, 32, 25, 51, 0, 127, 68)), + 8: ("Tape Delay - lean", (89, 58, 22, 89, 32, 32, 0, 51, 0, 0, 51)), + } + + def _build(self, time_ms=350.0, feedback=0.45, mix=0.35, glide_ms=6000.0, + wow_cents=2.0, flutter_cents=1.0, record_level=0.2, + spacing_um=5.0, spread=0.0, sync=False, division=6, + character=VARISPEED, max_time_ms=TIME_MAX_MS, patch=None): + if character not in CHARACTERS: + raise ValueError( + "character must be 'varispeed' or 'sliding-head'") + self._character = character + max_time_ms = float(max_time_ms) + # `not <=` catches NaN, which would otherwise pass both clamps and + # size the line from nothing. + if not max_time_ms <= TIME_MAX_MS: + max_time_ms = TIME_MAX_MS + if max_time_ms < TIME_MIN_MS: + max_time_ms = TIME_MIN_MS + self._max_time_ms = max_time_ms + #: The whole-frame Time last handed to the node, and in ms. + self._frames = 0 + self._node_ms = 0.0 + #: The Time the loss corner is read at: the clamped Time before it + #: is landed on a frame. + self._time_played = 0.0 + #: The longest delay, in whole frames, the read head may still sit + #: at. A walk starts from wherever the head is and the class cannot + #: see how far it has got, so after a falling move this keeps the old + #: Time until a jump or a reset lands the head. + self._reach = 1 + #: True while the node has been built or cleared and not yet told a + #: second Time: it snaps onto the configured delay on its first pull. + self._fresh = True + #: True while several macros are applied at once (the constructor, + #: `program_change`); the node is refreshed once, after the last. + self._deferred = False + self._slew = 0.0 + self._feedback = 0.0 + self._damping = 0.0 + self._corner = 0.0 + self._spread = 0.0 + self._wow_ms = 0.0 + #: (Wow, Flutter) the current table was written for. + self._wow_key = None + #: Two tables: the node borrows one while a move writes the other. + self._tables = (array("h", [0] * TABLE_POINTS), + array("h", [0] * TABLE_POINTS)) + self._table_index = 1 + self._table = None + #: k3 for the Spacing last asked, keyed by the Spacing in metres. + self._k3_key = None + self._k3 = 0.0 + + # A log knob cannot seed 0 or a negative; those clamp to the bottom. + time_ms = _option(time_ms, DEFAULTS[TIME_I]) + if not time_ms > 0.0: + time_ms = TIME_MIN_MS + time_ms = _between(time_ms, TIME_MIN_MS, TIME_MAX_MS) + spacing_um = _option(spacing_um, DEFAULTS[SPACING_I]) + if not spacing_um > 0.0: + spacing_um = SPACING_MIN_UM + #: A constructor Glide stays on the audio path until macro 3 moves: + #: the span starts at 1 200 ms, so a faster Glide (down to the 0.99 + #: pin) or an exact 0 has no knob position. A slower one is clamped + #: to the span's top, 12 s. 0, a negative and NaN are the jump. + glide_ms = float(glide_ms) + if glide_ms > GLIDE_MAX_MS: + glide_ms = GLIDE_MAX_MS + if not glide_ms > 0.0: + glide_ms = 0.0 + self._glide_exact = glide_ms + #: The constructor's Time, exactly, until macro 0 moves. Seeding a + #: log knob and reading it back is not exact: 350.0 ms comes back a + #: few ulps under, which at 22.05 kHz (7 717.5 frames) would land on + #: 7 717 instead of the 7 718 the whole-frame law gives 350.0. + self._time_exact = time_ms + self._time_seed = -1.0 + self._seeding = True + values = (time_ms, + _option(feedback, DEFAULTS[FEEDBACK_I]), + _option(mix, DEFAULTS[MIX_I]), + max(GLIDE_MIN_MS, glide_ms), + _option(wow_cents, DEFAULTS[WOW_I]), + _option(flutter_cents, DEFAULTS[FLUTTER_I]), + _option(record_level, DEFAULTS[RECORD_I]), + spacing_um, + _option(spread, DEFAULTS[SPREAD_I]), + 1.0 if sync else 0.0, + _option(division, DEFAULTS[DIVISION_I])) + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=max_time_ms + LINE_HEADROOM_MS, + delay_ms=self._clamp_ms(time_ms), + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + delay_slew=0.0) + # `clear()` empties the line and the loop filters and re-primes the + # read head, so a reset is silent and snaps onto the current Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + self._deferred = True + try: + self._init_macros(values) + finally: + self._deferred = False + self._seeding = False + # The knob is seeded where the constructor's Glide is, or at grid 1 + # for a faster one; `_glide_exact` carries the real value, so a + # get_macro / set_macro round trip keeps it gliding. + if self._glide_exact > 0.0 and self._macros[GLIDE_I] < GLIDE_FLOOR: + self._macros[GLIDE_I] = GLIDE_FLOOR + self._refresh() + # Read after the refresh, which re-seats a Time above `max_time_ms`. + self._time_seed = self._macros[TIME_I] + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _clamp_ms(self, time_ms): + time_ms = float(time_ms) + if time_ms > self._max_time_ms: + return self._max_time_ms + if time_ms < TIME_MIN_MS: + return TIME_MIN_MS + return time_ms + + def _time_ms(self): + """The Time the audio path plays, before it is clamped and landed.""" + if self._time_exact is not None: + return self._time_exact + return self._value(TIME_I) + + def _glide_ms(self): + if self._glide_exact is not None: + return self._glide_exact + if self._macros[GLIDE_I] <= 0.0: + return 0.0 + return self._value(GLIDE_I) + + def _walk_rate(self, from_ms, to_ms): + """The node's `delay_slew` for a Time move: the tape equation on + varispeed, the Glide law on sliding-head.""" + if self._character == VARISPEED: + return varispeed_slew(from_ms, to_ms) + return slew_of(self._glide_ms()) + + def _corner_hz(self, time_ms, spacing_um): + """The loop low-pass's -3 dB corner, before the clamp and the + pre-warp: eq. (13)'s half-power point, v k3(d) / 2 pi.""" + spacing_m = spacing_um * 1e-6 + if spacing_m != self._k3_key: + self._k3 = k3_of(spacing_m) + self._k3_key = spacing_m + return speed(self._character, time_ms) * self._k3 / (2.0 * math.pi) + + def _write_table(self, wow_cents, flutter_cents, out): + """Hook for the table law; returns the depth in ms.""" + return wow_table(wow_cents, flutter_cents, out) + + def _transport_state(self): + transport = self._transport + state = transport() if callable(transport) else transport + return transport, state + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one), where Time stays where the knob is.""" + transport, state = self._transport_state() + if transport is _component.static_transport: + return None + # A host whose tempo is not a finite positive number (0, None, a + # negative, NaN or infinity) leaves Time on the knob. `not bpm > 0` + # catches NaN, and `bpm * 0` is NaN for infinity. + bpm = float(state[2] or 0.0) + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + if not self._seeding: + if index == GLIDE_I: + self._glide_exact = None + elif index == TIME_I and not ( + self._time_exact is not None + and abs(position - self._time_seed) <= ECHO_TOLERANCE): + # A host that reads Time back and writes the same position + # keeps the constructor's exact Time; any other drops it. + self._time_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh the node once, so Time + is read against the new patch's Sync and moves once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + # The cleared node snaps onto the current Time on its next pull. + self._reach = max(1, self._frames) + self._fresh = False + + def _refresh(self): + fs = self._sample_rate + span = self._MACRO_RANGES[TIME_I] + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + # Division quantises Time into the same map, and the clamp + # shows through get_macro(0) the same way. + self._time_exact = None + self._macros[TIME_I] = _component.macro_position( + span, self._clamp_ms(synced)) + time_ms = self._time_ms() + clamped = self._clamp_ms(time_ms) + if clamped != time_ms: + if self._time_exact is not None: + self._time_exact = clamped + self._macros[TIME_I] = _component.macro_position(span, clamped) + frames = max(1, whole_frames(clamped, fs)) + node_ms = frames * 1000.0 / fs + if self._character == VARISPEED: + # The motor's law sets each move's rate from the Time it leaves; + # with no move the walk in progress keeps its rate. + if frames != self._frames and not self._fresh: + self._slew = self._walk_rate(self._node_ms, node_ms) + else: + self._slew = self._walk_rate(self._node_ms, node_ms) + if self._fresh or self._slew <= 0.0: + # A fresh node snaps onto the target, and with the slew off the + # read head jumps there on the next frame. + self._reach = frames + elif frames > self._reach: + self._reach = frames + self._frames = frames + self._node_ms = node_ms + self._time_played = clamped + + spacing = _between(self._value(SPACING_I), SPACING_MIN_UM, + SPACING_MAX_UM) + self._corner = self._corner_hz(clamped, spacing) + self._damping = nominal_damping_hz(self._hz(self._corner), fs) + # Handed as set: since audiodsp v0.6.3rc1 the node lands a loop + # low-pass that has stopped moving (#157), so no Feedback holds a + # small value for ever and nothing is stepped clear here. + self._feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) + + wow = _between(self._value(WOW_I), 0.0, WOW_MAX_CENTS) + flutter = _between(self._value(FLUTTER_I), 0.0, FLUTTER_MAX_CENTS) + key = (wow, flutter) + if key != self._wow_key: + # Write the table the node is not reading, then hand it over. + spare = 1 - self._table_index + depth = self._write_table(wow, flutter, self._tables[spare]) + if depth > 0.0: + self._table_index = spare + self._table = self._tables[spare] + elif self._fresh: + self._table = None + # Otherwise a playing node keeps the table it has: the node ramps + # the old depth out over 20 ms (audiodsp#160), and without a + # table it would ramp it out on its own sine instead, a jump in + # the read offset. At depth 0 the table moves nothing. + self._wow_ms = depth + self._wow_key = key + + # At one channel the node's cross-feed sends the repeat nowhere. + # At two, Spread is handed as set: since audiodsp v0.6.3rc3 a + # cross-fed tail reaches exact zero however the node rounds the + # cross-feed sum (audiodsp#173), so the 1/4096 grid is gone. + if self._channel_count == 1: + self._spread = 0.0 + else: + self._spread = _between(self._value(SPREAD_I), 0.0, 1.0) + self._delay.set( + delay_slew=self._slew, + delay_ms=node_ms, + feedback=self._feedback, + mix=_between(self._value(MIX_I), 0.0, 2.0), + loop_drive=_between(self._value(RECORD_I), 0.0, 1.0), + damping_hz=self._damping, + cut_hz=0.0, + cross_feed=self._spread, + wow_hz=WOW_HZ, + wow_depth_ms=self._wow_ms, + wow_shape=self._table) + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound: `laps_to_zero(f, excess)` laps of the longest delay + the read head may be at, plus the wow table's peak excursion in + frames rounded up, plus one frame for the interpolated read, plus + the loop low-pass's memory. Finite at every setting the class + reaches, stereo and mono, at every Spread.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`.""" + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + wow = int(math.ceil(self._wow_ms * self._sample_rate / 1000.0)) + return int(laps * (self._reach + wow + 1 + memory)) diff --git a/lib/audioeffects/saturation.py b/lib/audioeffects/saturation.py index da64fd9..2a60fae 100644 --- a/lib/audioeffects/saturation.py +++ b/lib/audioeffects/saturation.py @@ -17,6 +17,20 @@ capacitor, the playback head that cannot see DC, the flux high-pass. That is where Bias's operating-point shift goes instead of onto the output. +**Controls moved and moved back play nothing out of silence** +(audiocomponents#113). Mix back up from 0 comes back as the class was +built - cleared, the pole charged on the bias, the voices re-armed - where +it used to play what it held, up to 7 927 LSB. Off centre the pole holds +`curve(bias) * post_gain`, so Bias back to the centre now drains it (it bled +out as a 29 058 LSB thump), and Drive, Output, Headroom and Hysteresis +re-charge it as Bias does. Hysteresis back in starts its play operator at +the centre instead of where it froze (314 LSB). Off centre each charge +leaves at most 1 LSB, as the constructor's does. And a Bias re-charge no +longer pulls a block off the dry tap: that put the dry leg 256 frames ahead +of the wet one after every `program_change`, a comb at patch 7 (Mix 44), +and left a block of old audio in the Splitter for the next Mix move off 0 +to play (13 071 LSB). + **What the default surrenders.** Mix 1, Drive 0 dB, Headroom 0, Bias 0, Tilt 0, Hysteresis 0, Speed 15 ips (inert on tube). @@ -711,6 +725,15 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0, self._ready = False self._attached = False self._bias = 0.0 + #: Whether the plate pole was last charged on an offset, so a Bias + #: move back to the centre drains it rather than leaving it to + #: bleed out as a thump (audiocomponents#113). + self._charged = False + #: The Hysteresis the shapers were last handed; see `_apply_macro`. + self._hysteresis = 0.0 + #: The shapers' `post_gain`, and what it was at the last charge. + self._post = 1.0 + self._charged_post = 1.0 self._wet_head = 1.0 self._group_delay = _group_delay_samples(oversample, self._extra_delay) @@ -866,10 +889,28 @@ def _charge_coupling(self, rewire=False): stops as soon as a whole block comes back zero. A **live** Bias move - a `program_change` onto silence, most of - all - re-charges with `rewire=True`. That costs one block: the - re-point takes a block from the wet tap, so the dry tap is pulled - once to match it, and the split stays level. Without it a change - onto patch 4 banged 8 489 LSB out of nothing. + all - re-charges with `rewire=True`. Without it a change onto patch + 4 banged 8 489 LSB out of nothing. + + **The re-point costs no block, so nothing is pulled to level the + split.** This used to pull one block off the dry tap after the + re-point, on the belief that `Waveshaper.play()` takes one from the + wet tap. It does not - measured on CPython, desktop MicroPython and + CircuitPython, a `play()` onto a source takes nothing from it (only + `audiofilters.Filter.play` and `MixerVoice.play` do) - so the pull + put the dry leg a block **ahead** of the wet one at every + `program_change` and every Bias move, and left the wet tap a block + behind in the Splitter's ring. The dry leg's impulse came out 256 + frames early at patch 4, 512 after one more Bias move; at shipped + patch 7 (Mix 44), where the dry leg is heard, that is a comb; and + the block left behind in the ring is old audio that the next Mix + move off 0 played out of silence, 13 071 LSB (audiocomponents#113). + + A Bias move **back to the centre** drains the pole the same way: + the shaper then answers silence with zero, and the charge the pole + holds from the old offset would otherwise bleed out of silence as a + thump - 29 058 LSB after Bias went to its bottom stop and back + (audiocomponents#113). On a build with no `audiocore.get_buffer` this does nothing and the bang is back. @@ -877,13 +918,14 @@ def _charge_coupling(self, rewire=False): pull = getattr(audiocore, "get_buffer", None) if pull is None or self._after is None: return False - if not self._bias: + if not self._bias and not self._charged: if rewire: for shaper in self._shapers(): shaper.play(self._quiet) shaper.play(self._shaper_head) - pull(self._dry) return False + self._charged = bool(self._bias) + self._charged_post = self._post for index in range(CHARGE_BLOCKS): for shaper in self._shapers(): shaper.play(self._quiet) @@ -897,9 +939,6 @@ def _charge_coupling(self, rewire=False): if rewire: for shaper in self._shapers(): shaper.play(self._shaper_head) - # One block from the dry tap, so the split stays level: the - # re-point above took one from the wet one. - pull(self._dry) return True def _attach(self): @@ -955,6 +994,15 @@ def _apply_macro(self, index, position): value = _component.macro_value(self._MACRO_RANGES[index], position) if index in (0, 1, 3): self._push_gains() + # Drive, Output and Headroom move `post_gain`, and off centre + # the offset the plate pole holds is `curve(bias) * post_gain`, + # so they move it as much as Bias does: Headroom at patch 4 and + # back banged 4 885 LSB out of silence. Re-charged the way a + # Bias move is (audiocomponents#113). At the centre there is no + # offset and nothing runs. + if self._ready and (self._bias or self._charged) \ + and self._post != self._charged_post: + self._charge_coupling(rewire=True) elif index == 2: self._push_mix() self._refresh_output() @@ -978,8 +1026,33 @@ def _apply_macro(self, index, position): elif index == 6: self._tilt.gain_db = round(value * 8.0) / 8.0 elif index == 7: + back_in = (value > 0.0 and self._hysteresis <= 0.0 + and not self._constructing) + moved = value != self._hysteresis + self._hysteresis = value for shaper in self._shapers(): shaper.set(hysteresis=value) + if back_in: + # Hysteresis back in: the node skips its play operator + # outright while Hysteresis is 0, so the operator's position + # is frozen where it was when the knob went down and the + # first sample back steps from there - an offset out of + # silence, 314 LSB through the plate pole. Cleared, it + # starts at the centre, which is where silence would have + # left it (audiocomponents#113). Clearing a shaper clears + # its oversampler too, and off centre the offset then has to + # climb back through the half-band - a dip the charged pole + # passes as a thump, 7 560 LSB at patch 4 - so off centre + # the pole is re-charged behind it, as a Bias move does, + # with the operator already engaged so the charge settles + # where it will sit. + self._clear_nodes(only=self._shapers()) + if moved and self._ready and (self._bias or self._charged): + # Off centre the operator rests a half-width below the bias, + # so any Hysteresis move - off included - moves the offset + # the pole holds: 14 LSB out of silence at patch 4 after + # Hysteresis went off and came back. + self._charge_coupling(rewire=True) def _curve_at(self, x): """The table at `x`, interpolated the way the node interpolates.""" @@ -1053,6 +1126,7 @@ def _push_gains(self): self._wet_head = 1.0 for shaper in self._shapers(): shaper.set(pre_gain=pre, post_gain=post) + self._post = post self._push_mix() def _push_mix(self): @@ -1087,13 +1161,35 @@ def _refresh_output(self): if not self._ready: return if self._value(2) <= 0.0: - self._output = self._source + self._route_around(self._source) self._latency = 0 else: + if self._rejoin(keep=(self._mix,)): + self._rearm() self._attach() self._output = self._mix self._latency = self._wet_delay + def _rearm(self): + """Arm a graph Mix 0 left un-pulled, the way `_build` arms it. + + Back off a bypass every node still holds what it held when Mix went + to 0 - the plate pole, the tilt and makeup shelves, the shapers' + oversamplers - and each voice the block it had queued; `_rejoin` has + cleared the nodes. The mixer is left out of that walk on purpose: + its registered reset re-plays the voices there and then, through a + pole not yet charged. So the order is `_build`'s: the pole charged + on the bias with the shapers on the zero sample, the shapers pointed + back at the chain, the gates opened at the levels, and the voices + re-played (audiocomponents#113). + """ + self._charged = False + self._charge_coupling(rewire=True) + _component.open_level_gates( + self._mix, [self._mix.voice[0], self._mix.voice[1]], + self._silence) + self._attached = False + def program_change(self, index, channel=0, note_id=-1, sample_position=0): _component.Component.program_change(self, index, channel, note_id, diff --git a/tests/support/LIFECYCLE.md b/tests/support/LIFECYCLE.md new file mode 100644 index 0000000..bcebe04 --- /dev/null +++ b/tests/support/LIFECYCLE.md @@ -0,0 +1,121 @@ +# The lifecycle matrix + +`lifecycle.py` runs one fixed set of lifecycle events on an effect class and +judges every cell by the same properties, with no model in the loop. It +replaces hunting for the same defects class by class: audio lost across a +reset, a stale block when a control comes back, a tail that outlasts +`tail_samples`, a source that runs dry, source buffers that are not 256 +frames, two moves before one pull. + +To run it on one class (CPython, or a native binary with +`MICROPYPATH=lib:tests/support`), from the repo root: + + python tests/support/lifecycle.py DigitalDelay + python tests/support/lifecycle.py DigitalDelay --events=E1,E5 + python tests/support/lifecycle.py MultiTapDelay --quick + +It prints one line per cell, `class|event|rate|channels|patch|P1:..|...|h:..`, +and a `SUMMARY` line per class: cells run, cells with any red property, +declared exceptions. `tests/test_lifecycle_matrix.py` runs it on every Phase 5 +class on the branch, on CPython and both native interpreters, and holds the +red cells to its `KNOWN_RED` table. + +## The grid + +Every event runs at 48 000 and 22 050 Hz, stereo and mono, at the +constructor defaults (`d`) and at every shipped patch. `--quick` keeps the +full event list at the defaults and runs the patch configurations on the +events that do not multiply by the macro count (no E4, E10-macros or E11, +and E6 only to patch 0 and the last patch). The material is deterministic +and identical on every interpreter: a click train whose clicks each have +their own amplitude (the second channel another), an LCG noise burst, and an +integer triangle for P5. + +## Events + +The move lands before pull 12 (mid-stream), and "and back" comes 2 pulls +later (6 for Mix). + +- **E1** `reset()`, with 256-frame source buffers (on a block boundary) and + 100-frame buffers (part-way through one). +- **E2** the host's `audiocore.reset_buffer(effect.output)`. +- **E3** `deinit()`: the source still renders, every public method and + property raises, a second `deinit()` is harmless. +- **E4** each macro to each stop (0 and 127; and 64 for a bipolar one) and + back. +- **E5** Mix (the class's wet/dry control) to 0 and back. +- **E6** `program_change` to each other patch and back. +- **E8** the source hands back an empty buffer once, part-way through a + block, then carries on (`E8-dry`); the source ends (`E8-end`). A native + source can only run dry by handing back an empty buffer with + `GET_BUFFER_DONE`, so that is what the matrix does, through an + `audioroute.Port` it re-points. +- **E9** source buffers of 256, 100, 512 and 1000 frames and one whole + RawSample: P1 is Mix 0 all along; the other properties use the E5 move. +- **E10** reset, every macro to 127 and back, Mix to 0 and back, a patch + change and back, all before the first pull. +- **E11** two macro moves; a Mix move and a patch change; two patch changes; + three moves; all before one pull, back later. + +"Back" is `program_change(patch)` at a patch, and `set_macro` with the +values `get_macro` read at the defaults (landed exactly where that +round trip is not bit-exact). + +## Properties + +Each is judged against a control instance that never met the event, fed the +same material. A reset returns a class to patch 0, so E1's control is built +at patch 0. + +- **P1** At Mix 0 the output is the source, byte for byte, on time (shifted + by `latency_samples`). Events that do not move Mix take Mix back to 0 in + the same gap; E5 is judged over the stretch it holds Mix at 0. `E8-dry` may + insert silence but may not lose, repeat or reorder a frame; after + `E8-end` the output is the frames the source handed, then silence. +- **P2** Silence in gives silence out: after the event and `tail_samples` + (read after it) + `latency_samples` + one block + the last source buffer, + every sample is exactly 0 for 2048 frames. +- **P3** Nothing plays out of silence: the move away as the input stops, a + wait until the output is exactly silent, the move back; the next four + blocks are exactly 0. A single action is taken in the silence instead. +- **P4** From `tail_samples + latency_samples + one block` after the last + move on, the output is byte-identical to the control's, over 2048 frames + of material. `E8-dry` may converge to the control delayed by up to one + block. +- **P5** No step within one block of the move larger than 1.5 x the larger + of the triangle's own largest step and the control's. Control moves only + (E4, E5, E6, E9, E11): a reset, a deinit and a source fault are cuts by + definition, and E10 has nothing before it. +- **P6** The three interpreters print identical lines for every cell. Each + line carries `h:`, a CRC-32 of the event instance's whole render, so P6 + compares bytes, not only verdicts. +- **PD** E3's surface check. + +A verdict is `ok`, `na(...)` (the property does not apply to that cell, +said why: `tail None`, a whole RawSample, a class that never falls silent), +`RED(...)` with what was measured, or `decl`. + +## Declaring an exception + +A class that cannot satisfy a property by design (a glide, a reverb tail, a +free-running LFO) adds a row to `DECLARED` in `lifecycle.py`: + + DECLARED = { + ("TapeDelay", "E1-", "P4"): "wow and flutter run free across a reset", + } + +The key is (class `NAME`, event name prefix, property); the cell then prints +`decl` and counts as a declared exception. Nothing else skips a cell. The +test file's `KNOWN_RED` is different: it records what is red today, as +findings, one row per (class, event prefix, property) with the number and a +digest of its red cells, so the suite goes red when a cell changes either +way. The test runs the quick matrix; `LIFECYCLE_FULL=1` runs the full one, +and `LIFECYCLE_CLASSES=DigitalDelay,TapeDelay` limits it to some classes. + +## The plants + +The helper ships a control and a planted fault for every property, and +`TestPlants` holds each plant red on its cell with its control green there: +`DropOnReset` (P1), `HoldDC` (P2), `StaleMix` against `Routed` (P3), +`LateMove` (P4), `HardSwitch` (P5), `BusyDeinit` (PD) and `ImplPlant` +(P6, a level a hair different off CPython). diff --git a/tests/support/kit_faults.py b/tests/support/kit_faults.py index de89f8d..0f5a2e5 100644 --- a/tests/support/kit_faults.py +++ b/tests/support/kit_faults.py @@ -42,6 +42,10 @@ import audiofilters import synthio from audioeffects import _core +# The STATE faults below are written against the old `_core.Effect` delay, +# which `delay.py` keeps until the rebuilt `DigitalDelay` (adopted +# 2026-09-28) comes home; `audioeffects.DigitalDelay` is the rebuild now. +from audioeffects.delay import DigitalDelay as _OldDigitalDelay #: `_component` reads VENDOR off the module a class is defined in, and the #: CLICK fault below is a subclass of a rebuilt class. Without this the fault @@ -239,7 +243,7 @@ def reported(self): "latency_samples": property(reported)}) -class NoResetDelay(audioeffects.DigitalDelay): +class NoResetDelay(_OldDigitalDelay): """STATE's first fault: a delay line left full after `reset()`. The base class resets the node's buffer; this one keeps everything else @@ -251,7 +255,7 @@ def reset(self): self.program_change(0) -class LiveIntermediateDelay(audioeffects.DigitalDelay): +class LiveIntermediateDelay(_OldDigitalDelay): """STATE's second fault: an intermediate node left live after `deinit()`. @@ -265,7 +269,7 @@ def __init__(self, source, **options): filter=synthio.Biquad(synthio.FilterMode.LOW_PASS, 8000.0, Q=0.707), **_core.pcm()) self.pre.play(source) - audioeffects.DigitalDelay.__init__(self, self.pre, **options) + _OldDigitalDelay.__init__(self, self.pre, **options) class UnreleasableSectionLowPass(LowPass): diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py new file mode 100644 index 0000000..eddc89b --- /dev/null +++ b/tests/support/lifecycle.py @@ -0,0 +1,1394 @@ +"""The shared lifecycle matrix for audioeffects classes. + +One fixed set of lifecycle EVENTS (reset, a host reset of the output, +deinit, macro stops, Mix to 0 and back, patch changes, a source that runs +dry or ends, odd source buffer sizes, moves before the first pull, several +moves before one pull), run the same way on every class, each cell judged by +the same PROPERTIES against a CONTROL instance that never met the event. No +model is in the loop: a cell is green or red by arithmetic on the bytes. + +It is numpy-free and runs unchanged on CPython, MicroPython and +CircuitPython, so the three interpreters can be held to printing the same +line for every cell (P6, judged by `tests/test_lifecycle_matrix.py`). + + python tests/support/lifecycle.py DigitalDelay [--events E1,E5] [--quick] + +`LIFECYCLE.md` beside this file says what each event and property is and +how a class declares an exception. `run_class()` is the entry point; the +planted faults at the bottom are the matrix's own proof that each property +can fail. +""" + +import sys + +if __name__ == "__main__": + sys.path.insert(0, "lib") + +import binascii + +try: + from array import array +except ImportError: # pragma: no cover + array = None + +import audiocore +import audiofilters +import audiomixer + +try: + from audioroute import Port +except ImportError: # pragma: no cover + Port = None + +try: + from audioeffects import _component +except ImportError: # pragma: no cover + _component = None + +#: `_component` reads VENDOR off the module a class is defined in, and the +#: planted classes are defined here. +VENDOR = "PyDevices" + +BLOCK = 256 +RATES = (48000, 22050) +CHANNELS = (2, 1) +#: The pull the mid-stream event lands before, and how many pulls later the +#: move comes back. +K0 = 12 +GAP = 2 +#: Frames compared (P4) or held to zero (P2) past each bound. +MARGIN = 2048 +#: P5's factor, as a fraction: 3/2 = 1.5. +STEP_NUM = 3 +STEP_DEN = 2 +#: Frames of silence P3 waits for when a class reports no tail. +NO_TAIL_WAIT = 10 * 48000 + +#: The class's wet/dry control, by macro label. A class not named here uses +#: the macro labelled "Mix". +MIX_INDEX = { + "SlapbackDelay": 1, # "Level": the slap's level; 0 is the dry +} + +#: Where a class cannot satisfy a property by design, it says so HERE, one +#: row per (class NAME, event name prefix, property): reason. A matching +#: cell prints `decl` instead of RED and is counted as a declared exception. +#: Nothing else skips a cell. +DECLARED = { + ("AnalogDelay", "E", "P5"): + "a control that jumps steps the output (family, audiocomponents#117);" + " a Time move bends the pitch while the line walks, by design", + ("AnalogDelay", "E8-dry", "P3"): + "a tail cut short by a stopped source carries on when it comes back" + " (family, audiodsp#180)", + ("AnalogDelay", "E2-", "P4"): + "reset_buffer restarts the modulation triangle (the node's" + " state_init), so a modulated patch then matches a fresh instance," + " not the uninterrupted control", + ("AnalogDelay", "E4-", "P4"): + "a Mod Rate move shifts the free-running triangle; after a Time move" + " and back, a 1-LSB rounding difference circulates in the feedback" + " loop past tail_samples", + ("AnalogDelay", "E6-", "P4"): + "a patch change shifts the free-running triangle, or leaves a 1-LSB" + " rounding difference circulating in the feedback loop past" + " tail_samples", + ("AnalogDelay", "E11-", "P4"): + "a patch change shifts the free-running triangle, or leaves a 1-LSB" + " rounding difference circulating in the feedback loop past" + " tail_samples", + ("TapeDelay", 'E1-', 'P4'): + 'reset() restarts the wow and flutter table, so the wobble is never in phase with a fresh instance again', + ("TapeDelay", 'E2-', 'P4'): + 'a host reset_buffer restarts the wow and flutter table, so the wobble is never in phase with a fresh instance again', + ("TapeDelay", 'E4-m0=0', 'P4'): + "a Time move and back leaves other rounding in the loop than a fresh instance's; while material plays a 1 LSB difference can outlast tail_samples, which bounds silence", + ("TapeDelay", 'E4-m0=', 'P5'): + "varispeed bends the pitch on a Time move: a bend up past 1.5x steepens the triangle past P5's limit, and T1a's step clause holds (no click)", + ("TapeDelay", 'E11-2macro', 'P5'): + "varispeed bends the pitch on a Time move: a bend up past 1.5x steepens the triangle past P5's limit, and T1a's step clause holds (no click)", + ("TapeDelay", 'E4-m2=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("TapeDelay", 'E4-m4=', 'P5'): + "family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Wow: a balance move swaps the table's shape)", + ("TapeDelay", 'E5-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("TapeDelay", 'E6-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("TapeDelay", 'E9-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("TapeDelay", 'E11-mix+patch', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix and a patch change)', + ("TapeDelay", 'E11-2patch', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("TapeDelay", 'E11-3moves', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Time and a patch change)', + ("TapeDelay", 'E8-dry', 'P3'): + 'family limit, disclosed (audiodsp#180): a tail cut short by a source that stopped carries on when the source comes back', + # Reverb: the tank's int16 lines truncate on every write and its + # filters run in float, so once two tanks have heard different material + # they stay a few LSB apart under the same input for good (seen at Mod + # Depth 0 too); a reset or a re-cut also restarts the modulation phase. + ("Reverb", "E1-", "P4"): "a reset tank never re-converges byte for byte (int16 lines, free modulation phase)", + ("Reverb", "E2-", "P4"): "a reset_buffer tank never re-converges byte for byte (int16 lines, free modulation phase)", + ("Reverb", "E4-", "P4"): "a move that reaches the loop leaves the tank a few LSB off a fresh one for good", + ("Reverb", "E6-", "P4"): "a patch move reaches the loop and leaves the tank a few LSB off a fresh one for good", + ("Reverb", "E11-", "P4"): "moves that reach the loop leave the tank a few LSB off a fresh one for good", + # Brad's ruling of 2026-09-28, disclosed in the docstring's "Limits + # shared by the family" (audiocomponents#117, audiodsp#180). + ("Reverb", "E4-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E5-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E6-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E9-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E11-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E8-dry", "P3"): "family: the tail rings only while the source feeds (audiodsp#180)", + ("DigitalDelay", 'E4-m2=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("DigitalDelay", 'E4-m6=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Repeat Tone)', + ("DigitalDelay", 'E4-m7=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Repeat Cut)', + ("DigitalDelay", 'E5-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("DigitalDelay", 'E6-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("DigitalDelay", 'E9-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("DigitalDelay", 'E11-mix+patch', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix and a patch change)', + ("DigitalDelay", 'E11-2patch', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("DigitalDelay", 'E11-3moves', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (the patch change; the Time moves glide)', + ("DigitalDelay", 'E8-dry', 'P3'): + 'family limit, disclosed (audiodsp#180): a tail cut short by a source that stopped carries on when the source comes back', + ("SlapbackDelay", "E", "P5"): + "a control that jumps steps the output (family, audiocomponents#117):" + " Level, Tone and patch moves have no ramp", + ("SlapbackDelay", "E8-dry", "P3"): + "a tail cut short by a stopped source carries on when it comes back" + " (family, audiodsp#180)", + ("SlapbackDelay", "E1-", "P4"): + "a reset restarts the Wow wobble where a fresh instance's starts;" + " the control never stopped, so its wobble is further along and" + " with Wow above 0 the two never line up again (ok at Wow 0)", + ("SlapbackDelay", "E2-", "P4"): + "reset_buffer restarts the Wow wobble where a fresh instance's" + " starts; the control never stopped, so its wobble is further along" + " and with Wow above 0 the two never line up again (ok at Wow 0)", + ("MultiTapDelay", "E", "P5"): + "family limit, disclosed (audiocomponents#117): a control that jumps" + " makes the output step (Time and Heads move every head to a new" + " grid, Mix, Tilt and a patch change jump a level)", + ("MultiTapDelay", "E1-", "P4"): + "reset() empties both lines where the control's hold older laps; they" + " die to 1 LSB inside tail_samples, and a 1-LSB rounding difference" + " then circulates in the loop past it (silence reaches zero inside" + " it)", + ("MultiTapDelay", "E5-", "P4"): + "a return from Mix 0 starts both lines empty where the control's hold" + " older laps; they die to 1 LSB inside tail_samples, and a 1-LSB" + " rounding difference then circulates in the loop past it", + ("MultiTapDelay", "E9-", "P4"): + "a return from Mix 0 starts both lines empty where the control's hold" + " older laps; they die to 1 LSB inside tail_samples, and a 1-LSB" + " rounding difference then circulates in the loop past it", + ("MultiTapDelay", "E4-m4=", "P4"): + "a return from Mix 0 starts both lines empty where the control's hold" + " older laps; they die to 1 LSB inside tail_samples, and a 1-LSB" + " rounding difference then circulates in the loop past it", + ("MultiTapDelay", "E4-m6=", "P4"): + "crossing Repeat Tone's out stop swaps the loop that makes the laps" + " and empties the lap node going in; the old laps die to 1 LSB inside" + " tail_samples, and a 1-LSB rounding difference then circulates past" + " it", + ("MultiTapDelay", "E6-", "P4"): + "a patch change that crosses Repeat Tone's out stop empties the lap" + " node going in; the old laps die to 1 LSB inside tail_samples, and a" + " 1-LSB rounding difference then circulates past it", + ("MultiTapDelay", "E11-", "P4"): + "Mix 0 or a crossing of Repeat Tone's out stop empties a line; the old" + " laps die to 1 LSB inside tail_samples, and a 1-LSB rounding" + " difference then circulates past it", + ("MultiTapDelay", "E8-dry", "P4"): + "the lines hold the gap the source left, where the control's hold" + " audio; that dies to 1 LSB inside tail_samples, and a 1-LSB rounding" + " difference then circulates in the loop past it, for good at some" + " settings", + ("MultiTapDelay", "E4-m0=", "P3"): + "CPython only, the node's twin (audiodsp#177): the CPython" + " audiodelays.MultiTapDelay keeps the line past a shorter delay_ms" + " that the C node zeroes, so a Time move and back replays it", + ("MultiTapDelay", "E6-", "P3"): + "CPython only, the node's twin (audiodsp#177): the CPython" + " audiodelays.MultiTapDelay keeps the line past a shorter delay_ms" + " that the C node zeroes, so a patch change and back replays it", + ("MultiTapDelay", "E11-2patch", "P3"): + "CPython only, the node's twin (audiodsp#177): the CPython" + " audiodelays.MultiTapDelay keeps the line past a shorter delay_ms" + " that the C node zeroes, so a patch change and back replays it", + ("MultiTapDelay", "E11-3moves", "P3"): + "CPython only, the node's twin (audiodsp#177): the CPython" + " audiodelays.MultiTapDelay keeps the line past a shorter delay_ms" + " that the C node zeroes, so a patch change and back replays it", + ("ConvolutionReverb", 'E1-', 'P1'): + 'by design, disclosed: reset() empties the room, so the block in flight, 256 frames, comes out as exact zero, dry included', + ("ConvolutionReverb", 'E2-', 'P1'): + 'by design, disclosed: a host reset_buffer resets the node, so the block in flight, 256 frames, comes out as exact zero, dry included', + ("ConvolutionReverb", 'E4-m5=0', 'P1'): + 'by design, disclosed: Mix acts from the end of the block in flight, so the block after a move to Mix 0 comes out at the old Mix', + ("ConvolutionReverb", 'E5-', 'P1'): + 'by design, disclosed: Mix acts from the end of the block in flight, so the block after a move to Mix 0 comes out at the old Mix', + ("ConvolutionReverb", 'E4-m1=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Damping re-synthesizes the room)', + ("ConvolutionReverb", 'E4-m2=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Predelay re-synthesizes the room)', + ("ConvolutionReverb", 'E4-m4=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Room re-synthesizes the room)', + ("ConvolutionReverb", 'E4-m5=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("ConvolutionReverb", 'E5-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("ConvolutionReverb", 'E6-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("ConvolutionReverb", 'E9-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("ConvolutionReverb", 'E11-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (two or three moves before one pull)', + ("ConvolutionReverb", 'E8-dry', 'P3'): + 'family limit, disclosed (audiodsp#180): a tail cut short by a source that stopped carries on when the source comes back', + ("PingPongDelay", 'E4-m2=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', +} + +SOURCE_KINDS = ("256", "100", "512", "1000", "raw") + + +# -------------------------------------------------------------------------- +# Material + + +def _lcg(state): + return (1103515245 * state + 12345) & 0x7FFFFFFF + + +def click_noise(frames, channels, spacing=64, seed=12345): + """Clicks every `spacing` frames, each at its own amplitude (and the + second channel at a different one), with an LCG noise burst in the + middle eighth: a frame lost, repeated or swapped is visible.""" + values = array("h", bytes(2 * frames * channels)) + state = seed + for frame in range(3, frames, spacing): + state = _lcg(state) + amp = 2000 + (state >> 8) % 24000 + if (state >> 4) & 1: + amp = -amp + values[frame * channels] = amp + if channels == 2: + values[frame * channels + 1] = -(amp // 2) - 7 + lo = frames // 2 + for frame in range(lo, lo + frames // 8): + for channel in range(channels): + state = _lcg(state) + values[frame * channels + channel] = ((state >> 8) % 16001) - 8000 + return values + + +def triangle(frames, channels, rate, amp=12000, hz=100): + """An integer triangle: the same bytes on every interpreter, and its + largest step is small (4 * amp * hz / rate).""" + values = array("h", bytes(2 * frames * channels)) + period = rate // hz + for frame in range(frames): + phase = (frame % period) * 4 * amp // period + if phase < amp: + value = phase + elif phase < 3 * amp: + value = 2 * amp - phase + else: + value = phase - 4 * amp + for channel in range(channels): + values[frame * channels + channel] = value + return values + + +def _raw(values, rate, channels): + return audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + + +def _adapter(values, rate, channels, frames, loop): + """A RawSample handed out `frames` at a time: the house re-blocker. + With loop False it pads silence after the material, for ever.""" + block = audiofilters.Filter(filter=None, mix=1.0, + buffer_size=frames * channels * 2, + sample_rate=rate, channel_count=channels) + block.play(_raw(values, rate, channels), loop=loop) + return block + + +class Feed: + """The source a class is built around: a Port the host re-points. + + `mat` is the material, `sil` silence, `empty` a sample that hands back + an empty buffer (the only way a native source can run dry). A Port + moves no bytes, so a class sees exactly what the source behind it + hands.""" + + def __init__(self, values, rate, channels, kind, loop): + self.rate = rate + self.channels = channels + if kind == "raw": + self.mat = _raw(values, rate, channels) + else: + self.mat = _adapter(values, rate, channels, int(kind), loop) + self.sil = _adapter(array("h", bytes(4 * channels * BLOCK)), rate, + channels, BLOCK, True) + # MicroPython's RawSample refuses an empty array but takes an + # empty 16-bit memoryview. + self.empty = _raw(memoryview(array("h", [0, 0]))[0:0], rate, + channels) + self.port = Port(self.mat) + self.current = self.mat + self.sample_rate = rate + self.channel_count = channels + + def point(self, node): + self.current = node + self.port.play(node) + + +# -------------------------------------------------------------------------- +# Bytes + + +ZERO = bytes(BLOCK * 2 * 2 * 8) + + +def _is_zero(data): + step = len(ZERO) + for lo in range(0, len(data), step): + chunk = data[lo:lo + step] + if chunk != ZERO[:len(chunk)]: + return False + return True + + +def _samples(data): + out = array("h") + try: + out.frombytes(bytes(data)) + return out + except AttributeError: + pass + import struct + out.extend(struct.unpack("<%dh" % (len(data) // 2), bytes(data))) + return out + + +def _first_diff(a, b, lo, hi): + """First byte index in [lo, hi) where a and b differ, or -1.""" + step = 4096 + pos = lo + while pos < hi: + end = min(hi, pos + step) + if a[pos:end] != b[pos:end]: + for i in range(pos, end): + if a[i] != b[i]: + return i + pos = end + return -1 + + +def _last_diff(a, b, lo, hi): + """Last byte index in [lo, hi) where a and b differ, or -1.""" + step = 4096 + end = hi + while end > lo: + pos = max(lo, end - step) + if a[pos:end] != b[pos:end]: + for i in range(end - 1, pos - 1, -1): + if a[i] != b[i]: + return i + end = pos + return -1 + + +def _max_step(values, channels, lo, hi): + top = 0 + for ch in range(channels): + prev = None + for i in range(lo * channels + ch, hi * channels, channels): + v = values[i] + if prev is not None: + d = v - prev + if d < 0: + d = -d + if d > top: + top = d + prev = v + return top + + +# -------------------------------------------------------------------------- +# The class under test + + +def _starts(text, prefixes): + for prefix in prefixes: + if text.startswith(prefix): + return True + return False + + +def mix_index(cls): + name = cls.NAME + if name in MIX_INDEX: + return MIX_INDEX[name] + labels = cls.MACRO_LABELS + for i in range(len(labels)): + if labels[i] == "Mix": + return i + return None + + +def stops(cls, index): + mode = cls.MACRO_MODES.get(index, "UNIPOLAR") + if mode == "BIPOLAR": + return (0, 64, 127) + return (0, 127) + + +class Ctx: + """What one instance needs to undo a move: the patch it was built at, + or its macros' exact positions at the default.""" + + def __init__(self, e, patch): + self.patch = patch + self.positions = list(e._macros) + self.midi = [e.get_macro(i) for i in range(len(self.positions))] + self.inexact = 0 + + +def restore(e, ctx): + if ctx.patch is not None: + e.program_change(ctx.patch) + return + for i in range(len(ctx.midi)): + e.set_macro(i, ctx.midi[i]) + # set_macro(get_macro()) is not always bit-exact at the constructor's + # unquantised defaults; land exactly, so P4 measures the event and not + # the width of a float, and count it. + for i in range(len(ctx.positions)): + if e._macros[i] != ctx.positions[i]: + ctx.inexact += 1 + e._macros[i] = ctx.positions[i] + e._apply_macro(i, ctx.positions[i]) + + +def act(e, feed, ctx, actions): + for a in actions: + op = a[0] + if op == "reset": + e.reset() + elif op == "rb": + audiocore.reset_buffer(e.output) + elif op == "macro": + e.set_macro(a[1], a[2]) + elif op == "patch": + e.program_change(a[1]) + elif op == "restore": + restore(e, ctx) + elif op == "dry": + feed.port.play(feed.empty) + elif op == "wet": + feed.port.play(feed.current) + elif op == "end": + feed.point(feed.empty) + else: + raise ValueError(op) + + +class Event: + """`away` before pull K0 (or before the first pull when `first`), + `back` GAP pulls later (in the same gap when `first`).""" + + def __init__(self, name, away, back=None, kind="256", moves_mix=False, + control_move=True, post0=False, first=False, gap=GAP): + self.name = name + self.away = away + self.back = back or [] + self.kind = kind + self.moves_mix = moves_mix + self.control_move = control_move + self.post0 = post0 + self.first = first + self.gap = gap + + +def events(cls, patch, quick=False): + """Every event for `cls` at `patch` (None: the constructor defaults).""" + mix = mix_index(cls) + labels = cls.MACRO_LABELS + npatch = len(cls.PATCHES) + full = (not quick) or patch is None + out = [ + Event("E1-reset@block", [("reset",)], kind="256", + control_move=False, post0=True), + Event("E1-reset@part", [("reset",)], kind="100", + control_move=False, post0=True), + Event("E2-reset_buffer", [("rb",)], control_move=False), + ] + if full: + for i in range(len(labels)): + for v in stops(cls, i): + out.append(Event("E4-m%d=%d" % (i, v), [("macro", i, v)], + [("restore",)], moves_mix=(i == mix))) + if mix is not None: + out.append(Event("E5-mix0", [("macro", mix, 0)], [("restore",)], + moves_mix=True, gap=6)) + targets = range(npatch) if full else (0, npatch - 1) + for q in targets: + if q == patch: + continue + out.append(Event("E6-p%d" % q, [("patch", q)], [("restore",)], + moves_mix=True)) + out.append(Event("E8-dry", [("dry",)], [("wet",)], kind="100", + control_move=False, gap=1)) + out.append(Event("E8-end", [("end",)], kind="100", control_move=False)) + for kind in SOURCE_KINDS: + if mix is not None: + out.append(Event("E9-src%s" % kind, [("macro", mix, 0)], + [("restore",)], kind=kind, moves_mix=True, + gap=6)) + out.append(Event("E10-reset", [("reset",)], control_move=False, + post0=True, first=True)) + if full: + out.append(Event("E10-macros", + [("macro", i, 127) for i in range(len(labels))], + [("restore",)], moves_mix=True, first=True)) + if mix is not None: + out.append(Event("E10-mix0", [("macro", mix, 0)], [("restore",)], + moves_mix=True, first=True)) + out.append(Event("E10-patch", [("patch", npatch - 1 if patch != npatch - 1 + else 0)], [("restore",)], + moves_mix=True, first=True)) + if full: + out.append(Event("E11-2macro", [("macro", 0, 0), ("macro", 0, 127)], + [("restore",)], moves_mix=(mix == 0))) + if mix is not None: + out.append(Event("E11-mix+patch", + [("macro", mix, 0), ("patch", npatch - 1)], + [("restore",)], moves_mix=True)) + out.append(Event("E11-2patch", [("patch", npatch - 1), ("patch", 0)], + [("restore",)], moves_mix=True)) + out.append(Event("E11-3moves", + [("macro", 0, 0), ("macro", 0, 127), + ("patch", npatch - 1)], + [("restore",)], moves_mix=True)) + return out + + +# -------------------------------------------------------------------------- +# Rendering + + +def _build(cls, feed, rate, patch, options, post0=False): + e = cls(feed.port, sample_rate=rate, **options) + if patch is not None: + e.program_change(patch) + if post0: + e.program_change(0) + return e + + +def _pull(e, out): + """One host pull, appended as the class handed it. An empty pull adds + nothing; the caller counts them.""" + data = bytes(audiocore.get_buffer(e.output)[1]) + out.extend(data) + return len(data) + + +def _pull_to(e, out, nbytes, fsize): + """Pull until `out` holds `nbytes`. 64 empty pulls in a row is a source + that has ended at Mix 0: silence from there, which is what a host + playing it hears.""" + empties = 0 + while len(out) < nbytes: + if _pull(e, out) == 0: + empties += 1 + if empties >= 64: + out.extend(bytes(nbytes - len(out))) + return True + else: + empties = 0 + return False + + +def _tail(e): + t = e.tail_samples + return t + + +class Control: + """A control instance per (kind, material, rate, channels, patch, + post0), rendered lazily and shared by every cell that needs it.""" + + def __init__(self, cls, rate, channels, patch, options, kind, material, + post0): + values = material_for(material, rate, channels) + self.feed = Feed(values, rate, channels, kind, + loop=(material == "loop")) + self.e = _build(cls, self.feed, rate, patch, options, post0) + self.out = bytearray() + self.fsize = 2 * channels + self.ended = False + + def upto(self, frames): + nbytes = frames * self.fsize + if len(self.out) < nbytes and not self.ended: + self.ended = _pull_to(self.e, self.out, nbytes, self.fsize) + return self.out + + def close(self): + self.e.deinit() + + +_MATERIAL = {} + + +def material_for(which, rate, channels): + key = (which, rate, channels) + if key not in _MATERIAL: + if which == "loop": + _MATERIAL[key] = click_noise(2400, channels, spacing=150, + seed=777) + elif which == "once": + _MATERIAL[key] = click_noise(8192, channels) + elif which == "tri": + _MATERIAL[key] = triangle(8192, channels, rate) + else: + raise ValueError(which) + return _MATERIAL[key] + + +def _schedule_run(e, feed, ctx, ev, out, fsize, upto_frames, mixfix=None): + """Pull to `upto_frames`, taking `ev.away` before pull K0 (or before + the first pull) and `ev.back` `ev.gap` pulls later. Returns the frame + positions the actions landed at.""" + at = [] + ctx.lats = [] + k0 = 0 if ev.first else K0 + kb = k0 if ev.first else k0 + ev.gap + k = 0 + nbytes = upto_frames * fsize + empties = 0 + while len(out) < nbytes: + if k == k0: + at.append(len(out) // fsize) + act(e, feed, ctx, ev.away) + if mixfix: + mixfix(e) + ctx.lats.append(e.latency_samples) + if k == kb and ev.back: + if kb != k0: + at.append(len(out) // fsize) + act(e, feed, ctx, ev.back) + if mixfix: + mixfix(e) + if kb != k0: + ctx.lats.append(e.latency_samples) + if _pull(e, out) == 0: + empties += 1 + if empties >= 64: + out.extend(bytes(nbytes - len(out))) + break + else: + empties = 0 + k += 1 + ctx.pulls = k + return at + + +# -------------------------------------------------------------------------- +# The properties + + +def p1(cls, ev, rate, channels, patch, options): + """At Mix 0 the output is the source, byte for byte, on time.""" + mix = mix_index(cls) + if mix is None: + return "na" + if ev.name.startswith("E4-m%d=" % mix) and not ev.name.endswith("=0"): + return "na" + if ev.name.startswith("E10-mix"): + return "na" + values = material_for("once", rate, channels) + feed = Feed(values, rate, channels, ev.kind, loop=False) + e = _build(cls, feed, rate, patch, options, False) + ctx = Ctx(e, patch) + fsize = 2 * channels + if _starts(ev.name, ("E5", "E4-m%d=0" % mix)): + # The event itself takes Mix to 0: judge the stretch it holds it. + mixfix = None + window = None + else: + e.set_macro(mix, 0) + + def mixfix(effect): + effect.set_macro(mix, 0) + window = "all" + if ev.name.startswith("E9"): + # E9's P1 is the baseline: Mix 0 from the start, no move, this + # source shape. + ev = Event(ev.name, [], kind=ev.kind) + out = bytearray() + total = (K0 + 8 + 8) * BLOCK + try: + at = _schedule_run(e, feed, ctx, ev, out, fsize, total, mixfix) + lat = e.latency_samples + finally: + e.deinit() + expect = bytes(values) + if ev.kind == "raw": + total = min(total, len(values) // channels) + if window == "all": + lo, hi = 0, total + else: + if not at: + return "na" + lo = at[0] + hi = at[1] if len(at) > 1 else total + if ev.first: + return "na" + # Mix 0's own latency, read while the event holds it there. + lat = ctx.lats[0] + shifted = bytes(lat * fsize) + expect + shifted = shifted + bytes(max(0, total * fsize - len(shifted))) + d = _first_diff(out, shifted, lo * fsize, hi * fsize) + if d < 0: + return "ok" + frame = d // fsize + if ev.name.startswith("E8-end") and at and frame >= at[0]: + # The source's frames up to where it ended (it may have handed + # some past the pull it ended at), then silence. + if _is_zero(out[d:hi * fsize]): + return "ok" + if ev.name.startswith("E8-dry"): + # A starve may play as silence: zeros inserted, then the source + # again from where it stopped, with nothing lost or repeated. + z = frame + while z < hi and out[z * fsize:(z + 1) * fsize] == bytes(fsize): + z += 1 + gap = z - frame + if gap and _first_diff(out[z * fsize:], shifted[frame * fsize:], 0, + (hi - z) * fsize) < 0: + return "ok" + return "RED(first wrong frame %d%s)" % (frame - lo, _diagnose( + out, shifted, frame, fsize, hi)) + + +def _diagnose(out, expect, frame, fsize, hi): + """Where the wrong frame's run came from in the source.""" + want = out[frame * fsize:(frame + 16) * fsize] + if want == bytes(len(want)): + return ", silence" + for off in range(1, 4097): + for sign in (1, -1): + src = frame + sign * off + if src < 0: + continue + if expect[src * fsize:(src + 16) * fsize] == want: + if sign > 0: + return ", %d frames of source lost" % off + return ", %d frames of source repeated" % off + return ", not the source near there" + + +def _decl(cls, ev, prop): + for (name, prefix, p), reason in DECLARED.items(): + if name == cls.NAME and p == prop and ev.name.startswith(prefix): + return True + return False + + +def main_render(cls, ev, rate, channels, patch, options, controls): + """P2, P3, P4 from one instance, against a shared control.""" + fsize = 2 * channels + res = {} + values = material_for("loop", rate, channels) + feed = Feed(values, rate, channels, ev.kind, loop=True) + e = _build(cls, feed, rate, patch, options, False) + ctx = Ctx(e, patch) + ctx.cls = cls + ctx.options = options + # The control is rendered in step with the instance, pull for pull, + # and compared as it goes: nothing tail-long is kept. + cfeed = Feed(values, rate, channels, ev.kind, loop=True) + c = _build(cls, cfeed, rate, patch, options, ev.post0) + try: + out = bytearray() + at = _schedule_run(e, feed, ctx, ev, out, fsize, + ((0 if ev.first else K0) + ev.gap + 1) * BLOCK) + cout = bytearray() + for k in range(ctx.pulls): + if k == K0 and ev.name.startswith("E8-end"): + # The control's source goes silent where the instance's + # ended, and stays silent. + cfeed.point(cfeed.sil) + _pull(c, cout) + last = at[-1] if at else 0 + tail = _tail(e) + lat = e.latency_samples + if tail is None: + res["P4"] = "na(tail None)" + bound = last + BLOCK + lat + else: + bound = last + tail + lat + BLOCK + stop = bound + MARGIN + verdict, crc, s0 = _p4_stream(e, c, out, cout, last, bound, stop, + fsize, ev.name.startswith("E8-dry")) + if "P4" not in res: + res["P4"] = verdict + out = None + # P2: silence in, silence out after the tail. + feed.point(feed.sil) + tail = _tail(e) + if ev.kind == "raw": + # A whole RawSample is one buffer the class may still be + # playing through: the input is not silent until it has. + res["P2"] = "na(whole RawSample)" + wait = (tail or NO_TAIL_WAIT) + lat + BLOCK + MARGIN + elif tail is None: + res["P2"] = "na(tail None)" + wait = NO_TAIL_WAIT + else: + # Silence starts once the class has used the source buffer it + # was handed last. + b2 = s0 + tail + lat + BLOCK + int(ev.kind) + # Streamed: only the frames P2 reads are kept. + crc, left = _stream(e, (b2 - s0) * fsize, crc) + out = bytearray(left) + _pull_to(e, out, MARGIN * fsize, fsize) + out = out[:MARGIN * fsize] + crc = binascii.crc32(bytes(out), crc) + res["P2"] = _zero_after(out, 0, MARGIN, fsize, s0 - b2) + wait = tail + lat + BLOCK + MARGIN + res["h"] = "%08x" % (crc & 0xFFFFFFFF) + # P3: an event during silence plays nothing. Not for a whole + # RawSample, whose one buffer the class may hold part of. + if ev.kind == "raw": + res["P3"] = "na(whole RawSample)" + else: + res["P3"] = _p3(e, feed, ctx, ev, fsize, wait) + finally: + e.deinit() + c.deinit() + return res + + +def _pull_some(x, fsize): + """One pull that hands back bytes: 64 empty pulls in a row are a block + of silence.""" + for _ in range(64): + data = bytes(audiocore.get_buffer(x.output)[1]) + if data: + return data + return bytes(BLOCK * fsize) + + +def _p4_stream(e, c, out, cout, last, bound, stop, fsize, shifted): + """P4, streamed: the instance's and the control's bytes compared as + they are pulled, from the last move to `stop`. Returns the verdict, + the CRC of every byte the instance handed, and its frame count.""" + crc = binascii.crc32(bytes(out)) + base = 0 + lo = last * fsize + hi = stop * fsize + lastdiff = -1 + win_e = bytearray() + win_c = bytearray() + lo_e = bound * fsize + lo_c = (bound - BLOCK) * fsize + while base < hi: + if not out: + data = _pull_some(e, fsize) + crc = binascii.crc32(data, crc) + out = bytearray(data) + if not cout: + cout = bytearray(_pull_some(c, fsize)) + n = min(len(out), len(cout), hi - base) + a = out[:n] + b = cout[:n] + if base + n > lo and a != b: + d = _last_diff(a, b, max(0, lo - base), n) + if d >= 0: + lastdiff = base + d + if shifted: + if base + n > lo_e: + win_e.extend(a[max(0, lo_e - base):]) + if base + n > lo_c: + win_c.extend(b[max(0, lo_c - base):]) + out = out[n:] + cout = cout[n:] + base += n + s0 = (base + len(out)) // fsize + if lastdiff < 0 or lastdiff // fsize < bound: + return "ok", crc, s0 + if shifted: + for s in range(1, BLOCK + 1): + off = (BLOCK - s) * fsize + if win_c[off:off + len(win_e)] == win_e: + return "ok(shift %d)" % s, crc, s0 + return ("RED(differs at +%d, bound +%d)" % (lastdiff // fsize - last, + bound - last), crc, s0) + + +def _stream(e, nbytes, crc): + """Pull `nbytes` without keeping them, folded into `crc`. Returns the + crc and the bytes the last pull handed past `nbytes`.""" + got = 0 + empties = 0 + while got < nbytes: + data = bytes(audiocore.get_buffer(e.output)[1]) + if not data: + empties += 1 + if empties >= 64: + data = bytes(nbytes - got) + else: + continue + empties = 0 + take = min(len(data), nbytes - got) + crc = binascii.crc32(data[:take], crc) + got += take + if take < len(data): + return crc, data[take:] + return crc, b"" + + +def _zero_after(out, lo, hi, fsize, s0): + seg = out[lo * fsize:hi * fsize] + if _is_zero(seg): + return "ok" + vals = _samples(seg) + top = 0 + first = -1 + for i in range(len(vals)): + v = vals[i] + if v: + if first < 0: + first = i + if v < 0: + v = -v + if v > top: + top = v + return "RED(peak %d at +%d after input end, bound +%d)" % ( + top, lo - s0 + first // (fsize // 2), lo - s0) + + +def _p3(e, feed, ctx, ev, fsize, wait): + """Ring on the material, take the move away as the input stops, wait + for the output to reach exact silence, take the move back: the four + blocks after it are exactly zero. A single action is taken in the + silence instead.""" + out = bytearray() + if ev.first: + if not ev.back: + return "na" + # The first-pull variant: the move away before anything is pulled, + # a fresh instance, the material through it, then silence. + e = _build(ctx.cls, feed, feed.rate, ctx.patch, ctx.options, False) + ctx = Ctx(e, ctx.patch) + act(e, feed, ctx, ev.away) + feed.point(feed.mat) + try: + return _p3_body(e, feed, ctx, ev, fsize, wait, out) + finally: + if ev.first: + e.deinit() + + +def _p3_body(e, feed, ctx, ev, fsize, wait, out): + _pull_to(e, out, 8 * BLOCK * fsize, fsize) + feed.point(feed.sil) + if ev.back and not ev.first: + act(e, feed, ctx, ev.away) + blk = BLOCK * fsize + # Wait the whole tail, not only for a quiet stretch: a delay is quiet + # between the input and its first repeat. + tail = _tail(e) + hold = BLOCK if ev.kind == "raw" else int(ev.kind) + if tail is None: + need = max(wait, NO_TAIL_WAIT) + else: + need = max(wait, tail + e.latency_samples + BLOCK + hold + MARGIN) + _crc, left = _stream(e, (need - 8 * BLOCK) * fsize, 0) + seg = bytearray(left) + _pull_to(e, seg, 8 * blk, fsize) + if not _is_zero(seg): + return "na(never silent)" + if ev.back: + act(e, feed, ctx, ev.back) + else: + act(e, feed, ctx, ev.away) + seg = bytearray() + _pull_to(e, seg, 4 * blk, fsize) + if _is_zero(seg): + return "ok" + vals = _samples(seg) + top = 0 + first = -1 + for i in range(len(vals)): + v = vals[i] + if v: + if first < 0: + first = i + if v < 0: + v = -v + if v > top: + top = v + return "RED(peak %d from silence, first at +%d)" % ( + top, first // (fsize // 2)) + + +def p5(cls, ev, rate, channels, patch, options, controls): + """No step at the move larger than 1.5 x the larger of the material's + and the control's own largest step, on a triangle.""" + if not ev.control_move or ev.first: + return "na" + fsize = 2 * channels + values = material_for("tri", rate, channels) + key = ("tri", ev.kind, rate, channels, patch) + ctl = controls.get(key) + if ctl is None: + ctl = Control(cls, rate, channels, patch, options, ev.kind, "tri", + False) + controls[key] = ctl + total = (K0 + ev.gap + 6) * BLOCK + feed = Feed(values, rate, channels, ev.kind, loop=False) + e = _build(cls, feed, rate, patch, options, False) + ctx = Ctx(e, patch) + out = bytearray() + try: + at = _schedule_run(e, feed, ctx, ev, out, fsize, total) + finally: + e.deinit() + ref = _samples(bytes(ctl.upto(total)[:total * fsize])) + got = _samples(bytes(out[:total * fsize])) + own = _max_step(values, channels, 0, total) + base = max(own, _max_step(ref, channels, 0, total)) + limit = base * STEP_NUM // STEP_DEN + worst = 0 + where = 0 + for a in at: + lo = max(0, a - 1) + hi = min(total, a + BLOCK + 64) + s = _max_step(got, channels, lo, hi) + if s > worst: + worst = s + where = a + if worst <= limit: + return "ok" + return "RED(step %d at %d, limit %d)" % (worst, where, limit) + + +def e3(cls, rate, channels, patch, options): + """deinit(): the source still renders, the surface raises, a second + deinit is harmless.""" + fsize = 2 * channels + values = material_for("loop", rate, channels) + feed = Feed(values, rate, channels, "256", loop=True) + e = _build(cls, feed, rate, patch, options, False) + out = bytearray() + _pull_to(e, out, K0 * BLOCK * fsize, fsize) + e.deinit() + res = {} + try: + got = bytes(audiocore.get_buffer(feed.port)[1]) + res["P1"] = "ok" if len(got) == BLOCK * fsize else ( + "RED(source handed %d bytes)" % len(got)) + except Exception as err: # noqa: BLE001 + res["P1"] = "RED(source: %s)" % type(err).__name__ + quiet = [] + for name in sorted(dir(type(e))): + if name.startswith("_") or name.upper() == name or name in ( + "deinit", "create"): + continue + try: + value = getattr(e, name) + if callable(value): + args = {"set_macro": (0, 64), "get_macro": (0,), + "program_change": (0,), "macro": (0,), + "pitch_bend": (8192,), "control_change": (1, 64), + "channel_pressure": (64,), + "poly_pressure": (60, 64)}.get(name, ()) + value(*args) + quiet.append(name) + except Exception: # noqa: BLE001 + pass + try: + e.deinit() + second = "" + except Exception as err: # noqa: BLE001 + second = "; second deinit raised %s" % type(err).__name__ + if quiet or second: + res["PD"] = "RED(no raise after deinit: %s%s)" % ( + ",".join(quiet), second) + else: + res["PD"] = "ok" + return res + + +PROPS = ("P1", "P2", "P3", "P4", "P5") + + +def run_cell(cls, ev, rate, channels, patch, options, controls): + res = {} + res["P1"] = p1(cls, ev, rate, channels, patch, options) + res.update(main_render(cls, ev, rate, channels, patch, options, + controls)) + res["P5"] = p5(cls, ev, rate, channels, patch, options, controls) + for prop in PROPS: + if res[prop].startswith("RED") and _decl(cls, ev, prop): + res[prop] = "decl" + return res + + +def fmt(name, event, rate, channels, patch, res): + parts = [name, event, str(rate), str(channels), + "d" if patch is None else str(patch)] + for prop in sorted(res): + parts.append("%s:%s" % (prop, res[prop])) + return "|".join(parts) + + +def run_class(cls, options=None, rates=RATES, channels=CHANNELS, + patches=None, only=None, quick=False, emit=print, name=None): + """Run the matrix on `cls`, emitting one line per cell and a summary + line last. Returns (cells, red, declared).""" + options = options or {} + name = name or cls.NAME + if patches is None: + patches = [None] + sorted(cls.PATCHES) + cells = red = decl = 0 + for rate in rates: + for ch in channels: + for patch in patches: + controls = {} + try: + if only is None or "E3" in only: + res = e3(cls, rate, ch, patch, options) + line = fmt(name, "E3-deinit", rate, ch, patch, res) + emit(line) + cells += 1 + red += 1 if "RED" in line else 0 + for ev in events(cls, patch, quick): + if only is not None and ev.name.split("-")[0] \ + not in only: + continue + res = run_cell(cls, ev, rate, ch, patch, options, + controls) + line = fmt(name, ev.name, rate, ch, patch, res) + emit(line) + cells += 1 + red += 1 if "RED" in line else 0 + decl += line.count(":decl") + finally: + for ctl in controls.values(): + ctl.close() + emit("SUMMARY|%s|cells %d|red %d|declared %d" % (name, cells, red, decl)) + return cells, red, decl + + +# -------------------------------------------------------------------------- +# Planted faults: the matrix's proof that each property can fail. +# +# `Plain` is the control for P1, P2, P4, P5 and PD: one mixer voice whose +# level is the dry/wet law (Mix 0 is level 1.0, the source bit for bit; +# Mix 1 is the Gain macro's level), so nothing is ever routed around. +# `Routed` is the control for P3: Mix 0 routes around to the source and the +# way back rejoins and re-arms, the house pattern. Each plant breaks exactly +# one thing. + +_COMPONENT = _component.Component if _component is not None else object + + +class Plain(_COMPONENT): + NAME = "LifecyclePlain" + TIER = "audiodsp" if _component is None else _component.AUDIODSP + REQUIRES = ("audioroute",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = 0 + MACRO_LABELS = ("Mix", "Gain") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (0.0, 1.0)) + PATCHES = {0: ("Unity", (127, 127)), 1: ("Soft", (127, 96))} + + def _build(self, mix=1.0, gain=1.0, patch=None): + pcm = self._pcm(BLOCK * self._channel_count * 2) + self._mixer = self._own(audiomixer.Mixer(voice_count=2, **pcm)) + self._dc = _raw(array("h", bytes(4 * self._channel_count)), + self._sample_rate, self._channel_count) + self._arm() + self._output = self._mixer + self._macros = [mix, gain] + self._init_macros((mix, gain), patch) + + def _arm(self): + self._mixer.voice[0].play(self._source, loop=True) + self._mixer.voice[1].play(self._dc, loop=True) + + def _level(self): + mix = self._macros[0] + return 1.0 - mix + mix * self._macros[1] + + def _apply_macro(self, index, position): + self._mixer.voice[0].level = self._level() + + +class Routed(Plain): + """Mix 0 routes around a 5 ms echo line to the source; the way back + rejoins, clearing the line, and re-arms.""" + NAME = "LifecycleRouted" + REQUIRES = ("audioroute",) + TAIL_SAMPLES = 512 + + def _build(self, mix=1.0, gain=1.0, patch=None): + import audiodelays + pcm = self._pcm(BLOCK * self._channel_count * 2) + self._echo = self._own(audiodelays.Echo( + max_delay_ms=10, delay_ms=5, decay=0.0, mix=1.0, **pcm)) + Plain._build(self, mix, gain, patch) + + def _arm(self): + self._echo.play(self._source, loop=True) + self._mixer.voice[0].play(self._echo, loop=True) + self._mixer.voice[1].play(self._dc, loop=True) + + def _apply_macro(self, index, position): + if index == 0 and position == 0.0: + self._route_around(self._source) + return + if index == 0: + if self._rejoin(): + self._arm() + self._output = self._mixer + self._mixer.voice[0].level = self._macros[1] + + +class DropOnReset(Plain): + """P1: reset() swallows one block of the borrowed source.""" + + def reset(self): + Plain.reset(self) + audiocore.get_buffer(self._source) + + +class HoldDC(Plain): + """P2: after a reset the output sits on a DC offset for ever.""" + + def reset(self): + Plain.reset(self) + ch = self._channel_count + self._dc_on = _raw(array("h", [9] * (2 * ch)), self._sample_rate, ch) + self._mixer.voice[1].play(self._dc_on, loop=True) + + +class StaleMix(Routed): + """P3: Mix back from 0 takes the graph back untouched, so the echo line + plays what it held out of silence (stale_blocks.StaleRejoin).""" + + def _rejoin(self, keep=()): + self._stranded = False + return False + + +class LateMove(Plain): + """P4: a move is answered one move late, so away-and-back leaves the + class where it went.""" + + def _apply_macro(self, index, position): + late = getattr(self, "_late", None) + self._late = self._level() + self._mixer.voice[0].level = self._late if late is None else late + + +class HardSwitch(Plain): + """P5: Mix below half re-points the output to a primed silent mixer, + mid-waveform, a hard switch.""" + + def _build(self, mix=1.0, gain=1.0, patch=None): + pcm = self._pcm(BLOCK * self._channel_count * 2) + self._quiet = self._own(audiomixer.Mixer(voice_count=1, **pcm)) + self._quiet.voice[0].level = 0.0 + Plain._build(self, mix, gain, patch) + + def _arm(self): + Plain._arm(self) + self._quiet.voice[0].play(self._dc, loop=True) + + def _apply_macro(self, index, position): + Plain._apply_macro(self, index, position) + if self._constructing: + return + self._output = self._quiet if self._macros[0] < 0.5 else self._mixer + + +class ImplPlant(Plain): + """P6: the level is a hair different off CPython, so the bytes differ + between interpreters while every verdict stays the same.""" + + def _level(self): + level = Plain._level(self) + if sys.implementation.name != "cpython": + level = level * 0.99 + return level + + +class BusyDeinit(Plain): + """PD: tail_samples still answers after deinit.""" + + @property + def tail_samples(self): + return 0 + + +PLANTS = {"plain": Plain, "routed": Routed, "droponreset": DropOnReset, + "holddc": HoldDC, + "stalemix": StaleMix, "latemove": LateMove, + "hardswitch": HardSwitch, "busydeinit": BusyDeinit, + "implplant": ImplPlant} + + +def _cli(argv): + only = None + quick = False + names = [] + for arg in argv: + if arg.startswith("--events="): + only = arg.split("=", 1)[1].split(",") + elif arg == "--quick": + quick = True + else: + names.append(arg) + for name in names: + if name in PLANTS: + cls = PLANTS[name] + else: + from audioeffects import rebuilt + cls = rebuilt.module_class(name) + if cls is None: + print("MISSING|%s" % name) + continue + run_class(cls, only=only, quick=quick, name=name if name in PLANTS + else None) + print("DONE") + + +if __name__ == "__main__": + # Run as the module `lifecycle`, not `__main__`: `_component` reads + # VENDOR off the module a planted class is defined in. + sys.path.insert(0, "tests/support") + import lifecycle + lifecycle._cli(sys.argv[1:]) diff --git a/tests/support/stale_blocks.py b/tests/support/stale_blocks.py new file mode 100644 index 0000000..5616b06 --- /dev/null +++ b/tests/support/stale_blocks.py @@ -0,0 +1,223 @@ +"""The stale-block measurement, shared by the ten classes' tests +(audiocomponents#113). + +A control that routes a class's graph around - Mix to 0 handing the source +straight back, Lookahead to 0, Band Limit off - leaves the graph behind it +un-pulled, holding whatever it held. `blip()` plays the move the way a +musician would: a 300 Hz tone at 20 000 LSB for 0.5 s with the macro at `a`, +the macro to `b` as the tone stops, 1 s of silence, the macro back to `a`, +0.5 s more silence. It returns the peak over the last 0.25 s before the move +back (the class's own ring: 0 means the move back is into silence) and the +peak after it, short of the last two blocks. Sound there is old audio +coming back out of silence. + +`first_blip()` is the construction variant: built at its default (which is +wet, so a mixer voice primes itself with the first block of the source), Mix +to 0 before anything is pulled, the tone and the silence through the bypass, +then Mix up. What it returns is the peak after the move up. + +`StaleRejoin` and `ClearOnlyRejoin` are the two planted faults every class +test shows red, applied to a class with `planted(cls, fault)`: + +* `StaleRejoin` - the old behaviour: coming back off a bypass touches + nothing. +* `ClearOnlyRejoin` - a wrong cure: the nodes are cleared but the class is + told nothing happened, so it does not re-arm - the voices keep the blocks + they had queued and a biased shaper's coupling pole is left uncharged. +""" + +import math +import os +import sys +from array import array + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", + "lib")) + +import audiocore # noqa: E402 +import audiofilters # noqa: E402 + +#: `_component` reads VENDOR off the module a class is defined in, and the +#: planted classes `planted()` builds are defined here. +VENDOR = "PyDevices" + +BLOCK = 256 +RATES = (48000, 44100, 22050) + + +def _block_up(frames): + return (frames + BLOCK - 1) // BLOCK * BLOCK + + +def spans(rate): + """Where the tone stops, where the move back lands, and where reading + ends, in frames.""" + tone = _block_up(rate // 2) + back = tone + _block_up(rate) + end = back + _block_up(rate // 2) + return tone, back, end + + +def source(rate, channels): + """The tone, then silence, delivered a block at a time and never + again: `render_effect.py`'s bit-transparent adapter. A bare RawSample + hands its whole buffer to a class whose Mix 0 is the source itself, and + starts again when it runs out.""" + tone, _back, end = spans(rate) + values = array("h", bytes(2 * channels * end)) + step = 2.0 * math.pi * 300.0 / rate + for frame in range(tone): + value = int(20000 * math.sin(step * frame)) + for channel in range(channels): + values[frame * channels + channel] = value + sample = audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + wrap = audiofilters.Filter(filter=None, mix=1, + buffer_size=BLOCK * channels * 2, + sample_rate=rate, bits_per_sample=16, + samples_signed=True, channel_count=channels) + wrap.play(sample, loop=False) + return wrap + + +def _render(effect, frames, channels, moves): + out = array("h") + pending = list(moves) + while len(out) < frames * channels: + done = len(out) // channels + while pending and done >= pending[0][0]: + _frame, index, value = pending.pop(0) + effect.set_macro(index, value) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + data = bytes(2 * BLOCK * channels) + out.extend(array("h", data)) + return out + + +def _peak(values, lo, hi): + top = 0 + for value in values[lo:hi]: + if value < 0: + value = -value + if value > top: + top = value + return top + + +def _build(cls, rate, channels, patch, options): + effect = cls(source(rate, channels), sample_rate=rate, **options) + if patch is not None: + effect.program_change(patch) + return effect + + +def blip(cls, index, a, b, rate=48000, channels=2, patch=None, **options): + """(before, after): the peak before the move back and after it.""" + tone, back, end = spans(rate) + effect = _build(cls, rate, channels, patch, options) + effect.set_macro(index, a) + out = _render(effect, end, channels, + [(tone, index, b), (back, index, a)]) + effect.deinit() + ch = channels + return (_peak(out, (back - rate // 4) * ch, back * ch), + _peak(out, back * ch, (end - 2 * BLOCK) * ch)) + + +def twin(cls, index, a, rate=48000, channels=2, patch=None, **options): + """The peak an instance that never moves plays over the window `blip` + reads after the move back: the class's own floor there. A few settings + never reach silence whatever the move (an off-centre Bias's wander), + and a blip is only old audio where this is lower.""" + _tone, back, end = spans(rate) + effect = _build(cls, rate, channels, patch, options) + effect.set_macro(index, a) + out = _render(effect, end, channels, []) + effect.deinit() + return _peak(out, back * channels, (end - 2 * BLOCK) * channels) + + +def in_step(cls, index, a, b, rate=48000, channels=2, patch=None, + **options): + """The largest difference between a moved instance and one that never + moved over the second half of a second tone played after the move back: + 0 (or a few LSB of settling) when the graph came back in step, and + thousands when one leg came back a block out of line.""" + tone, back, end = spans(rate) + again = end + _block_up(rate // 4) + + def material(): + values = array("h", bytes(2 * channels * again)) + step = 2.0 * math.pi * 300.0 / rate + for lo, hi in ((0, tone), (end, again)): + for frame in range(lo, hi): + value = int(20000 * math.sin(step * (frame - lo))) + for channel in range(channels): + values[frame * channels + channel] = value + sample = audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + wrap = audiofilters.Filter( + filter=None, mix=1, buffer_size=BLOCK * channels * 2, + sample_rate=rate, bits_per_sample=16, samples_signed=True, + channel_count=channels) + wrap.play(sample, loop=False) + return wrap + + outs = [] + for moves in ([(tone, index, b), (back, index, a)], []): + effect = cls(material(), sample_rate=rate, **options) + if patch is not None: + effect.program_change(patch) + effect.set_macro(index, a) + outs.append(_render(effect, again, channels, moves)) + effect.deinit() + moved, still = outs + half = end + (again - end) // 2 + gap = 0 + for position in range(half * channels, again * channels): + step = moved[position] - still[position] + if step < 0: + step = -step + if step > gap: + gap = step + return gap + + +def first_blip(cls, index, rate=48000, channels=2, patch=None, **options): + """The peak after Mix comes up on an instance that went to 0 before its + first pull.""" + _tone, back, end = spans(rate) + effect = _build(cls, rate, channels, patch, options) + effect.set_macro(index, 0) + out = _render(effect, end, channels, [(back, index, 127)]) + effect.deinit() + ch = channels + return _peak(out, back * ch, (end - 2 * BLOCK) * ch) + + +class StaleRejoin: + """Planted: the old behaviour. A bypass left behind is taken back + untouched, so what the graph held plays.""" + + def _rejoin(self, keep=()): + self._stranded = False + return False + + +class ClearOnlyRejoin: + """Planted, a wrong cure: the nodes are cleared, but the class is not + told, so it does not re-arm them the way its constructor does.""" + + def _rejoin(self, keep=()): + if self._stranded: + self._stranded = False + self._clear_nodes(keep) + return False + + +def planted(cls, fault): + """`cls` with `fault` mixed in, under the same provider name.""" + return type(cls.__name__ + fault.__name__, (fault, cls), + {"NAME": cls.NAME}) diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py new file mode 100644 index 0000000..3ce1471 --- /dev/null +++ b/tests/test_cpython_effects_analogdelay.py @@ -0,0 +1,2662 @@ +"""`AnalogDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/AnalogDelay.md` +(frozen at anchor cc61011, the Station A critique revision, with the +post-build revisions of 2026-09-28 in its section 8: T3's inside no-step bar +carries the loop low-pass's carry across the move's coefficient change and +reads from the move's own first difference, the landing gap is read, the +walk is read where the head lands, the binade pieces have a resolvable +minimum, and T7's arrival clause is claimed across Mix's interior material +by material, at every grid position and at any Mix at or above +1.01 x 0.5 / W on the two loud materials; R13's Spread grid came out at +audiodsp v0.6.3rc3, whose node ends the stereo tail itself). Four of its +Tier 2 rows can be demonstrated, T2a, T3, T6 and T7, and each is here as +the measurement at a few of the row's cells beside the same measurement +shown red on the row's planted fault at the constructor defaults. Every +fault is shown unreachable from every macro position and shipped patch at +48, 44.1 and 22.05 kHz, and every row's measurement is shown red on the +class built as a wire. T1 is unmeasured and T2b, T4 and T5 are disconfirmed +by decision; `RecordedRows` holds the numbers their verdicts rest on, so a +build that gained a hold or a fixed pair would show up here first. The +full spans, the stereo renders the rows name, the three interpreters and +the M5 table live in the evidence pack, not in this file. + +The class is reached by `rebuilt.module_class("AnalogDelay")`, which is also what +`audioeffects.AnalogDelay` serves since its adoption on 2026-09-29. + +The dossier's laws are written out here independently of the class: the +corner is 0.2211 N / T, the Time map 20 * 30^position ms, the whole frame +floor(ms fs / 1000 + 0.5), and a Time move's walk rate |dT| / T_new. +""" + +import math +import os +import sys +import unittest +import wave +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import analogdelay as ad # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls) +from tools import effect_measurements as kit # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +RATES = (48000, 44100, 22050) +BLOCK = 256 +TIME_I, FEEDBACK_I, MIX_I, MODULATION_I, RATE_I, SPREAD_I, SYNC_I, \ + DIVISION_I = range(8) +SINGLE, DOUBLE = "single-line", "double-line" +STAGES = {SINGLE: 4096, DOUBLE: 8192} + +AnalogDelay = rebuilt.module_class("AnalogDelay") + +PROBE_DIR = os.path.join(os.path.dirname(__file__), "..", "tools", + "effect_probes") + + +# -------------------------------------------------------------------------- +# The dossier's laws, written out independently of the class + + +def law_corner(character, time_ms): + """0.2211 N / T, T the knob's milliseconds (dossier T2a).""" + return 0.2211 * STAGES[character] / (time_ms / 1000.0) + + +def whole(time_ms, rate): + """The nearest whole frame at the running rate (dossier section 6).""" + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +def grid_ms(midi): + """Time's map: 20-600 ms, log, at MIDI position `midi`.""" + return 20.0 * 30.0 ** (midi / 127.0) + + +def midi_of_ms(time_ms): + """Time's MIDI position for `time_ms`, unquantised.""" + return 127.0 * math.log(time_ms / 20.0) / math.log(30.0) + + +def clamp_hz(hz, rate): + return min(max(hz, 1.0), rate * 0.5 * 0.98) + + +def first_claimed(character, rate): + """The first Time grid position where the corner law is under the + clamp (dossier T2a, Quantified over).""" + for midi in range(128): + if law_corner(character, grid_ms(midi)) < rate * 0.5 * 0.98: + return midi + return None + + +def cents(ratio): + return 1200.0 * math.log(ratio) / math.log(2.0) + + +# -------------------------------------------------------------------------- +# Planted faults, one or more per demonstrated row, each of the row's kind + + +class CornerN6144(AnalogDelay): + """T2a: the corner law run on N = 6144 on both characters. N is the + character, not a macro, so no position or patch reaches it.""" + + NAME = 'AnalogDelay' + + def _corner_for(self, time_ms): + return ad.corner_hz(6144, time_ms) + + +class DoubleLineN6144(AnalogDelay): + """T6: double-line's corner law run on N = 6144, single-line's + untouched.""" + + NAME = 'AnalogDelay' + + def _corner_for(self, time_ms): + if self._character == DOUBLE: + return ad.corner_hz(6144, time_ms) + return AnalogDelay._corner_for(self, time_ms) + + +class JumpAnalogDelay(AnalogDelay): + """T3: the move as one jump (slew 0), which has no walk at all.""" + + NAME = 'AnalogDelay' + + def _walk_rate(self, from_frames, to_frames): + return 0.0 + + +class ConstantGlideWalk(AnalogDelay): + """T3: DigitalDelay's constant walk at its Glide 800 ms (slew 787.5 / + 800) in place of the clock's law.""" + + NAME = 'AnalogDelay' + + def _walk_rate(self, from_frames, to_frames): + return 787.5 / 800.0 + + +class UnflooredWalk(AnalogDelay): + """Section 6: the clock's law with no floor. A slew under half a + single-precision step of the head's position rounds away in the node's + walk, so a small move never lands.""" + + NAME = 'AnalogDelay' + + def _walk_rate(self, from_frames, to_frames): + return ad.clock_slew(from_frames, to_frames) + + +class _ReadStep(kit_faults._Node): + """A node in the path that, from frame `step_at` on, plays its source + `frames` frames late: the read stepping back that far, once, and + staying there.""" + + right_only = False + + def __init__(self, source, frames): + kit_faults._Node.__init__(self, source) + self.frames = int(frames) + self.pulled = 0 + self.step_at = None + self._history = np.zeros(self.frames * self.channel_count) + + def _process(self, block): + channels = self.channel_count + count = len(block) // channels + joined = np.concatenate([self._history, block]) + late = joined[:len(block)] + self._history = joined[len(block):] + out = block.copy() + if self.step_at is not None: + start = max(0, self.step_at - self.pulled) + if start < count: + if self.right_only and channels > 1: + first = start * channels + 1 + out[first::channels] = late[first::channels] + else: + out[start * channels:] = late[start * channels:] + self.pulled += count + return out + + +class MidWalkReadStep(AnalogDelay): + """T3's no-step clause: halfway through a Time move's walk the read + steps 8 frames late, once, and walks on from there. Nothing the surface + reaches puts a step in the walk: every Time move is one constant-rate + walk (section 6).""" + + NAME = 'AnalogDelay' + STEP_FRAMES = 8 + + def _build(self, *arguments, **options): + AnalogDelay._build(self, *arguments, **options) + self._step = _ReadStep(self._delay, self.STEP_FRAMES) + self._output = self._step + + def _refresh(self): + before, fresh = self._frames, self._fresh + AnalogDelay._refresh(self) + step = getattr(self, "_step", None) + if (step is not None and not fresh and self._frames != before + and self._slew > 0.0): + walk = abs(self._frames - before) / self._slew + step.step_at = step.pulled + int(walk // 2) + + +class RightReadStep(MidWalkReadStep): + """T3's stereo identity (dossier section 8, R10): `MidWalkReadStep` on + the right channel alone, so the left, which every T3 clause reads, walks + clean. Nothing the surface reaches treats the two lanes differently at + Spread 0.""" + + NAME = 'AnalogDelay' + + def _build(self, *arguments, **options): + MidWalkReadStep._build(self, *arguments, **options) + self._step.right_only = True + + +class EarlyStepWalk(AnalogDelay): + """T3's inside clause at the start of a walk (the round-2 re-refuter's + plant): on a Time move the node is handed `T_new - STEP` frames and + walks there at the clock's law, and `AT` frames after the move a node + in the path starts playing its source STEP frames late, so the read + still lands on `T_new`, with one step of STEP frames one frame into its + walk. Nothing the surface reaches does this: every Time move is one + constant-rate walk to the frame it names (section 6).""" + + NAME = 'AnalogDelay' + STEP = 3 + AT = 1 + + def _build(self, *arguments, **options): + AnalogDelay._build(self, *arguments, **options) + self._step = _ReadStep(self._delay, self.STEP) + self._output = self._step + + def _refresh(self): + before, fresh = self._frames, self._fresh + AnalogDelay._refresh(self) + step = getattr(self, "_step", None) + if (step is not None and not fresh and self._frames != before + and self._slew > 0.0): + target = self._frames - self.STEP + self._delay.set(delay_ms=ad.hand_off_ms(target, + self._sample_rate)) + step.step_at = step.pulled + self.AT + + +class _Blip(kit_faults._Node): + """From frame `start`, for `length` frames, play the source `frames` + frames late; otherwise pass it through.""" + + def __init__(self, source, frames, length): + kit_faults._Node.__init__(self, source) + self.frames = int(frames) + self.length = int(length) + self.pulled = 0 + self.start = None + self._history = np.zeros(self.frames * self.channel_count) + + def _process(self, block): + channels = self.channel_count + count = len(block) // channels + joined = np.concatenate([self._history, block]) + late = joined[:len(block)] + self._history = joined[len(block):] + out = block.copy() + if self.start is not None: + a = max(0, self.start - self.pulled) + b = min(count, self.start + self.length - self.pulled) + if a < b: + out[a * channels:b * channels] = late[a * channels: + b * channels] + self.pulled += count + return out + + +class LandingBlip(AnalogDelay): + """T3's landing gap clause (the round-2 re-refuter's plant): for one + frame, 10 frames past where the walk lands, the output plays its source + 8 frames late, then goes back. Nothing the surface reaches does this: + the head lands once and stays (section 6).""" + + NAME = 'AnalogDelay' + STEP = 8 + LENGTH = 1 + AFTER = 10 + + def _build(self, *arguments, **options): + AnalogDelay._build(self, *arguments, **options) + self._blip = _Blip(self._delay, self.STEP, self.LENGTH) + self._output = self._blip + + def _refresh(self): + before, fresh = self._frames, self._fresh + AnalogDelay._refresh(self) + blip = getattr(self, "_blip", None) + if (blip is not None and not fresh and self._frames != before + and self._slew > 0.0): + walk = int(math.ceil(abs(self._frames - before) / self._slew)) + blip.start = blip.pulled + walk + self.AFTER + + +class DryGainDelay(AnalogDelay): + """T7: the output +0.1 dB (`kit_faults.HiddenGain`), a dry above + unity, which nothing reaches: the node's dry is exactly 1 below Mix 1.""" + + NAME = 'AnalogDelay' + + def _build(self, *arguments, **options): + AnalogDelay._build(self, *arguments, **options) + self._output = kit_faults.HiddenGain(self._delay, 0.1) + + +class PlainHandOff(AnalogDelay): + """Section 6's hand-off: `k * 1000 / fs` as DigitalDelay hands it, + which the node's single-precision arithmetic lands a frame short at + some counts (patch 0's Time at 44.1 kHz).""" + + NAME = 'AnalogDelay' + + def _node_time_ms(self, frames): + return frames * 1000.0 / self._sample_rate + + +class SpreadInMono(AnalogDelay): + """Section 4: Spread passed to the node at one channel.""" + + NAME = 'AnalogDelay' + + def _refresh(self): + AnalogDelay._refresh(self) + spread = self._value(SPREAD_I) + self._spread = spread + self._delay.set(cross_feed=spread) + + +class TargetOnlyTail(AnalogDelay): + """Tail: sized on the target Time only, forgetting that a falling walk + starts from the old one.""" + + NAME = 'AnalogDelay' + + def _refresh(self): + AnalogDelay._refresh(self) + self._reach = self._frames + + +class NoSwingTail(AnalogDelay): + """Tail: the modulation's swing left out of the reach.""" + + NAME = 'AnalogDelay' + + def _tail_bound(self): + memory, excess = ad.tone_excess(self._damping, self._sample_rate) + laps = ad.laps_to_zero(self._feedback, excess) + return int(laps * (self._reach + 1 + memory)) + + +class HalfTail(AnalogDelay): + """Tail: half the bound.""" + + NAME = 'AnalogDelay' + + def _tail_bound(self): + return AnalogDelay._tail_bound(self) // 2 + + +class SteppedAnalog(AnalogDelay): + """The workaround retired at audiodsp v0.6.3rc1: the Feedback handed + to the node at the nearer edge of the loop low-pass's stall window + (`clear_of_stalls`), a Feedback nobody set. (Up to v0.6.2 the fault + here was the opposite, `RawFeedback`: the Feedback handed as set, which + held 1 LSB for ever inside a window.)""" + + NAME = 'AnalogDelay' + + def _refresh(self): + AnalogDelay._refresh(self) + excess = ad.tone_excess(self._damping, self._sample_rate)[1] + stepped = clear_of_stalls(self._feedback, excess) + if stepped != self._feedback: + self._feedback = stepped + self._delay.set(feedback=stepped) + + +class OneLapTail(AnalogDelay): + """Tail: one lap of the longest delay, where the bound counts the laps + to exact zero. Red on any tail that takes more than one lap.""" + + NAME = 'AnalogDelay' + + def _tail_bound(self): + return self._reach + 1 + + +class JumpModAnalog(AnalogDelay): + """A Modulation move that moves the read head by the whole change in + swing at once, as the node did at the triangle's peak up to v0.6.2 (it + added depth x triangle with no ramp).""" + + NAME = 'AnalogDelay' + + def _refresh(self): + old = self._swing_ms + AnalogDelay._refresh(self) + if (not getattr(self, "_seeding", False) + and not self._deferred and self._swing_ms != old): + self._delay.set(delay_slew=0.0, + delay_ms=self._node_ms + self._swing_ms - old) + + +class PerMacroPatch(AnalogDelay): + """A patch applied one macro at a time, refreshing after each.""" + + NAME = 'AnalogDelay' + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + _component.Component.program_change(self, index, channel, note_id, + sample_position) + + +# -------------------------------------------------------------------------- +# Sources and pulls + + +def array_src(values, channels=2, rate=RATE): + data = array("h") + for value in values: + for _ in range(channels): + data.append(int(value)) + return probes.ArraySource(data, rate=rate, channels=channels, block=BLOCK) + + +def silence_src(frames, channels=2, rate=RATE): + return probes.ArraySource(array("h", [0] * (frames * channels)), + rate=rate, channels=channels, block=BLOCK) + + +def sine_values(hz, frames, rate, level, phase=0.0): + n = np.arange(frames) + return np.round(level * np.sin(2.0 * math.pi * hz * n / rate + + phase)).astype(np.int64).tolist() + + +def pull(effect, frames, channels=None, on_block=None): + """Interleaved int16 of `frames` frames; `on_block(frame)` runs before + each block is pulled.""" + channels = channels or effect.channel_count + out = array("h") + while len(out) < frames * channels: + if on_block is not None: + on_block(len(out) // channels) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + out.extend([0] * (frames * channels - len(out))) + break + out.extend(memoryview(data).cast("h")) + return np.array(out[:frames * channels], dtype=np.int16) + + +def left(interleaved, channels): + return interleaved[0::channels].astype(np.float64) + + +def material(name, rate, channels): + path = os.path.join(PROBE_DIR, str(rate), "%dch" % channels, + name + ".wav") + handle = wave.open(path) + try: + data = handle.readframes(handle.getnframes()) + finally: + handle.close() + return array("h", data) + + +def analytic(x): + x = np.asarray(x, dtype=np.float64) + n = len(x) + spec = np.fft.fft(x) + h = np.zeros(n) + h[0] = 1.0 + if n % 2 == 0: + h[n // 2] = 1.0 + h[1:n // 2] = 2.0 + else: + h[1:(n + 1) // 2] = 2.0 + return np.fft.ifft(spec * h) + + +def inst_hz(x, rate): + phase = np.unwrap(np.angle(analytic(x))) + return np.diff(phase) * rate / (2.0 * math.pi) + + +# -------------------------------------------------------------------------- +# T2a / T6: the first repeat's -3 dB corner + + +def first_repeat(cls, rate, character, time_ms=None, midi=None, + amp=32767, frames=8192, **options): + """Mix 2, Feedback 0, one impulse of `amp` at frame 0; the first + repeat's samples from its whole-frame arrival, scaled to a unit + impulse. Time from the constructor (`time_ms`) or the grid (`midi`).""" + if midi is not None: + time_ms = grid_ms(midi) + arrival = whole(time_ms, rate) + total = arrival + frames + values = [0] * total + values[0] = amp + effect = cls(array_src(values, 1, rate), sample_rate=rate, + character=character, feedback=0.0, mix=2.0, + time_ms=time_ms, **options) + if midi is not None: + effect.set_macro(TIME_I, midi) + out = left(pull(effect, total, 1), 1) + effect.deinit() + return out[arrival:] / float(amp) + + +def magnitude_db(h, rate, hz): + k = np.arange(len(h)) + value = abs(np.dot(h, np.exp(-2j * math.pi * hz / rate * k))) + return 20.0 * math.log10(max(value, 1e-15)) + + +def crossing_hz(h, rate, level_db=-3.0103, low=10.0): + """The first frequency where |H| falls through `level_db`, by + bisection on the exact DTFT, or `None` where it never does below + 0.999 x Nyquist (a wire's empty first repeat, or no corner).""" + high = rate * 0.5 * 0.999 + if not np.any(h): + return None + if magnitude_db(h, rate, high) > level_db: + return None + if magnitude_db(h, rate, low) < level_db: + return None + for _ in range(50): + middle = math.sqrt(low * high) + if magnitude_db(h, rate, middle) > level_db: + low = middle + else: + high = middle + return math.sqrt(low * high) + + +def corner_ratio(cls, rate, character, time_ms=None, midi=None, amp=32767): + """The measured corner over `0.2211 N / T`, or `None`.""" + h = first_repeat(cls, rate, character, time_ms=time_ms, midi=midi, + amp=amp) + hz = crossing_hz(h, rate) + if hz is None: + return None + knob = grid_ms(midi) if midi is not None else time_ms + return hz / law_corner(character, knob) + + +def t2a_measure(cls, rate=RATE): + """T2a at the named cells, both characters: every ratio within 10 %, + and the octave ratios 1.8-2.2 between claimed cells.""" + ratios = {} + for character in (SINGLE, DOUBLE): + for time_ms in (75.0, 150.0, 300.0, 600.0): + if law_corner(character, time_ms) >= rate * 0.5 * 0.98: + continue + ratios[(character, time_ms)] = corner_ratio( + cls, rate, character, time_ms=time_ms) + passed = all(r is not None and abs(r - 1.0) <= 0.10 + for r in ratios.values()) + octaves = [] + if passed: + for (character, time_ms), ratio in ratios.items(): + longer = (character, 2.0 * time_ms) + if longer in ratios: + hz_short = ratio * law_corner(character, time_ms) + hz_long = ratios[longer] * law_corner(character, 2 * time_ms) + octaves.append(hz_short / hz_long) + passed = all(1.8 <= o <= 2.2 for o in octaves) + return {"passed": passed, "ratios": ratios, "octaves": octaves} + + +def t6_measure(cls, rate=RATE, times=(150.0, 300.0, 600.0)): + """T6: double-line's corner over single-line's at the same Time, + 1.8-2.2 at every cell where double-line's target is under the clamp.""" + ratios = {} + for time_ms in times: + if law_corner(DOUBLE, time_ms) >= rate * 0.5 * 0.98: + continue + single = first_repeat(cls, rate, SINGLE, time_ms=time_ms) + double = first_repeat(cls, rate, DOUBLE, time_ms=time_ms) + hz_single = crossing_hz(single, rate) + hz_double = crossing_hz(double, rate) + ratios[time_ms] = (None if hz_single is None or hz_double is None + else hz_double / hz_single) + passed = bool(ratios) and all( + r is not None and 1.8 <= r <= 2.2 for r in ratios.values()) + return {"passed": passed, "ratios": ratios} + + +# -------------------------------------------------------------------------- +# T3: a Time move glides on the clock's law + + +MOVE_AT = 40960 # a block boundary 853 ms in: the line is full + + +def default_move_at(rate): + """The block boundary T3 moves on at `rate`: MOVE_AT's 853 ms, floored + to a block.""" + return MOVE_AT * rate // 48000 // BLOCK * BLOCK + + +def t3_render(cls, rate, character, t_old, t_new, values, channels=1, + move_at=None, handed=None): + """Render `values` at Mix 2, Feedback 0, Time `t_old`, moving Time to + `t_new` through `set_macro(0, ...)` on the block at `move_at` (a block + boundary; MOVE_AT's by default). With a dict `handed`, the node's + options as handed by that move are copied into it, and the + `damping_hz` the constructor handed goes in as `damping_before`.""" + if handed is None: + effect = cls(array_src(values, channels, rate), sample_rate=rate, + character=character, time_ms=t_old, feedback=0.0, + mix=2.0) + else: + with NodeSpy(): + effect = cls(array_src(values, channels, rate), sample_rate=rate, + character=character, time_ms=t_old, feedback=0.0, + mix=2.0) + before = getattr(getattr(effect, "_delay", None), "_handed", {}) + handed["damping_before"] = before.get("damping_hz", 0.0) + target = midi_of_ms(t_new) + if move_at is None: + move_at = default_move_at(rate) + + def move(frame): + if frame == move_at: + if handed is None: + effect.set_macro(TIME_I, target) + else: + with NodeSpy(): + effect.set_macro(TIME_I, target) + handed.update(getattr(effect._delay, "_handed", {})) + + interleaved = pull(effect, len(values), channels, on_block=move) + if handed is not None and channels > 1: + # Every clause reads the left channel; the right is held to it byte + # for byte (dossier section 8, R10), so the left clauses read both. + lanes = interleaved.reshape(-1, channels).astype(np.int64) + handed["lr"] = int(np.max(np.abs(lanes - lanes[:, :1]))) + out = left(interleaved, channels) + effect.deinit() + return out, move_at + + +def t3_static(cls, rate, character, time_ms, values, channels=1): + effect = cls(array_src(values, channels, rate), sample_rate=rate, + character=character, time_ms=time_ms, feedback=0.0, mix=2.0) + out = left(pull(effect, len(values), channels), channels) + effect.deinit() + return out + + +def open_loop(cls): + """Measurement helper, not a fault: `cls` with the loop low-pass taken + out after every refresh, so a ramp render reads the read head's delay + with no filter lag on it. The walk does not depend on the filter.""" + def _refresh(self): + cls._refresh(self) + self._delay.set(damping_hz=0.0) + return type("OpenLoop" + cls.__name__, (cls,), {"_refresh": _refresh}) + + +def read_position(cls, rate, character, t_old, t_new, frames): + """The read head's delay, frame by frame, off a slope-1 ramp render + through `open_loop(cls)`: `n - ramp(n) - out(n)`. Superseded for T3 by + `head_trace` (dossier section 8, revision 2026-09-28); kept so the + Station C and refutation probes still run as they were written.""" + k_old = whole(t_old, rate) + move_at = default_move_at(rate) + origin = move_at - k_old - 2000 + n = np.arange(frames) + ramp = ((n - origin) % 65536) - 32768 + out, move_at = t3_render(open_loop(cls), rate, character, t_old, t_new, + ramp.tolist()) + return (n - origin - 32768) - out, move_at + + +def walk_end(delay, move_at, k_new): + """The first frame after the move at which the read position, and the + frame after it, sit within 2 frames of `k_new` (the Station A probe's + rule), or the end of the render if it never does. Superseded for T3 by + `landing` (revision 2026-09-28): it reads a walk 2 / slew frames early, + which is 10-21 % of a one-grid-step move from the 20 ms stop.""" + near = np.abs(delay[move_at:] - k_new) <= 2.0 + both = np.nonzero(near[:-1] & near[1:])[0] + if both.size == 0: + return len(delay) + return move_at + int(both[0]) + + +def binade_pieces(delay, move_at, end, law): + """Each piece of the walk between powers of two of the read position, + at least 1000 frames long, as (pitch ratio off a least-squares slope, + frames). Superseded for T3 by `walk_pieces` (revision 2026-09-28): it + found no piece on 60 -> 20 and 40 -> 20 ms at 48 and 22.05 kHz, and T3 + then passed with nothing read; kept so the refutation probes still + run as they were written.""" + span = delay[move_at:end] + powers = np.floor(np.log2(np.maximum(span, 1.0))) + pieces = [] + start = 0 + for index in range(1, len(span) + 1): + if index == len(span) or powers[index] != powers[start]: + a, b = start + 16, index - 16 + if b - a >= 1000: + x = np.arange(a, b, dtype=np.float64) + slope = np.polyfit(x, span[a:b], 1)[0] + pieces.append((1.0 - slope, b - a)) + start = index + return pieces + + +def ramp_slope(k_old, k_new): + """The steepest power-of-two ramp slope, up to 64 LSB per frame, whose + period still holds the longer delay twice over with room for the walk: + the head then reads to 1 / slope frame.""" + kmax = max(k_old, k_new) + slope = 1 + while slope < 64 and 65536 // (2 * slope) > 1.25 * kmax + 64: + slope *= 2 + return slope + + +def head_trace(cls, rate, character, t_old, t_new, frames, move_at=None, + channels=1): + """The read head's delay off one steep ramp render through + `open_loop(cls)`: (delay per frame to 1 / slope frame, the frames whose + read straddles the ramp's wrap marked False, slope, move frame).""" + k_old, k_new = whole(t_old, rate), whole(t_new, rate) + slope = ramp_slope(k_old, k_new) + n = np.arange(frames) + ramp = ((n * slope) % 65536) - 32768 + out, move_at = t3_render(open_loop(cls), rate, character, t_old, t_new, + ramp.tolist(), channels, move_at=move_at) + period = 65536.0 / slope + delay = np.mod(n - (out + 32768.0) / slope, period) + bad = np.abs(np.diff(out, prepend=out[0])) > 4 * slope + for k in (1, 2): + bad[:-k] |= bad[k:].copy() + bad[k:] |= bad[:-k].copy() + return delay, ~bad, slope, move_at + + +def landing(delay, valid, slope, move_at, k_new): + """The frame after the last one, from the move on, at which the head + is more than one ramp step (1 / slope frame) off `k_new`: where the + walk lands, at the ramp's resolution. The end of the render if the + head is still off there.""" + off = (np.abs(delay[move_at:] - k_new) > 1.0 / slope) & valid[move_at:] + idx = np.nonzero(off)[0] + if idx.size == 0: + return move_at + last = move_at + int(idx[-1]) + 1 + return len(delay) if last >= len(delay) - 3 else last + + +def min_piece(slope, k_old, k_new): + """The shortest piece whose rate the ramp resolves to 1 cent: one ramp + step of position (1 / slope frame) over the piece moves its rate by at + most (2^(1/1200) - 1) of the law's pitch ratio k_old / k_new.""" + ratio = float(k_old) / k_new + return int(math.ceil(1.0 / (slope * ratio * (2.0 ** (1.0 / 1200.0) + - 1.0)))) + + +def walk_pieces(delay, valid, slope, move_at, end, k_old, k_new): + """The walk cut where the head crosses each power of two between the + two Times (the node walks the head in single precision, so each binade + plays at its own rate), each piece's pitch ratio 1 - dD/dn by least + squares over its valid frames, as (ratio, frames). A piece shorter than + `min_piece` is left out; if none is left, the whole walk is one piece, + and if that is short too, the list is empty and the clause is red.""" + shortest = min_piece(slope, k_old, k_new) + lo_k, hi_k = sorted((k_old, k_new)) + edges = [2 ** e for e in range(1, 20) if lo_k < 2 ** e < hi_k] + cuts = [move_at] + for edge in sorted(edges, reverse=k_new < k_old): + seg = delay[move_at:end] + hit = (seg >= edge) if k_new > k_old else (seg < edge) + idx = np.nonzero(hit & valid[move_at:end])[0] + if idx.size: + cuts.append(move_at + int(idx[0])) + cuts.append(end) + + def fit(a, b): + m = np.arange(a + 2, b - 2) + m = m[valid[m]] + if len(m) < shortest: + return None + return (1.0 - np.polyfit(m.astype(float), delay[m], 1)[0], len(m)) + + pieces = [p for p in (fit(a, b) for a, b in zip(cuts, cuts[1:])) + if p is not None] + if not pieces: + whole_walk = fit(move_at, end) + if whole_walk is not None: + pieces = [whole_walk] + return pieces + + +def one_pole_keep(damping_hz, rate): + """1 - a for the node's loop low-pass at `damping_hz`, a = 1 - + exp(-2 pi damping_hz / fs) (`one_pole_coefficient`); 0 with the + low-pass out, where the node passes the read straight through (a = 1).""" + if damping_hz > 0.0: + return math.exp(-2.0 * math.pi * damping_hz / rate) + return 0.0 + + +def carry_c0(damping_before, damping_hz, rate): + """c0 = (a_new / a_old)(1 - a_old): what the low-pass's first + difference keeps of the last pre-move difference across the one sample + at which its coefficient changes from `damping_before`'s to + `damping_hz`'s. The node's update is y += a (x - y) + (`audiodsp_feedback_delay.c:493-496`, the coefficient set at `:170`), + and x - y before the move is d(-1) (1 - a_old) / a_old, so + d(0) = a_new x'(0) + c0 d(-1). With `damping_before` None this is 1, + the fix-round-1 term, kept so its probes still run as written.""" + if damping_before is None: + return 1.0 + a_new = 1.0 - one_pole_keep(damping_hz, rate) + a_old = 1.0 - one_pole_keep(damping_before, rate) + return (a_new / a_old) * (1.0 - a_old) + + +def memory_bars(bar, pre_bar, damping_hz, rate, frames, damping_before=None, + first=1): + """T3's inside no-step bar, frame by frame (dossier section 8, + revisions R1 and R8): the shifted tone's own largest first difference, + plus what the loop low-pass can still carry of the pre-move tone's + slope, `c0 * pre_bar * (1 - a_new)^(k + first)`, `c0` from `carry_c0` + with `a_old` from the `damping_hz` the node held before the move and + `a_new` from the one the move handed it. Exponent 0 is the move's own + first difference, `diff[move - 1]` = d(0), and exponent k + 1 is + `diff[move + k]` = d(k + 1). After the move the first difference obeys + d(k) = (1 - a_new) d(k - 1) + a_new x'(k), so this is the node's own + carry of |d(-1)| <= pre_bar. With `damping_before` None, `c0` is 1: + the fix-round-1 term, which was 4.33 to 8.12 times the carry on rising + moves and 0.50 to 5.82 times on falling ones, kept so its probes still + run as written.""" + keep = one_pole_keep(damping_hz, rate) + c0 = carry_c0(damping_before, damping_hz, rate) if keep > 0.0 else 0.0 + k = np.arange(frames, dtype=np.float64) + return bar + c0 * pre_bar * keep ** (k + first) + + +def inside_clause(diff, move_at, walk, bar, pre_bar, damping_hz, rate, + damping_before=None): + """T3's inside no-step statistic, as (largest ratio, its frame from the + move, the settle, the largest first difference in `diff[move : move + + span]` with no memory term, which is the frozen statistic's numerator). + + With `damping_before` (revision R8, fix round 2) the window is + `diff[move - 1 : move + span]`, from the move's own first difference to + the one before the landing's, each against `memory_bars` on the node's + carry (exponent 0 at `diff[move - 1]`, whose frame is reported as -1). + The difference into the landed frame is the landing gap clause's + (`t3_measure`). `span` is at least `settle`, the frames until the carry + term falls under 5 % of the bar, so a move with no walk (the jump) is + still read. With `damping_before` None it is fix round 1's clause as + it ran: the window from `diff[move]`, the term with no `c0`, and the + settle off that term.""" + keep = one_pole_keep(damping_hz, rate) + carried = pre_bar * (carry_c0(damping_before, damping_hz, rate) + if keep > 0.0 else 0.0) + settle = 1 + if keep > 0.0 and carried > 0.05 * bar: + settle = max(1, int(math.ceil( + math.log(0.05 * bar / carried) / math.log(keep)))) + span = min(max(walk - 1, settle, 1), len(diff) - move_at) + frozen = float(np.max(diff[move_at:move_at + span])) + if damping_before is None: + window = diff[move_at:move_at + span] + ratios = window / memory_bars(bar, pre_bar, damping_hz, rate, span) + worst = int(np.argmax(ratios)) + return float(ratios[worst]), worst, settle, frozen + window = diff[move_at - 1:move_at + span] + ratios = window / memory_bars(bar, pre_bar, damping_hz, rate, span + 1, + damping_before, first=0) + worst = int(np.argmax(ratios)) + return float(ratios[worst]), worst - 1, settle, frozen + + +def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, + level=12000, channels=1, phase=0.0, move_at=None): + """T3's clauses on one move: the pitch over the whole walk and on each + binade piece within 10 cents of 1200 log2(T_old / T_new), the walk + within 5 % of T_new (read where the head lands, off a steep ramp), the + residual 50-250 ms after it within 1 cent, and the three no-step + statistics within 5 % of their bars: inside the walk against + `memory_bars` on the node's carry; over the landing gap, + `diff[landing - 1 : landing + 64]`, against the larger of the inside + and after bars (revision R8: no clause read those frames before); and + after it, `diff[landing + 64 : landing + 4064]`, against the unshifted + tone's bar. `inside_from_move` over `inside_bar` is the frozen + statistic (the largest first difference from the move frame against + the shifted tone's bar, no memory term) over the same window, and + `inside_r1` the fix-round-1 bar's reading (no `c0`), kept beside the + revision.""" + k_old, k_new = whole(t_old, rate), whole(t_new, rate) + law = cents(float(k_old) / k_new) + if move_at is None: + move_at = default_move_at(rate) + frames = move_at + int(1.25 * k_new) + 400 + int(0.3 * rate) + 4096 + delay, valid, slope, _ = head_trace(cls, rate, character, t_old, t_new, + frames, move_at, channels) + end = landing(delay, valid, slope, move_at, k_new) + walk = end - move_at + walk_ok = abs(walk - k_new) <= 0.05 * k_new + + handed = {} + tone = sine_values(997.0, frames, rate, level, phase) + out, _ = t3_render(cls, rate, character, t_old, t_new, tone, channels, + move_at=move_at, handed=handed) + diff = np.abs(np.diff(out.astype(np.int64))) + + shifted = 997.0 * k_old / k_new + ref = t3_static(cls, rate, character, t_new, + sine_values(shifted, k_new + 8192, rate, level), channels) + inside_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) + ref = t3_static(cls, rate, character, t_old, + sine_values(997.0, k_old + 8192, rate, level), channels) + pre_bar = float(np.max(np.abs(np.diff(ref[k_old + 2048:])))) + ref = t3_static(cls, rate, character, t_new, + sine_values(997.0, k_new + 8192, rate, level), channels) + later_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) + + damping = float(handed.get("damping_hz", 0.0)) + damping_before = float(handed.get("damping_before", 0.0)) + inside_ratio, worst, settle, inside_from_move = inside_clause( + diff, move_at, walk, inside_bar, pre_bar, damping, rate, + damping_before) + inside_ok = inside_ratio <= 1.05 + inside_r1 = inside_clause(diff, move_at, walk, inside_bar, pre_bar, + damping, rate)[0] + + result = {"walk": walk, "k_new": k_new, "law": law, "slope": slope, + "inside_ratio": inside_ratio, "inside_at": worst, + "inside_ok": inside_ok, "inside_from_move": inside_from_move, + "inside_bar": inside_bar, "pre_bar": pre_bar, + "damping_hz": damping, "damping_before": damping_before, + "settle": settle, "inside_r1": inside_r1, + "later_bar": later_bar, "walk_ok": walk_ok, + "lr": int(handed.get("lr", 0))} + if walk < 64 or end + int(0.25 * rate) + 1 > frames: + # No walk at all, or one that never lands on T_new inside the + # render: the walk clause is red and the pitch has nothing to read. + result.update(passed=False, whole=None, pieces=[], residual=None, + later=None, gap=None, gap_ok=False) + return result + hz = inst_hz(out, rate) + margin = min(400, walk // 10) + whole_cents = cents(float(np.median(hz[move_at + margin:end - margin])) + / 997.0) + pieces = [cents(ratio) for ratio, _ in + walk_pieces(delay, valid, slope, move_at, end, k_old, k_new)] + after = hz[end + int(0.05 * rate):end + int(0.25 * rate)] + residual = cents(float(np.median(after)) / 997.0) + later = float(np.max(diff[end + 64:end + 4064])) + gap = float(np.max(diff[end - 1:end + 64])) + gap_ok = gap <= 1.05 * max(inside_bar, later_bar) + + result.update(whole=whole_cents, pieces=pieces, residual=residual, + later=later, gap=gap, gap_ok=gap_ok) + result["passed"] = ( + walk_ok + and abs(whole_cents - law) <= 10.0 + and len(pieces) > 0 + and all(abs(p - law) <= 10.0 for p in pieces) + and abs(residual) <= 1.0 + and inside_ok + and gap_ok + and later <= 1.05 * later_bar + and result["lr"] == 0) + return result + + +# -------------------------------------------------------------------------- +# T7: the dry path is a wire until the first repeat, and the repeat is there + + +def t7_measure(cls, rate=RATE, channels=2, name="ramp_fs", **options): + """WIRE over the first T - S - 1 frames of the kit's material, and, + where Mix is above 0 and the material outlasts it, the output not the + source somewhere in [T - S - 1, T + S + 64).""" + probe_effect = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + frames_t = probe_effect._frames + swing = int(math.ceil(probe_effect._swing_ms * rate / 1000.0)) + mix = probe_effect.macro(MIX_I) + probe_effect.deinit() + data = material(name, rate, channels) + total = len(data) // channels + effect = cls(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), sample_rate=rate, **options) + out = pull(effect, total, channels) + effect.deinit() + src = np.array(data, dtype=np.int16) + window = max(0, frames_t - swing - 1) * channels + differing = int(np.count_nonzero(out[:window] != src[:window])) + arrival_end = (frames_t + swing + 64) * channels + applies = mix > 0.0 and arrival_end <= len(src) + arrived = None + if applies: + arrived = int(np.count_nonzero(out[window:arrival_end] + != src[window:arrival_end])) + passed = differing == 0 and (not applies or arrived > 0) + return {"passed": passed, "differing": differing, "arrived": arrived, + "frames": frames_t, "swing": swing} + + +def wet_peak_in_arrival(rate, channels, name, character=SINGLE): + """W: the wet alone (Mix 2) at the defaults, its largest magnitude in + [T - 1, T + 64), as the node rounds it.""" + probe_effect = AnalogDelay(silence_src(64, channels, rate), + sample_rate=rate, character=character) + k = probe_effect._frames + probe_effect.deinit() + data = material(name, rate, channels) + effect = AnalogDelay(probes.ArraySource(data, rate=rate, + channels=channels, block=BLOCK), + sample_rate=rate, character=character, mix=2.0) + out = pull(effect, len(data) // channels, channels) + effect.deinit() + lo, hi = (k - 1) * channels, (k + 64) * channels + return float(np.max(np.abs(out[lo:hi].astype(np.float64)))) + + +# -------------------------------------------------------------------------- +# Tail + + +def kit_tail(cls, seconds, rate=RATE, **options): + data, on = probes.burst_silence(total_s=seconds, rate=rate) + effect = cls(probes.ArraySource(data, rate=rate, channels=2, + block=BLOCK), sample_rate=rate, **options) + declared = effect.tail_samples + render = probes.render(effect, int(seconds * rate), rate=rate, + channels=2) + result = kit.tail(render, burst_end_frame=on, + declared_tail_samples=declared, + settle_frames=effect._frames) + effect.deinit() + return declared, result["values"], result["red"] + + +def fullscale_tail(cls, rate=RATE, channels=1, **options): + """A full-scale DC fill for two laps, then silence: the last non-zero + frame after the input stops against `tail_samples`.""" + probe_effect = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + declared = probe_effect.tail_samples + frames_t = probe_effect._frames + probe_effect.deinit() + fill = (2 * frames_t + BLOCK) // BLOCK * BLOCK + total = fill + declared + 2 * BLOCK + values = [32767] * fill + [0] * (total - fill) + effect = cls(array_src(values, channels, rate), sample_rate=rate, + **options) + out = pull(effect, total, channels) + after = out[fill * channels:] + nonzero = np.nonzero(after)[0] + last = 0 if nonzero.size == 0 else int(nonzero[-1]) // channels + 1 + return {"passed": last <= declared, "declared": declared, "last": last} + + +# -------------------------------------------------------------------------- +# Reachability: what the node is handed, or what the output does, at the +# position walked. The twin's `FeedbackDelay.set` is watched while a walk +# runs, so a reading sees the options the class actually handed over. + + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node: `_handed` (the latest value of each option) and + `_writes` (each call's options, in order).""" + + def __enter__(self): + node_class = ad.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._writes = [] + node._handed.update(options) + node._writes.append(dict(options)) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def copy_of(effect, source): + """A fresh instance of the same class and character on `source`, at + `effect`'s macro positions.""" + other = type(effect)(source, sample_rate=effect._sample_rate, + character=effect._character) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + return other + + +def read_corner_law(effect): + """The `damping_hz` handed to the node against the pre-warp of the + dossier's corner law at the knob's milliseconds, clamped, for this + instance's character.""" + rate = effect._sample_rate + knob = effect.macro(TIME_I) + law = nominal_damping_hz(clamp_hz(law_corner(effect._character, knob), + rate), rate) + return round(effect._delay._handed["damping_hz"] / law, 4) + + +def read_walk_law(effect): + """On a copy at these positions, one Time move of 32 grid steps after + a block has been pulled: the `delay_slew` handed against the dossier's + |dT| / T_new of the two whole frames.""" + rate = effect._sample_rate + other = copy_of(effect, silence_src(4 * BLOCK, 2, rate)) + pull(other, BLOCK, 2) + before = other._frames + now = other.get_macro(TIME_I) + other.set_macro(TIME_I, now + 32.0 if now < 64 else now - 32.0) + after = other._frames + slew = other._delay._handed["delay_slew"] + other.deinit() + law = abs(after - before) / float(after) + return round(slew / law, 4) + + +def read_feedback_as_set(effect): + """The Feedback handed to the node less the knob's (since audiodsp + v0.6.3rc1 the class hands the Feedback as set).""" + return round(effect._delay._handed["feedback"] + - effect.macro(FEEDBACK_I), 9) + + +def read_modulation_move(effect): + """On a copy at these positions, one Modulation move of 16 grid steps + after a block has been pulled: the `delay_ms` handed less the + whole-frame Time's hand-off (a Modulation move changes the depth, not + the delay).""" + rate = effect._sample_rate + other = copy_of(effect, silence_src(4 * BLOCK, 2, rate)) + pull(other, BLOCK, 2) + now = other.get_macro(MODULATION_I) + other.set_macro(MODULATION_I, now + 16.0 if now < 64 else now - 16.0) + offset = other._delay._handed["delay_ms"] - other._node_ms + other.deinit() + return round(offset, 6) + + +def read_dry_gain(effect): + """The dry path's gain before the first repeat: a copy at these + positions on a 256-frame full-scale ramp, least-squares out / in.""" + ramp = probes.ramp_fs(frames=256, channels=2) + other = copy_of(effect, probes.ArraySource( + ramp, rate=effect._sample_rate, channels=2, block=BLOCK)) + out = pull(other, 256, 2).astype(np.float64) + src = np.array(ramp, dtype=np.float64) + other.deinit() + return round(float(np.dot(out, src) / np.dot(src, src)), 5) + + +def read_landing(effect): + """Whether the node's own single-precision arithmetic lands the handed + delay at or above its whole frame.""" + ms = np.float32(effect._delay._handed["delay_ms"]) + frames = float(ms * np.float32(effect._sample_rate) / np.float32(1000.0)) + return frames >= effect._frames + + +def read_lanes(effect): + """At these positions, stereo, with Mix 2, Feedback 0 and Modulation 0, + one Time move of 32 grid steps once the line has filled, on a slope-1 + ramp in both lanes: the largest |L - R| over the render.""" + rate = effect._sample_rate + now = effect.get_macro(TIME_I) + new = now + 32.0 if now < 64 else now - 32.0 + k_old = effect._frames + k_new = whole(grid_ms(new), rate) + fill = (k_old + 2 * BLOCK) // BLOCK * BLOCK + frames = fill + int(1.3 * k_new) + 2 * BLOCK + ramp = (np.arange(frames) % 65536) - 32768 + other = type(effect)(array_src(ramp.tolist(), 2, rate), sample_rate=rate, + character=effect._character) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + other.set_macro(MIX_I, 127) + other.set_macro(FEEDBACK_I, 0) + other.set_macro(MODULATION_I, 0) + + def move(frame): + if frame == fill: + other.set_macro(TIME_I, new) + + lanes = pull(other, frames, 2, on_block=move).reshape(-1, 2) + other.deinit() + lanes = lanes.astype(np.int64) + return int(np.max(np.abs(lanes[:, 1] - lanes[:, 0]))) + + +def read_step(effect): + """On an open-loop copy at these positions, read with Mix 2, Feedback 0 + and Modulation 0, one Time move of 32 grid steps once the line has + filled, off a slope-1 ramp: whether the read head jumps, in any one + frame of the walk, by more than the walk's own rate rounded up plus + one frame.""" + rate = effect._sample_rate + now = effect.get_macro(TIME_I) + new = now + 32.0 if now < 64 else now - 32.0 + k_old = effect._frames + k_new = whole(grid_ms(new), rate) + fill = (k_old + 2 * BLOCK) // BLOCK * BLOCK + frames = fill + int(1.3 * k_new) + 2 * BLOCK + n = np.arange(frames) + ramp = (n % 65536) - 32768 + other = open_loop(type(effect))(array_src(ramp.tolist(), 1, rate), + sample_rate=rate, + character=effect._character) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + other.set_macro(MIX_I, 127) + other.set_macro(FEEDBACK_I, 0) + other.set_macro(MODULATION_I, 0) + + def move(frame): + if frame == fill: + other.set_macro(TIME_I, new) + + out = left(pull(other, frames, 1, on_block=move), 1) + slew = other._slew + other.deinit() + delay = np.mod(n - (out + 32768.0), 65536.0) + # A read that straddles the ramp's wrap is not a jump of the head. + bad = np.abs(np.diff(out, prepend=out[0])) > 64.0 + for k in (1, 2): + bad[:-k] |= bad[k:].copy() + bad[k:] |= bad[:-k].copy() + good = ~bad + jumps = np.abs(np.diff(delay[fill:]))[good[fill + 1:]] + return bool(np.max(jumps) > math.ceil(slew) + 1.0) + + +def read_early(effect): + """On a copy at these positions, one Time move of 32 grid steps after + a block has been pulled: whether the `delay_ms` handed is not the hand + off of the whole frame the move names (EarlyStepWalk's signature).""" + rate = effect._sample_rate + other = copy_of(effect, silence_src(4 * BLOCK, 2, rate)) + pull(other, BLOCK, 2) + now = other.get_macro(TIME_I) + other.set_macro(TIME_I, now + 32.0 if now < 64 else now - 32.0) + handed = float(other._delay._handed["delay_ms"]) + frames = other._frames + other.deinit() + return handed != ad.hand_off_ms(frames, rate) + + +#: (name, fault, reading, constructor options for both builds). +REACH_WALKS = ( + ("CornerN6144", CornerN6144, read_corner_law, {}), + ("CornerN6144 double", CornerN6144, read_corner_law, + {"character": DOUBLE}), + ("DoubleLineN6144", DoubleLineN6144, read_corner_law, + {"character": DOUBLE}), + ("JumpAnalogDelay", JumpAnalogDelay, read_walk_law, {}), + ("ConstantGlideWalk", ConstantGlideWalk, read_walk_law, {}), + ("DryGainDelay", DryGainDelay, read_dry_gain, {}), + ("MidWalkReadStep", MidWalkReadStep, read_step, {}), + ("EarlyStepWalk", EarlyStepWalk, read_early, {}), + ("LandingBlip", LandingBlip, read_step, {}), + ("RightReadStep", RightReadStep, read_lanes, {}), + ("SteppedAnalog", SteppedAnalog, read_feedback_as_set, + {"feedback": 0.99}), + ("JumpModAnalog", JumpModAnalog, read_modulation_move, {}), +) + + +def reach(faulted, reading, rate, ctor): + """`kit_faults.fault_reachability` at `rate`, the node watched.""" + def build(cls): + return cls(silence_src(512, 2, rate), sample_rate=rate, **ctor) + + with NodeSpy(): + return kit_faults.fault_reachability(AnalogDelay, faulted, reading, + build) + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + cls = AnalogDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Feedback", "Mix", "Modulation", "Mod Rate", + "Spread", "Sync", "Division")) + self.assertEqual(cls.MACRO_MODES[SYNC_I], "TOGGLE") + self.assertEqual(len(cls.PATCHES), 7) + self.assertEqual(cls.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho",)) + effect = cls(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ("tempo_sync",)) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects + self.assertIn("AnalogDelay", rebuilt.ADOPTED) + self.assertNotIn("AnalogDelay", rebuilt.parked()) + self.assertIs(audioeffects.AnalogDelay, AnalogDelay) + served = audioeffects.create("AnalogDelay", silence_src(64), RATE) + self.assertIsInstance(served, AnalogDelay) + served.deinit() + + def test_patches_are_the_dossier_settings_on_the_grid(self): + # Section 6's table, settings in the macros' own units, through + # `_component.macro_of`; patch 0 is the constructor's defaults. + spans = AnalogDelay._MACRO_RANGES + settings = { + 0: (300.0, 0.40, 0.40, 0.0, 0.8, 0.0, 0.0, 6.0), + 1: (60.0, 0.30, 0.50, 0.0, 0.8, 0.0, 0.0, 6.0), + 2: (550.0, 0.55, 0.40, 0.0, 0.8, 0.0, 0.0, 6.0), + 3: (350.0, 0.45, 0.45, 3.0, 1.0, 0.0, 0.0, 6.0), + 4: (40.0, 0.0, 2.0, 2.0, 5.0, 0.0, 0.0, 6.0), + 5: (450.0, 0.85, 0.35, 1.0, 0.4, 0.5, 0.0, 6.0), + 6: (300.0, 0.45, 0.40, 0.0, 0.8, 0.0, 1.0, 8.0), + } + names = ("Single Line Repeats", "Short Bright Repeats", + "Long Dark Repeats", "Modulated Repeats", "Wet Vibrato", + "High Feedback Wash", "Dotted Eighth, Synced") + for index, values in settings.items(): + grid = tuple(_component.macro_of(span, value) + for span, value in zip(spans, values)) + self.assertEqual(AnalogDelay.PATCHES[index], + (names[index], grid), index) + effect = AnalogDelay(silence_src(512), sample_rate=RATE) + for index, expected in enumerate(AnalogDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_characters(self): + for character, stages in ((SINGLE, 4096), (DOUBLE, 8192)): + effect = AnalogDelay(silence_src(64), sample_rate=RATE, + character=character) + self.assertEqual(effect._stages, stages) + with self.assertRaises(ValueError): + AnalogDelay(silence_src(64), sample_rate=RATE, + character="triple-line") + + def test_the_corner_law_as_handed(self): + # Section 6: damping_hz 2 980.2 / 2 973.3 / 2 847.8 at 300 ms + # single-line, the pre-warped 3 018.8 Hz corner. + for rate, damping in ((48000, 2980.2), (44100, 2973.3), + (22050, 2847.8)): + effect = AnalogDelay(silence_src(64, 2, rate), sample_rate=rate) + self.assertAlmostEqual(effect._damping, damping, places=1) + self.assertAlmostEqual(effect._corner, 3018.8, delta=0.1) + effect = AnalogDelay(silence_src(64), sample_rate=RATE, + character=DOUBLE) + self.assertAlmostEqual(effect._corner, 6037.5, places=1) + # Under the clamp point the corner holds at 0.98 x Nyquist. + effect.set_macro(TIME_I, 0) + self.assertEqual(effect._corner, 23520.0) + + def test_tail_samples_as_each_patch_plays(self): + # Tier 3 and App. F4: laps x (reach + swing + 1 + memory), each + # patch sized as its MIDI tuple plays, at 48 kHz, single-line. + expected = {None: 187954, 0: 172956, 1: 28940, 2: 480276, + 3: 238966, 4: 2037, 5: 1273515, 6: 201782} + for patch, frames in expected.items(): + effect = AnalogDelay(silence_src(64), sample_rate=RATE, + patch=patch) + self.assertEqual(effect.tail_samples, frames, patch) + # Patch 6 with a host at 120 bpm: 375 ms, 18 000 frames. + effect = AnalogDelay.create(silence_src(64), RATE, + transport=lambda: (True, 0.0, 120.0, + 4, 4), patch=6) + self.assertEqual(effect._frames, 18000) + self.assertEqual(effect.tail_samples, 253008) + + def test_the_tail_is_finite_everywhere_the_knobs_go(self): + for character in (SINGLE, DOUBLE): + for rate in RATES: + effect = AnalogDelay(silence_src(64, 2, rate), + sample_rate=rate, character=character) + for time_midi in (0, 25, 64, 101, 127): + effect.set_macro(TIME_I, time_midi) + for fb_midi in range(128): + effect.set_macro(FEEDBACK_I, fb_midi) + self.assertIsNotNone(effect.tail_samples, + (character, rate, time_midi, + fb_midi)) + # Handed as set since audiodsp v0.6.3rc1 (#157); + # it was stepped clear, 3 x 10^-5 at most. + self.assertEqual(effect._feedback, + min(0.99, effect._value(FEEDBACK_I))) + # Planted: the retired stepping moves the 0.99 stop. + stepped = SteppedAnalog(silence_src(64, 2, rate), + sample_rate=rate, + character=character, feedback=0.99) + self.assertNotEqual(stepped._feedback, 0.99) + self.assertLess(abs(stepped._feedback - 0.99), 3e-5) + + def test_constructor_edges(self): + effect = AnalogDelay(silence_src(64), sample_rate=RATE, time_ms=0.0, + mod_rate_hz=0.0, max_time_ms=float("nan")) + self.assertEqual(effect._frames, 960) + self.assertAlmostEqual(effect.macro(RATE_I), 0.05) + self.assertEqual(effect._max_time_ms, 600.0) + effect = AnalogDelay(silence_src(64), sample_rate=RATE, + max_time_ms=100.0, time_ms=300.0) + self.assertEqual(effect._frames, 4800) + self.assertAlmostEqual(effect.macro(TIME_I), 100.0) + effect.set_macro(TIME_I, 127) + self.assertEqual(effect._frames, 4800) + self.assertAlmostEqual(effect.get_macro(TIME_I), midi_of_ms(100.0)) + + +# -------------------------------------------------------------------------- +# Section 6: Time lands on its whole frame on every interpreter + + +class TimeHandOff(unittest.TestCase): + def test_every_frame_count_lands_whole(self): + # Every count from 20 to 606 ms at the three rates: the node's + # single-precision arithmetic (modelled with numpy's float32, + # independently of the class's `array('f')`) lands the handed + # value at or above the count, at most 0.00195 frames above it. + for rate in RATES: + worst = 0.0 + stepped = 0 + fs = np.float32(rate) + for k in range(whole(20.0, rate), whole(606.0, rate) + 1): + value = ad.hand_off_ms(k, rate) + got = float(np.float32(np.float32(value) * fs) + / np.float32(1000.0)) + self.assertGreaterEqual(got, k, (rate, k)) + worst = max(worst, got - k) + plain = np.float32(k * 1000.0 / rate) + stepped += float(np.float32(plain * fs) + / np.float32(1000.0)) < k + self.assertLessEqual(worst, 0.00196, rate) + # App. F5: none at 48 kHz, 1 899 at 44.1, 953 at 22.05. + self.assertEqual(stepped, {48000: 0, 44100: 1899, + 22050: 953}[rate]) + + def _impulse(self, cls, rate, time_ms): + k = whole(time_ms, rate) + values = [0] * (k + 512) + values[0] = 32767 + effect = cls(array_src(values, 1, rate), sample_rate=rate, + time_ms=time_ms, feedback=0.0, mix=2.0) + effect.set_macro(TIME_I, midi_of_ms(time_ms)) + out = left(pull(effect, k + 512, 1), 1) + return k, out + + def test_patch_0_time_arrives_on_its_frame(self): + # Patch 0's grid Time at 44.1 kHz is k = 13 188, a count the plain + # hand-off lands short: nothing may arrive before k. + rate = 44100 + time_ms = grid_ms(101) + k, out = self._impulse(AnalogDelay, rate, time_ms) + self.assertEqual(k, 13188) + self.assertEqual(int(np.count_nonzero(out[1:k])), 0) + self.assertGreater(abs(out[k]), 10000) + k, out = self._impulse(PlainHandOff, rate, time_ms) + self.assertNotEqual(int(np.count_nonzero(out[1:k])), 0) + + def test_the_plain_hand_off_is_off_the_surface(self): + for rate in (44100, 22050): + with NodeSpy(): + result = kit_faults.fault_reachability( + AnalogDelay, False, read_landing, + lambda cls: cls(silence_src(64, 2, rate), + sample_rate=rate)) + self.assertEqual(result["clean"], True) + + +# -------------------------------------------------------------------------- +# Tier 2 rows + + +class T2aCornerTracksTime(unittest.TestCase): + def test_the_named_cells_and_octaves(self): + for rate in RATES: + result = t2a_measure(AnalogDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + for ratio in result["ratios"].values(): + self.assertLess(abs(ratio - 1.0), 0.01, (rate, result)) + + def test_the_grid_at_both_levels(self): + # Every claimed grid position at 48 kHz on both characters through + # the kit's sweep, stops first, at 0 dBFS; at -40 dBFS from one + # position higher, and every eighth. + for character in (SINGLE, DOUBLE): + first = first_claimed(character, RATE) + self.assertEqual(first, {SINGLE: 25, DOUBLE: 51}[character]) + for amp, low, midpoints in ( + (32767, first, 127 - first - 1), + (328, first + 1, (127 - first - 1) // 8)): + def measure_at(settings, amp=amp, character=character): + ratio = corner_ratio(AnalogDelay, RATE, character, + midi=settings[TIME_I], amp=amp) + return {"values": {"error": 1.0 if ratio is None + else ratio - 1.0}} + result = kit.macro_sweep( + lambda: AnalogDelay(silence_src(64), sample_rate=RATE, + character=character), + [kit.MacroSpan(TIME_I, low, 127, midpoints=midpoints)], + measure_at, figure="error", bar=0.10) + self.assertEqual(result["red"], [], + (character, amp, result["values"]["worst"], + result["values"]["at_units"])) + + def test_the_other_rates_on_the_grid(self): + for rate in (44100, 22050): + for character in (SINGLE, DOUBLE): + first = first_claimed(character, rate) + for midi in list(range(first, 128, 6)) + [127]: + ratio = corner_ratio(AnalogDelay, rate, character, + midi=midi) + self.assertIsNotNone(ratio, (rate, character, midi)) + self.assertLess(abs(ratio - 1.0), 0.10, + (rate, character, midi, ratio)) + + def test_n6144_is_red(self): + for rate in RATES: + self.assertFalse(t2a_measure(CornerN6144, rate)["passed"], rate) + ratio = corner_ratio(CornerN6144, RATE, SINGLE, time_ms=300.0) + self.assertAlmostEqual(ratio, 1.5, delta=0.01) + ratio = corner_ratio(CornerN6144, RATE, DOUBLE, time_ms=300.0) + self.assertAlmostEqual(ratio, 0.75, delta=0.01) + + +class T6CharactersAnOctaveApart(unittest.TestCase): + def test_the_named_cells(self): + for rate in RATES: + result = t6_measure(AnalogDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + # 150 ms at 22.05 kHz: double-line's target is above the clamp, so + # the cell is not claimed, and the ratio shows why. + single = crossing_hz(first_repeat(AnalogDelay, 22050, SINGLE, + time_ms=150.0), 22050) + double = crossing_hz(first_repeat(AnalogDelay, 22050, DOUBLE, + time_ms=150.0), 22050) + self.assertLess(double / single, 1.8) + self.assertAlmostEqual(double / single, 1.7927, delta=0.001) + + def test_double_lines_grid_and_the_patches(self): + for midi in list(range(51, 128, 4)) + [127, 101, 124, 107, 116]: + single = crossing_hz(first_repeat(AnalogDelay, RATE, SINGLE, + midi=midi), RATE) + double = crossing_hz(first_repeat(AnalogDelay, RATE, DOUBLE, + midi=midi), RATE) + self.assertTrue(1.8 <= double / single <= 2.2, (midi,)) + + def test_n6144_on_double_line_is_red(self): + for rate in RATES: + result = t6_measure(DoubleLineN6144, rate, times=(300.0,)) + self.assertFalse(result["passed"], rate) + self.assertAlmostEqual(result["ratios"][300.0], 1.5, delta=0.01) + + +class T3TimeGlidesOnTheClock(unittest.TestCase): + def test_the_row_cell(self): + # 200 -> 100.4 ms: +1193.2 cents for 4 819 frames at 48 kHz. + for character in (SINGLE, DOUBLE): + result = t3_measure(AnalogDelay, RATE, character) + self.assertTrue(result["passed"], (character, result)) + self.assertAlmostEqual(result["law"], 1193.2, delta=0.05) + + def test_moves_both_ways_at_three_rates(self): + for rate, t_old, t_new, level in ( + (48000, 100.4, 200.0, 12000), + (48000, 300.0, 100.0, 1200), + (44100, 100.0, 300.0, 32000), + (44100, 200.0, 600.0, 12000), + (22050, 600.0, 200.0, 3800), + (22050, 20.0, 60.0, 12000), + (48000, 40.0, 20.0, 12000)): + result = t3_measure(AnalogDelay, rate, SINGLE, t_old, t_new, + level) + self.assertTrue(result["passed"], + (rate, t_old, t_new, level, result)) + + def test_the_long_moves_are_read_per_binade(self): + # 200 -> 600 ms crosses 16 384 frames at 48 kHz; its upper binade + # plays its own rate, within 10 cents of the law (App. F5: +3.38). + result = t3_measure(AnalogDelay, RATE, SINGLE, 200.0, 600.0) + self.assertEqual(len(result["pieces"]), 2, result) + self.assertTrue(result["passed"], result) + + def test_the_jump_is_red(self): + # The walk clause reds a jump at every boundary: there is no walk. + # The inside clause sees the step itself at all 16 since revision + # R8 reads the move's own first difference, diff[move - 1] (fix + # round 1's window, from diff[move], saw 15: at the other the jump + # landed on nearly the same sample value, 0.964 of its bar). + base = default_move_at(RATE) + seen = 0 + for j in range(16): + result = t3_measure(JumpAnalogDelay, RATE, SINGLE, 20.0, 60.0, + move_at=base + BLOCK * j) + self.assertFalse(result["passed"], (j, result)) + self.assertLess(result["walk"], 64, j) + seen += 0 if result["inside_ok"] else 1 + self.assertEqual(seen, 16) + + def test_the_constant_glide_is_red(self): + for rate in RATES: + result = t3_measure(ConstantGlideWalk, rate, SINGLE, 300.0, 100.0) + self.assertFalse(result["passed"], (rate, result)) + self.assertGreater(result["walk"], 1.5 * result["k_new"]) + self.assertLess(result["whole"] - result["law"], -600.0) + self.assertTrue(all(p - result["law"] < -600.0 + for p in result["pieces"]), result) + + +class T3NoStepRevision(unittest.TestCase): + """Dossier section 8, revisions R1-R2 and R8 (2026-09-28): the inside + no-step bar carries the loop low-pass's carry of the pre-move slope + across the coefficient change the move makes, from the `damping_hz` + the node held and the one it was handed; the landing gap, which no + clause read before, is read against the larger of the inside and after + bars; the walk is read where the head lands; the binade pieces have a + minimum the ramp resolves, and a walk with none left is read whole. + A first-difference statistic cannot see a read step of 1 or 2 frames + (a step of s frames moves one first difference by at most s - 1 times + the slope); the inside clause sees steps from about 3 frames.""" + + def test_the_one_pole_memory_is_not_a_step(self): + # 20 -> 60 ms moved ten blocks later than the pack's boundary: the + # frozen statistic reads 551 against a 522 bar (1.0556) at the + # walk's first frame; the revised bar there is 522 + c0 1562 (1 - a) + # with c0 = (a_new / a_old)(1 - a_old) = 0.162, 577. + move_at = default_move_at(RATE) + 10 * BLOCK + for character in (SINGLE, DOUBLE): + result = t3_measure(AnalogDelay, RATE, character, 20.0, 60.0, + move_at=move_at) + self.assertGreater(result["inside_from_move"], + 1.05 * result["inside_bar"], character) + self.assertTrue(result["inside_ok"], (character, result)) + self.assertTrue(result["passed"], (character, result)) + result = t3_measure(open_loop(AnalogDelay), RATE, character, + 20.0, 60.0, move_at=move_at) + self.assertLessEqual(result["inside_from_move"], + 1.05 * result["inside_bar"], character) + + def test_the_memory_is_the_handed_coefficient(self): + keep = math.exp(-2.0 * math.pi * 11606.7651 / RATE) + keep_old = math.exp(-2.0 * math.pi * 13461.1 / RATE) + c0 = (1.0 - keep) / (1.0 - keep_old) * keep_old + self.assertAlmostEqual(c0, 0.162, delta=0.001) + bars = memory_bars(522.0, 1562.0, 11606.7651, RATE, 3, + damping_before=13461.1) + self.assertAlmostEqual(bars[0], 522.0 + c0 * 1562.0 * keep) + self.assertAlmostEqual(bars[2], 522.0 + c0 * 1562.0 * keep ** 3) + # The fix-round-1 term, kept for its probes: no c0. + bars = memory_bars(522.0, 1562.0, 11606.7651, RATE, 1) + self.assertAlmostEqual(bars[0], 522.0 + 1562.0 * keep) + self.assertEqual(list(memory_bars(522.0, 1562.0, 0.0, RATE, 2, + damping_before=13461.1)), + [522.0, 522.0]) + + def test_the_carry_is_the_nodes_recursion(self): + # Across the move the node's first difference is a_new x'(0) + + # c0 d(-1): predicted off an open-loop render of the same move to + # a few LSB, where the constant-a form misses by hundreds. + rate, move_at = RATE, default_move_at(RATE) + frames = move_at + 4096 + tone = sine_values(997.0, frames, rate, 12000) + handed = {} + y, _ = t3_render(AnalogDelay, rate, SINGLE, 200.0, 600.0, tone, + handed=handed) + x, _ = t3_render(open_loop(AnalogDelay), rate, SINGLE, 200.0, 600.0, + tone) + y, x = y.astype(float), x.astype(float) + a_new = 1.0 - one_pole_keep(handed["damping_hz"], rate) + c0 = carry_c0(handed["damping_before"], handed["damping_hz"], rate) + m = move_at + d_prev = y[m - 1] - y[m - 2] + node = a_new * (x[m] - x[m - 1]) + c0 * d_prev + constant = a_new * (x[m] - x[m - 1]) + (1.0 - a_new) * d_prev + self.assertLess(abs(node - (y[m] - y[m - 1])), 3.0) + self.assertGreater(abs(constant - (y[m] - y[m - 1])), 100.0) + + def test_a_mid_walk_read_step_is_red(self): + result = t3_measure(MidWalkReadStep, RATE, SINGLE) + self.assertFalse(result["inside_ok"], result) + self.assertLess(abs(result["inside_at"] - result["k_new"] // 2), 4, + result) + self.assertFalse(result["passed"]) + + def test_an_early_step_is_red(self): + # EarlyStepWalk, 3 frames late one frame after the move and landing + # on T_new, on 200 -> 600 ms at 48 kHz single-line over 8 tone + # phases: the fix-round-1 bar passes it at every phase, the node's + # carry reds it at half of them. + red = r1_red = 0 + for p in range(8): + result = t3_measure(EarlyStepWalk, RATE, SINGLE, 200.0, 600.0, + channels=2, phase=2.0 * math.pi * p / 8.0) + self.assertTrue(result["walk_ok"], (p, result)) + red += 0 if result["inside_ok"] else 1 + r1_red += 1 if result["inside_r1"] > 1.05 else 0 + self.assertEqual(r1_red, 0) + self.assertGreaterEqual(red, 4) + + def test_a_landing_blip_is_red(self): + # LandingBlip, one frame 8 late 10 frames past the landing: before + # R8 no clause read it and the row passed; the gap clause reds it + # and nothing else does. + for t_old, t_new in ((200.0, 100.4), (100.4, 200.0)): + result = t3_measure(LandingBlip, RATE, SINGLE, t_old, t_new) + self.assertFalse(result["gap_ok"], (t_old, t_new, result)) + self.assertFalse(result["passed"]) + self.assertTrue(result["inside_ok"] and result["walk_ok"] + and result["later"] <= 1.05 * result["later_bar"], + result) + + def test_stereo_is_the_left_channel_and_the_right_held_to_it(self): + # Revision R10: every clause reads the left channel, and on a + # stereo render the right must equal it byte for byte. A step on + # the right channel alone passes every left clause and is red on + # the identity only (the gate audit's round 3: 60 of 60 passed the + # row before this). + for rate, t_old, t_new in ((48000, 200.0, 100.4), + (48000, 100.0, 300.0), + (44100, 20.0, 60.0), + (22050, 60.0, 20.0)): + for character in (SINGLE, DOUBLE): + result = t3_measure(AnalogDelay, rate, character, t_old, + t_new, channels=2) + self.assertEqual(result["lr"], 0, (rate, t_old, character)) + self.assertTrue(result["passed"], (rate, t_old, result)) + for t_old, t_new in ((200.0, 100.4), (100.4, 200.0)): + result = t3_measure(RightReadStep, RATE, SINGLE, t_old, t_new, + channels=2) + self.assertGreater(result["lr"], 0, (t_old, result)) + self.assertFalse(result["passed"], (t_old, result)) + self.assertTrue(result["walk_ok"] and result["inside_ok"] + and result["gap_ok"], (t_old, result)) + + def test_the_landing_gap_holds_on_the_class(self): + for t_old, t_new in ((300.0, 100.0), (100.0, 300.0), (20.0, 60.0), + (60.0, 20.0)): + result = t3_measure(AnalogDelay, RATE, DOUBLE, t_old, t_new) + self.assertTrue(result["gap_ok"], (t_old, t_new, result)) + self.assertTrue(result["passed"], (t_old, t_new, result)) + + def test_one_grid_step_from_the_stop_lands(self): + # The frozen walk_end read this 9.6 / 10.5 / 21.0 % short. + for rate in RATES: + k_new = whole(grid_ms(1), rate) + move_at = default_move_at(rate) + frames = move_at + 2 * k_new + 4096 + delay, valid, slope, _ = head_trace(AnalogDelay, rate, SINGLE, + 20.0, grid_ms(1), frames) + walk = landing(delay, valid, slope, move_at, k_new) - move_at + self.assertLess(abs(walk - k_new), 0.02 * k_new, (rate, walk)) + old, _ = read_position(AnalogDelay, rate, SINGLE, 20.0, + grid_ms(1), frames) + walk = walk_end(old, move_at, k_new) - move_at + self.assertGreater(abs(walk - k_new), 0.05 * k_new, (rate, walk)) + + def test_every_named_move_reads_a_piece(self): + # The frozen binade_pieces found none on 60 -> 20 and 40 -> 20 ms + # at 48 and 22.05 kHz, or on 20 -> 60 ms at 22.05 kHz. + for rate in (48000, 22050): + for t_old, t_new in ((60.0, 20.0), (40.0, 20.0), (20.0, 60.0), + (20.0, 40.0)): + result = t3_measure(AnalogDelay, rate, SINGLE, t_old, t_new) + self.assertGreater(len(result["pieces"]), 0, + (rate, t_old, t_new)) + self.assertTrue(result["passed"], (rate, t_old, t_new)) + + def test_a_sliver_under_the_minimum_is_not_read(self): + # 3 : 1 from 8 182 frames crosses 8 192 in a 16-frame sliver the + # frozen reading put at -31.77 cents; the ramp cannot resolve it. + k_old = 8182 + result = t3_measure(AnalogDelay, RATE, SINGLE, k_old * 1000.0 / RATE, + 3 * k_old * 1000.0 / RATE) + self.assertEqual(len(result["pieces"]), 2, result) + self.assertTrue(result["passed"], result) + self.assertGreater(min_piece(1, k_old, 3 * k_old), 16) + + def test_no_piece_is_red(self): + # A walk too short for any piece to resolve reads no piece, which + # is red rather than a pass with nothing read. + delay = np.full(100, 960.0) + pieces = walk_pieces(delay, np.ones(100, bool), 1, 0, 50, 2880, 960) + self.assertEqual(pieces, []) + + +def noise_src(frames, rate, seed=5): + rng = np.random.RandomState(seed) + values = np.round(rng.uniform(-1.0, 1.0, frames) * 8000.0) + return probes.ArraySource(array("h", values.astype(np.int16).tobytes()), + rate=rate, channels=1, block=BLOCK) + + +def small_move_lands(cls, rate, t_old, t_new): + """(differing samples, walk floor handed) over the last 0.5 s of 3 s + after one move from rest `t_old -> t_new` through `set_macro`, against + the class built at `t_new`: 0 means the head reached the new frame.""" + fill = int(rate / BLOCK) + 1 + settled = int(3.0 * rate) + outs = [] + for time_ms in (t_old, t_new): + effect = cls(noise_src(fill * BLOCK + settled + BLOCK, rate), + sample_rate=rate, time_ms=time_ms, mix=2.0, + feedback=0.0) + pull(effect, fill * BLOCK) + if time_ms == t_old: + effect.set_macro(TIME_I, midi_of_ms(t_new)) + outs.append(pull(effect, settled)) + last = int(0.5 * rate) + return int(np.count_nonzero(outs[0][-last:] != outs[1][-last:])) + + +class SmallTimeMovesLand(unittest.TestCase): + """Section 6: every Time lands on its whole frame, a move of a few + frames included. The node walks in single precision, so the class + floors the walk rate at two single-precision steps of the furthest the + head may sit (written out here: 2^(e - 23) for a head under 2^e + frames).""" + + MOVES = ((300.0, 300.1), (300.0, 299.9), (600.0, 599.5), + (300.0, 300.02)) + + def test_moves_under_eight_frames_land(self): + for rate in (48000, 44100): + for t_old, t_new in self.MOVES: + moved = whole(t_new, rate) - whole(t_old, rate) + self.assertLess(abs(moved), 25) + with self.subTest(rate=rate, move=(t_old, t_new)): + self.assertEqual( + small_move_lands(AnalogDelay, rate, t_old, t_new), 0) + + def test_the_floor_as_handed(self): + with NodeSpy(): + effect = AnalogDelay(silence_src(4 * BLOCK), sample_rate=RATE) + effect.set_macro(TIME_I, midi_of_ms(300.1)) # +5 frames + self.assertEqual(effect._delay._handed["delay_slew"], 2.0 ** -9) + effect = AnalogDelay(silence_src(4 * BLOCK), sample_rate=RATE, + time_ms=600.0) + effect.set_macro(TIME_I, midi_of_ms(599.5)) # -24 frames + self.assertEqual(effect._delay._handed["delay_slew"], 2.0 ** -8) + effect = AnalogDelay(silence_src(4 * BLOCK), sample_rate=RATE) + effect.set_macro(TIME_I, midi_of_ms(100.0)) # the law, 2.0 + self.assertAlmostEqual(effect._delay._handed["delay_slew"], 2.0) + + def test_the_unfloored_law_leaves_the_head_short(self): + for rate in (48000, 44100): + for t_old, t_new in self.MOVES[:3]: + with self.subTest(rate=rate, move=(t_old, t_new)): + self.assertGreater( + small_move_lands(UnflooredWalk, rate, t_old, t_new), + 1000) + + +class T7DryIsAWire(unittest.TestCase): + def test_defaults_on_three_materials(self): + for name in ("ramp_fs", "tones_step", "sweep_log"): + for channels in (2, 1): + result = t7_measure(AnalogDelay, RATE, channels, name) + self.assertTrue(result["passed"], (name, channels, result)) + self.assertEqual(result["frames"], 14400) + + def test_the_stops_and_the_corner(self): + cells = ( + {"time_ms": 20.0}, {"time_ms": 600.0}, {"feedback": 0.99}, + {"mix": 0.0}, {"mix": 63 * 2.0 / 127.0}, {"mix": 1.0}, + {"modulation_ms": 5.0}, {"mod_rate_hz": 0.05}, + {"mod_rate_hz": 8.0}, {"spread": 1.0}, + {"time_ms": 20.0, "feedback": 0.99, "mix": 1.0, + "modulation_ms": 5.0, "mod_rate_hz": 8.0, "spread": 1.0}) + for options in cells: + for character in (SINGLE, DOUBLE): + result = t7_measure(AnalogDelay, RATE, 2, "ramp_fs", + character=character, **options) + self.assertTrue(result["passed"], (options, character, + result)) + corner = cells[-1] + for rate in RATES: + for channels in (2, 1): + result = t7_measure(AnalogDelay, rate, channels, "sweep_log", + **corner) + self.assertTrue(result["passed"], (rate, channels, result)) + self.assertEqual(result["frames"], whole(20.0, rate)) + self.assertIsNotNone(result["arrived"]) + + def test_the_patches_with_mix_up_to_one(self): + for patch in (0, 1, 2, 3, 5, 6): + result = t7_measure(AnalogDelay, RATE, 2, "tones_step", + patch=patch) + self.assertTrue(result["passed"], (patch, result)) + + def test_the_mix_interior(self): + # Dossier section 8, revisions R4 and R9: arrival is claimed on + # ramp_fs and tones_step at every Mix grid position above 0 and at + # any Mix at or above 1.01 x 0.5 / W, W the wet alone's peak in the + # arrival window, and on sweep_log from grid 17 at 48 kHz, where + # the wet's ~2 LSB times Mix first clears the node's round to + # nearest. The null build reds at grid 1. + for name, grids in (("ramp_fs", (1, 2, 8, 16, 17, 40, 63)), + ("tones_step", (1, 2, 8, 16, 17, 40, 63)), + ("sweep_log", (17, 24, 40, 63))): + for grid in grids: + result = t7_measure(AnalogDelay, RATE, 2, name, + mix=2.0 * grid / 127.0) + self.assertTrue(result["passed"], (name, grid, result)) + result = t7_measure(AnalogDelay, RATE, 2, "sweep_log", + mix=2.0 * 16 / 127.0) + self.assertEqual(result["arrived"], 0) # the unclaimed edge + # Off the grid (a constructor Mix stays unquantised): at 1.01 x + # 0.5 / W on tones_step (W = 776 LSB, 6.44e-4) the repeat arrives; + # at Mix 1e-4, and at set_macro(2, 0.001) (Mix 1.57e-5), every + # sample stays on the source, which the row does not claim. The + # edge 0.5 / W itself is not claimed: W is the rounded peak. + wet = wet_peak_in_arrival(RATE, 2, "tones_step") + self.assertEqual(wet, 776.0) + result = t7_measure(AnalogDelay, RATE, 2, "tones_step", + mix=1.01 * 0.5 / wet) + self.assertTrue(result["passed"], result) + result = t7_measure(AnalogDelay, RATE, 2, "tones_step", mix=1e-4) + self.assertEqual((result["differing"], result["arrived"]), (0, 0)) + result = t7_measure(AnalogDelay, RATE, 2, "ramp_fs", mix=1e-5) + self.assertEqual((result["differing"], result["arrived"]), (0, 0)) + data = material("tones_step", RATE, 2) + effect = AnalogDelay(probes.ArraySource(data, rate=RATE, channels=2, + block=BLOCK), + sample_rate=RATE) + effect.set_macro(MIX_I, 0.001) + self.assertLess(effect.macro(MIX_I), 0.5 / wet) + out = pull(effect, 14400 + 64, 2) + effect.deinit() + src = np.array(data[:len(out)], dtype=np.int16) + self.assertEqual(int(np.count_nonzero(out != src)), 0) + wire = kit_faults.wire_build(AnalogDelay) + result = t7_measure(wire, RATE, 2, "ramp_fs", mix=2.0 / 127.0) + self.assertFalse(result["passed"], result) + self.assertEqual(result["differing"], 0) + + def test_a_dry_above_unity_is_red_at_every_level(self): + for name in ("ramp_fs", "tones_step", "sweep_log"): + result = t7_measure(DryGainDelay, RATE, 2, name) + self.assertFalse(result["passed"], name) + self.assertGreater(result["differing"], 1000, name) + + +# -------------------------------------------------------------------------- +# The rows recorded unmeasured or disconfirmed by decision + + +class RecordedRows(unittest.TestCase): + def test_no_null_at_the_clock(self): + # T1 and T2b: with no sample-and-hold the first repeat has no null + # at f_clk = N / 2T (App. F1: -7.67 / -7.07 dB at 300 ms), so there + # is nothing to read f_clk from. A null 20 dB deep would mean the + # class gained a hold it was not built with. + for character, f_clk in ((SINGLE, 4096 / 0.6), (DOUBLE, 8192 / 0.6)): + h = first_repeat(AnalogDelay, RATE, character, time_ms=300.0) + self.assertGreater(magnitude_db(h, RATE, f_clk), -20.0) + + def test_the_only_corner_moves_with_time(self): + # T5: the class's one corner moves 75 % across 150 -> 600 ms and its + # slope over the octave above it is a one-pole's (App. F1: 3.3-3.95 + # dB/octave), against a fixed pair's 30 dB/octave. + low = crossing_hz(first_repeat(AnalogDelay, RATE, SINGLE, + time_ms=600.0), RATE) + high = crossing_hz(first_repeat(AnalogDelay, RATE, SINGLE, + time_ms=150.0), RATE) + self.assertGreater(1.0 - low / high, 0.7) + h = first_repeat(AnalogDelay, RATE, SINGLE, time_ms=300.0) + corner = crossing_hz(h, RATE) + slope = magnitude_db(h, RATE, corner) - magnitude_db( + h, RATE, 2.0 * corner) + self.assertLess(slope, 6.0) + + +# -------------------------------------------------------------------------- +# Tier 1, the fast half + + +class Tier1Fast(unittest.TestCase): + def test_mix_zero_is_a_wire_on_the_full_scale_ramp(self): + for rate in RATES: + for channels in (2, 1): + for character in (SINGLE, DOUBLE): + ramp = probes.ramp_fs(frames=rate, channels=channels) + source = probes.ArraySource(ramp, rate=rate, + channels=channels, + block=BLOCK) + effect = AnalogDelay(source, sample_rate=rate, mix=0.0, + time_ms=20.0, feedback=0.99, + modulation_ms=5.0, mod_rate_hz=8.0, + spread=1.0, character=character) + out = pull(effect, rate, channels) + self.assertTrue(np.array_equal( + out, np.array(ramp, dtype=np.int16)), + (rate, channels, character)) + + def test_silence_stays_silence(self): + for patch in range(7): + effect = AnalogDelay(silence_src(RATE // 2), sample_rate=RATE, + patch=patch) + self.assertEqual(int(np.max(np.abs(pull(effect, RATE // 2)))), 0) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + for options, seconds in (({}, 4.5), ({"patch": 1}, 1.5), + ({"patch": 3}, 5.5), ({"patch": 4}, 0.5), + ({"character": DOUBLE}, 4.5)): + declared, values, red = kit_tail(AnalogDelay, seconds, **options) + self.assertEqual(red, [], (options, values)) + self.assertLessEqual(values["tail_samples"], declared) + declared, values, red = kit_tail(HalfTail, 4.5) + self.assertNotEqual(red, [], values) + + def test_full_scale_meets_the_bound(self): + # Full scale round the loop at the patches whose Feedback sits on a + # stall window's side and at 0.99, mono, 48 kHz, short Times. + for options in ({"time_ms": 20.0, "feedback": 0.5}, + {"time_ms": 20.0, "feedback": 0.9}, + {"time_ms": 20.0, "feedback": 0.99}, + {"time_ms": 40.0, "feedback": 0.7, + "modulation_ms": 5.0, "mod_rate_hz": 8.0}): + result = fullscale_tail(AnalogDelay, **options) + self.assertTrue(result["passed"], (options, result)) + self.assertGreater(result["last"], 0) + + def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): + # Feedback 0.5 at 600 ms, where the low-pass is slow enough to rest + # a hair above 1 LSB. Up to audiodsp v0.6.2, handed raw, 1 LSB went + # round for ever on a 2 LSB DC, and the class stepped the Feedback + # clear. Since v0.6.3rc1 (#157) 0.5 is handed as set and the tail + # ends inside the bound. Planted: the retired stepping. + probe = AnalogDelay(silence_src(64), sample_rate=RATE, time_ms=600.0, + feedback=0.5, mix=2.0) + self.assertEqual(probe._feedback, 0.5) + declared = probe.tail_samples + fill = 4 * 28800 // BLOCK * BLOCK + values = [2] * fill + [0] * (declared + RATE) + effect = AnalogDelay(array_src(values, 1), sample_rate=RATE, + time_ms=600.0, feedback=0.5, mix=2.0) + out = pull(effect, len(values), 1) + nonzero = np.flatnonzero(out) + self.assertGreater(len(nonzero), 0) + self.assertLessEqual(int(nonzero[-1]) - fill + 1, declared) + stepped = SteppedAnalog(silence_src(64), sample_rate=RATE, + time_ms=600.0, feedback=0.5, mix=2.0) + self.assertNotEqual(stepped._feedback, 0.5) + self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + + #: (feedback, spread) where the node's cross-feed sum, off the grid, + #: hands a landed k back: the re-refutation's three portable cells + #: (k = 9, 11, 50; the Feedback as its float32 value) and the typed + #: Feedback 0.9899999 at Spread 39/127 (analogdelay_reaudit1_refute.py). + CROSS_FEED_CELLS = ((0.9444443583488464, 1.0 / 127.0, 9), + (0.9545453786849976, 3.0 / 127.0, 11), + (0.9899999499320984, 2.0 / 127.0, 50), + (0.9899999, 39.0 / 127.0, 50)) + + def _cross_feed_tail(self, cls, feedback, spread, k): + """Stereo, 48 kHz, Time 20 ms, Mix 2: a DC of 2k + 2 LSB on both + lanes for four laps, then silence to `tail_samples` plus a lap. + Returns (declared, the last non-zero frame after the fill, the + largest |sample| past `tail_samples`).""" + options = {"time_ms": 20.0, "feedback": feedback, "mix": 2.0, + "spread": spread} + probe = cls(silence_src(64), sample_rate=RATE, **options) + declared = probe.tail_samples + probe.deinit() + fill = 4 * 960 // BLOCK * BLOCK + BLOCK + values = [2 * k + 2] * fill + [0] * (declared + 960 + BLOCK) + effect = cls(array_src(values), sample_rate=RATE, **options) + out = pull(effect, len(values), 2) + effect.deinit() + after = out[fill * 2:] + nonzero = np.flatnonzero(after) + last = 0 if nonzero.size == 0 else int(nonzero[-1]) // 2 + 1 + past = after[declared * 2:] + return declared, last, int(np.max(np.abs(past.astype(np.int64)))) + + def test_the_cross_feed_stall_cells_reach_zero(self): + # Re-audit round 1's Tier 1 failure (dossier section 8, R13): up to + # audiodsp v0.6.3rc2 these cells held k LSB on both lanes for ever + # with Spread handed as set, and the class put Spread on a 1/4096 + # grid. At v0.6.3rc3 the node ends them (audiodsp#170, #173), the + # grid is gone, and each ends inside the bound with Spread as set. + # The old plant (Spread off the grid) no longer goes red on this + # node; planted instead: OneLapTail, a bound of one lap, red at + # all four, so the check reads the tail. + for feedback, spread, k in self.CROSS_FEED_CELLS: + declared, last, past = self._cross_feed_tail( + AnalogDelay, feedback, spread, k) + self.assertGreater(last, 0, (feedback, spread)) + self.assertLessEqual(last, declared, (feedback, spread)) + self.assertEqual(past, 0, (feedback, spread)) + declared, last, past = self._cross_feed_tail( + OneLapTail, feedback, spread, k) + self.assertGreater(last, declared, (feedback, spread)) + self.assertGreater(past, 0, (feedback, spread)) + + def test_spread_is_handed_as_set(self): + # Since audiodsp v0.6.3rc3 the node hands Spread nothing to dodge. + for rate in RATES: + effect = AnalogDelay(silence_src(64, 2, rate), sample_rate=rate) + for midi in range(128): + effect.set_macro(SPREAD_I, midi) + self.assertEqual(effect._spread, effect._value(SPREAD_I), + midi) + mono = AnalogDelay(silence_src(64, 1, rate), sample_rate=rate, + spread=39.0 / 127.0) + self.assertEqual(mono._spread, 0.0) + + def _modulation_move(self, cls, start_ms, target_ms, mod_hz=1.0, + points=8): + """Per move, (the largest first difference in the 40 ms after it, + its bar) at `points` moves an eighth of the triangle's period + apart: 997 Hz at 12 000 LSB, mono, wet only, Time 300 ms, 48 kHz + (`analogdelay_refix1.py modmove`). The read offset is S(t) u(t) + fs / 1000 frames; while the node ramps the swing (20 ms, audiodsp + #160) |dD/dn| <= |change| / 20 + 0.004 r max(S_old, S_new), and + neither the linear-interpolated read nor the one-pole can raise a + first difference, so the bar is the source's own largest step times + 1 plus that, plus 1 for the output's rounding.""" + first = (14400 + RATE // 5) // BLOCK * BLOCK + spacing = int(RATE / mod_hz / 8.0) // BLOCK * BLOCK + change = abs(target_ms - start_ms) + rows = [] + for k in range(points): + at = first + k * spacing + values = sine_values(997.0, at + 2400, RATE, 12000) + effect = cls(array_src(values, 1), sample_rate=RATE, + time_ms=300.0, feedback=0.0, mix=2.0, spread=0.0, + modulation_ms=start_ms, mod_rate_hz=mod_hz) + + def move(frame, at=at, effect=effect): + if frame == at: + effect.set_macro(MODULATION_I, target_ms * 127.0 / 5.0) + + y = pull(effect, at + 1920 + BLOCK, 1, + on_block=move).astype(float) + effect.deinit() + source = float(np.abs(np.diff(np.array(values, float))).max()) + bar = source * (1.0 + change / 20.0 + + 0.004 * mod_hz * max(start_ms, target_ms)) + 1 + rows.append((float(np.abs(np.diff(y[at - 1:at + 1920])).max()), + bar)) + return rows + + def test_a_modulation_move_does_not_step(self): + # Since audiodsp v0.6.3rc1 the node ramps a new swing in over 20 ms + # (#160), so a Modulation move glides; at every phase each first + # difference stays under the ramp-and-triangle bar (worst 0.943 of + # it over 240 cells, three rates). On v0.6.2's node, which jumped + # the read, the same bar is red at 133 of those 240 cells, 4.42 x + # at worst (7 133 on 1 -> 1.5 ms). The bar at ba7948b, the output's own step before the move + # times 1 + |change| / 20, left out the triangle's slope after the + # move and was red on the clean class at 16 of those 240 cells. + # Planted: the read head moved by the whole change, half a period of + # the tone for 1 -> 1.5 ms (a 3 ms change is 2.99 periods, which no + # first difference sees). + for start, target in ((1.0, 1.5), (5.0, 0.0), (5.0, 2.0), + (0.0, 5.0)): + for mod_hz in (1.0, 8.0): + for worst, bar in self._modulation_move( + AnalogDelay, start, target, mod_hz): + self.assertLessEqual(worst, bar, (start, target, mod_hz)) + rows = self._modulation_move(JumpModAnalog, 1.0, 1.5) + self.assertEqual(sum(1 for worst, bar in rows if worst > bar), 8) + + def _walk_tail(self, cls): + """600 ms of 997 Hz, Feedback 0, Mix 2; as the tone stops, Time + 600 -> 20 ms (slew 29), and one block later, with the head still + near 600 ms, 20 -> 21 ms, whose rate (|dT| / T_new, 0.048) is taken + from the Time last handed and not from where the head is. The walk + then crawls down from near 600 ms for most of a second.""" + tone = int(0.6 * RATE) // BLOCK * BLOCK + BLOCK + values = sine_values(997.0, tone, RATE, 12000) + [0] * (2 * RATE) + effect = cls(array_src(values), sample_rate=RATE, time_ms=600.0, + feedback=0.0, mix=2.0) + seen = {} + + def move(frame): + if frame == tone: + effect.set_macro(TIME_I, 0) + elif frame == tone + BLOCK: + effect.set_macro(TIME_I, midi_of_ms(21.0)) + seen["declared"] = effect.tail_samples + + out = pull(effect, len(values), on_block=move) + after = out[(tone + BLOCK) * 2:] + last = int(np.nonzero(after)[0][-1]) // 2 + 1 + return seen["declared"], last + + def test_a_falling_walk_keeps_the_old_time_in_the_tail(self): + declared, last = self._walk_tail(AnalogDelay) + self.assertGreater(last, 20000) + self.assertLessEqual(last, declared) + declared, last = self._walk_tail(TargetOnlyTail) + self.assertGreater(last, declared) + + def test_the_swing_is_in_the_tail(self): + # Feedback 0 at 20 ms with a 5 ms swing: the repeat can sit up to + # 240 frames late, and the bound says so. + options = {"time_ms": 20.0, "feedback": 0.0, "modulation_ms": 5.0, + "mod_rate_hz": 8.0, "mix": 2.0} + effect = AnalogDelay(silence_src(64), sample_rate=RATE, **options) + self.assertEqual(effect.tail_samples, 960 + 240 + 1 + + ad.tone_excess(effect._damping, RATE)[0]) + faulted = NoSwingTail(silence_src(64), sample_rate=RATE, **options) + self.assertLess(faulted.tail_samples, effect.tail_samples) + # A burst ending while the triangle holds the read late is heard + # after the faulted bound. + worst = 0 + for start in range(0, 6000, 750): + values = [0] * start + sine_values(997.0, 2048, RATE, 12000) + values += [0] * 4096 + effect = AnalogDelay(array_src(values, 1), sample_rate=RATE, + **options) + out = pull(effect, len(values), 1) + nonzero = np.nonzero(out[start + 2048:])[0] + if nonzero.size: + worst = max(worst, int(nonzero[-1]) + 1) + self.assertLessEqual(worst, AnalogDelay( + silence_src(64), sample_rate=RATE, **options).tail_samples) + self.assertGreater(worst, faulted.tail_samples) + + def test_mono_holds_spread_at_zero(self): + # Section 4: at one channel the node's cross-feed sends the repeat + # nowhere; the class holds it at 0, and the repeats go on. + def repeats(cls): + values = [0] * (3 * 4800 + 512) + values[0] = 12000 + effect = cls(array_src(values, 1), sample_rate=RATE, + time_ms=100.0, feedback=0.7, mix=2.0, spread=1.0) + out = left(pull(effect, len(values), 1), 1) + return [float(np.max(np.abs(out[k * 4800:k * 4800 + 256]))) + for k in (1, 2, 3)] + clean = repeats(AnalogDelay) + self.assertTrue(all(r > 1000 for r in clean), clean) + faulted = repeats(SpreadInMono) + self.assertEqual(faulted[1:], [0.0, 0.0]) + + def test_modulation_is_the_table_and_a_time_move_leaves_it(self): + with NodeSpy(): + effect = AnalogDelay(silence_src(4 * BLOCK), sample_rate=RATE, + modulation_ms=5.0, mod_rate_hz=1.0) + self.assertEqual(effect._delay._handed["wow_depth_ms"], 5.0) + self.assertAlmostEqual(effect._delay._handed["wow_hz"], 1.0) + pull(effect, BLOCK) + effect.set_macro(TIME_I, 64) + self.assertEqual(effect._delay._handed["wow_depth_ms"], 5.0) + self.assertNotIn("wow_shape", effect._delay._writes[-1]) + table = ad.triangle_table() + self.assertEqual((table[0], table[64], table[128], table[192]), + (0, 32767, 0, -32767)) + + def test_the_swing_reads_as_a_triangle(self): + # App. F4: 300 ms, 5 ms at 1 Hz reads +-240 frames off a ramp. + rate = RATE + start = 20000 + origin = start - 15200 + frames = start + rate + 100 + n = np.arange(frames) + ramp = ((n - origin) % 65536) - 32768 + effect = AnalogDelay(array_src(ramp.tolist(), 1, rate), + sample_rate=rate, mix=2.0, feedback=0.0, + modulation_ms=5.0, mod_rate_hz=1.0) + out = left(pull(effect, frames, 1), 1) + delay = (ramp - out)[start:start + rate] + swing = np.max(delay) - np.min(delay) + self.assertAlmostEqual(swing / 2.0, 240.0, delta=3.0) + + def test_reset_empties_the_line(self): + values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) + values += [0] * RATE + effect = AnalogDelay(array_src(values), sample_rate=RATE, + time_ms=100.0, feedback=0.9, mix=2.0) + pull(effect, 2304) + effect.reset() + self.assertEqual(effect.patch_index, 0) + self.assertEqual(int(np.max(np.abs(pull(effect, RATE // 2)))), 0) + + def test_deinit_leaves_the_source(self): + source = array_src(sine_values(440.0, 1024, RATE, 8000)) + effect = AnalogDelay(source, sample_rate=RATE) + pull(effect, 256) + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + + def test_click_delay_is_zero(self): + for rate in (48000, 44100): + values = [0] * 2048 + values[10] = 30000 + effect = AnalogDelay(array_src(values, 2, rate), sample_rate=rate) + out = left(pull(effect, 2048), 2) + self.assertEqual(int(np.argmax(np.abs(out))), 10) + self.assertEqual(out[10], 30000) + self.assertEqual(effect.latency_samples, 0) + + def test_the_input_ceiling(self): + # Below Mix 1 an input peaking at floor(32767 (1 - Mix)) - 1 cannot + # reach the rail: a square at that peak, Feedback 0.99, 20 ms. + mix = 0.4 + peak = int(math.floor(32767 * (1.0 - mix))) - 1 + values = ([peak] * 24 + [-peak - 1] * 24) * 2000 + effect = AnalogDelay(array_src(values), sample_rate=RATE, mix=mix, + time_ms=20.0, feedback=0.99) + out = pull(effect, len(values)) + self.assertLess(int(np.max(out)), 32767) + self.assertGreater(int(np.min(out)), -32768) + self.assertGreater(int(np.max(out)), peak + 5000) + + +class SyncAndTransport(unittest.TestCase): + def _synced(self, bpm, patch=6): + def transport(): + return (True, 0.0, bpm, 4, 4) + return AnalogDelay.create(silence_src(64), RATE, transport=transport, + patch=patch) + + def test_a_host_sets_time_from_division(self): + effect = self._synced(120.0) + self.assertEqual(effect._frames, 18000) + effect.set_macro(DIVISION_I, 127) # 1/1 = 2000 ms, clamps + self.assertEqual(effect._frames, 28800) + self.assertAlmostEqual(effect.get_macro(TIME_I), 127.0) + + def test_sync_is_division_clamped_to_the_span(self): + # Every Division at tempos where the clamp bites at both ends. Each + # tempo has a Division the clamp moves, so an unclamped claim fails. + for rate in RATES: + for bpm in (30.0, 60.0, 90.0, 120.0, 400.0): + effect = AnalogDelay.create( + silence_src(64, rate=rate), rate, patch=6, + transport=lambda bpm=bpm: (True, 0.0, bpm, 4, 4)) + clamped = 0 + for i, beats in enumerate(ad.DIVISION_BEATS): + effect.set_macro(DIVISION_I, i * 127.0 / 15.0) + want = beats * 60000.0 / bpm + held = min(600.0, max(20.0, want)) + clamped += held != want + # To the nearest frame: a tie (1837.5 frames at 90 BPM, + # 22.05 kHz) may land either side. + self.assertLessEqual( + abs(effect._frames - held * rate / 1000.0), 0.5 + 1e-9, + (rate, bpm, i)) + self.assertGreater(clamped, 0, bpm) + effect.deinit() + + def test_no_host_or_a_bad_tempo_leaves_time_on_the_knob(self): + effect = AnalogDelay(silence_src(64), sample_rate=RATE, patch=6) + self.assertEqual(effect._frames, whole(grid_ms(101), RATE)) + for bpm in (0.0, -1.0, float("nan"), float("inf"), None): + effect = self._synced(bpm) + self.assertEqual(effect._frames, whole(grid_ms(101), RATE), bpm) + + def test_the_transport_is_read_only_with_sync_on(self): + reads = [] + + def transport(): + reads.append(1) + return (True, 0.0, 120.0, 4, 4) + effect = AnalogDelay.create(silence_src(512), RATE, + transport=transport) + self.assertEqual(reads, []) + effect.set_macro(SYNC_I, 127) + self.assertGreater(len(reads), 0) + + def test_a_patch_moves_time_once(self): + # program_change refreshes once, so a patch change from a synced + # patch to an unsynced one lands the new patch's own Time and walks + # to it at the clock's law from the synced Time. + for cls, frames in ((AnalogDelay, whole(grid_ms(41), RATE)), + (PerMacroPatch, 18000)): + effect = cls.create(silence_src(64), RATE, + transport=lambda: (True, 0.0, 120.0, 4, 4), + patch=6) + effect.program_change(1) + self.assertEqual(effect._frames, frames, cls) + with NodeSpy(): + effect = self._synced(120.0) + mark = len(effect._delay._writes) + effect.program_change(1) + writes = effect._delay._writes[mark:] + self.assertEqual(len(writes), 1) + k = whole(grid_ms(41), RATE) + self.assertAlmostEqual(writes[0]["delay_slew"], + abs(k - 18000) / float(k)) + effect = PerMacroPatch.create( + silence_src(64), RATE, + transport=lambda: (True, 0.0, 120.0, 4, 4), patch=6) + mark = len(effect._delay._writes) + effect.program_change(1) + self.assertEqual(len(effect._delay._writes[mark:]), 8) + + def test_another_macro_leaves_a_walk_at_its_rate(self): + with NodeSpy(): + effect = AnalogDelay(silence_src(8 * BLOCK), sample_rate=RATE) + pull(effect, BLOCK) + effect.set_macro(TIME_I, midi_of_ms(100.0)) + slew = effect._delay._handed["delay_slew"] + self.assertAlmostEqual(slew, 2.0) + effect.set_macro(FEEDBACK_I, 90) + self.assertEqual(effect._delay._handed["delay_slew"], slew) + + +# -------------------------------------------------------------------------- +# The docstring's claims, each tied to the test that asserts it + +#: (sentence, word for word as the class docstring has it, and the test +#: that asserts it). Every sentence in the docstring that makes a claim is +#: here; one that could not be tied to a test was struck. +CLAIMS = ( + ("Time runs from 20 to 600 ms, Feedback from 0 to 0.99 and Mix from 0 " + "to 2.", "test_the_knob_spans"), + ("Up to Mix 1 the dry passes untouched until the first repeat arrives.", + "test_the_stops_and_the_corner"), + ("Mix 0 is a wire.", "test_mix_zero_is_a_wire_on_the_full_scale_ramp"), + ("A click comes out on the frame it went in: there is no latency.", + "test_click_delay_is_zero"), + ("A one-channel source gets the same effect with Spread held at 0.", + "test_mono_holds_spread_at_zero"), + ("Sync locks Time to Division of the host's beat, clamped to Time's " + "span.", "test_sync_is_division_clamped_to_the_span"), + ('`"single-line"` (the default) is one 4096-stage line, the Boss DM-2; ' + '`"double-line"` is two in series, the Deluxe Memory Man.', + "test_characters"), + ("Any other `character` raises `ValueError`.", "test_characters"), + ("The repeats' high-frequency corner is 0.2211 N / T for N stages and a " + "Time of T seconds, so it halves each time Time doubles.", + "test_the_named_cells_and_octaves"), + ("Where the law passes 0.98 of Nyquist the corner holds there, so a " + "shorter Time no longer brightens the repeats.", "test_characters"), + ("Every Time lands on a whole frame at every rate.", + "test_every_frame_count_lands_whole"), + ("A small Time move still lands.", "test_moves_under_eight_frames_land"), + ("Turning Time through several positions a block apart takes seconds to " + "settle, where one jump to the same place lands within the new Time.", + "test_turning_time_settles_in_seconds"), + ("The delay swings on a triangle by a fixed number of milliseconds, " + "whatever the Time.", "test_the_swing_reads_as_a_triangle"), + ("A Time move leaves the swing as it is.", + "test_modulation_is_the_table_and_a_time_move_leaves_it"), + ("A Modulation move glides over 20 ms.", + "test_a_modulation_move_does_not_step"), + ("Below Mix 1, an input peaking at or below floor(32767 (1 - Mix)) - 1 " + "does not reach the rail.", "test_the_input_ceiling"), + ("`tail_samples` is an upper bound on how long the repeats take to " + "reach exact zero after your input stops.", + "test_the_tail_reaches_exact_zero_inside_tail_samples"), + ("`reset()` empties the line and returns to patch 0.", + "test_reset_empties_the_line"), + ("With no host tempo, Time stays on the knob.", + "test_no_host_or_a_bad_tempo_leaves_time_on_the_knob"), + ("There is no sample-and-hold, so the repeats have no null at the " + "clock.", "test_no_null_at_the_clock"), + ("There is no fixed anti-alias pair: the repeats' one corner is the one " + "that moves with Time.", "test_the_only_corner_moves_with_time"), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "test_a_jumping_control_steps_the_output"), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", "test_a_tail_cut_short_carries_on"), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", "test_a_tail_cut_short_carries_on"), +) + + +def _flat(text): + return " ".join(text.split()) + + +def _cell(event, patch, channels=1, rate=RATE): + """One lifecycle matrix cell on the class, as measured, with the class's + DECLARED rows lifted so the raw verdict shows.""" + import lifecycle + ev = [e for e in lifecycle.events(AnalogDelay, patch) + if e.name == event][0] + saved = dict(lifecycle.DECLARED) + for key in list(lifecycle.DECLARED): + if key[0] == "AnalogDelay": + del lifecycle.DECLARED[key] + controls = {} + try: + return lifecycle.run_cell(AnalogDelay, ev, rate, channels, patch, {}, + controls) + finally: + lifecycle.DECLARED.clear() + lifecycle.DECLARED.update(saved) + for ctl in controls.values(): + ctl.close() + + +class TheClaims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + doc = _flat(AnalogDelay.__doc__) + tests = set() + for value in globals().values(): + if isinstance(value, type) and issubclass(value, + unittest.TestCase): + tests.update(n for n in dir(value) if n.startswith("test_")) + rest = doc + for sentence, test in CLAIMS: + self.assertIn(sentence, doc, sentence) + self.assertIn(test, tests, sentence) + rest = rest.replace(sentence, " ") + self.assertIn("**Limits shared by the family.**", doc) + numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] + self.assertEqual(numbers, []) + + def test_the_knob_spans(self): + effect = AnalogDelay(silence_src(64), sample_rate=RATE) + for index, low, high in ((TIME_I, 20.0, 600.0), + (FEEDBACK_I, 0.0, 0.99), + (MIX_I, 0.0, 2.0)): + effect.set_macro(index, 0) + self.assertAlmostEqual(effect._value(index), low, places=9) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect._value(index), high, places=9) + + def test_turning_time_settles_in_seconds(self): + # Brad's ruling of 2026-09-28 keeps the walk as it is. At 48 kHz, + # wet alone, Feedback 0: MIDI 101 -> 111 as ten moves a block + # apart against one jump made with the last of them. The jump + # lands on a static 111 within its Time (391 ms); the ten moves + # still differ from the jump a second later, and match it by eight. + move_at = MOVE_AT + last = move_at + 9 * BLOCK + frames = last + 8 * RATE + values = sine_values(997.0, frames, RATE, 12000) + + def render(moves, midi=101): + effect = AnalogDelay(array_src(values, 1), sample_rate=RATE, + time_ms=grid_ms(midi), feedback=0.0, + mix=2.0) + + def on_block(frame): + for at, to in moves: + if frame == at: + effect.set_macro(TIME_I, to) + out = pull(effect, frames, 1, on_block=on_block).astype(int) + effect.deinit() + return out + + steps = render([(move_at + k * BLOCK, 102 + k) for k in range(10)]) + jump = render([(last, 111)]) + static = render([], midi=111) + landed = np.flatnonzero(jump != static) + self.assertLessEqual(int(landed[-1]) - last, + whole(grid_ms(111), RATE) + BLOCK) + settled = np.flatnonzero(steps != jump) + self.assertGreater(int(settled[-1]) - last, RATE) + self.assertLess(int(settled[-1]), frames - RATE // 10) + + def test_a_jumping_control_steps_the_output(self): + # The matrix's E5 cell at patch 4 (Mix 2 -> 0 and back) steps past + # its bar; the class declares it (audiocomponents#117). + res = _cell("E5-mix0", 4) + self.assertTrue(res["P5"].startswith("RED"), res) + self.assertEqual(res["P1"], "ok", res) + + def test_a_tail_cut_short_carries_on(self): + # The matrix's E8-dry cell at patch 4: the source hands back an + # empty buffer once, and when it comes back the tail it cut short + # plays out of the silence (audiodsp#180). + res = _cell("E8-dry", 4) + self.assertTrue(res["P3"].startswith("RED(peak"), res) + self.assertEqual(res["P2"], "ok", res) + + +# -------------------------------------------------------------------------- +# The two checks every planted fault and every row is held to + + +class FaultsAreUnreachable(unittest.TestCase): + """Every fault's reachability walk, at 48, 44.1 and 22.05 kHz, reading + what the node is handed (or what the output does) at each position.""" + + CHECKED = 8 * 17 + 7 + + def test_every_fault_is_off_the_surface_at_three_rates(self): + for rate in RATES: + for name, faulted, reading, ctor in REACH_WALKS: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor) + self.assertEqual(result["checked"], self.CHECKED) + self.assertNotEqual(result["target"], result["clean"]) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire, beside + a control on the real class that must pass.""" + + def test_every_row_is_red_on_a_wire(self): + for name, measure in ( + ("T2a", t2a_measure), + ("T3", t3_measure), + ("T6", t6_measure), + ("T7", t7_measure)): + with self.subTest(row=name): + result = kit_faults.null_build_red( + AnalogDelay, measure, label="AnalogDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_bitcrusher.py b/tests/test_cpython_effects_bitcrusher.py index 0c47f9e..b105364 100644 --- a/tests/test_cpython_effects_bitcrusher.py +++ b/tests/test_cpython_effects_bitcrusher.py @@ -1083,3 +1083,122 @@ def test_program_change_onto_digital_silence_stays_silent(self): self.assertEqual(peak, 0, "program_change(%d) on silence emitted %d " "LSB" % (patch, peak)) + + +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + No in-step check here: the hold's accumulator restarts with the graph, + so the returning tone is held on a different grid from an instance + that never moved - hundreds of LSB apart before the fix as well. + """ + + CLS = rebuilt.Bitcrusher + MIX = 4 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + +class NoBranchClear(rebuilt.Bitcrusher): + """Planted: the class before the fix. Band Limit or Dither back on and + nothing is cleared.""" + + def _clear_nodes(self, keep=(), only=None): + if only is None: + rebuilt.Bitcrusher._clear_nodes(self, keep, only) + + +class FirstSectionClear(rebuilt.Bitcrusher): + """Planted, a wrong cure: Band Limit back on clears the first section of + the low-pass and leaves the rest holding what they held.""" + + def _clear_nodes(self, keep=(), only=None): + if only is not None and only is self._sections: + only = self._sections[:1] + rebuilt.Bitcrusher._clear_nodes(self, keep, only) + + +class TheBandLimitAndTheDitherComeBackClean(unittest.TestCase): + """Band Limit and Dither back on after a pause play nothing from before + it (audiocomponents#113). The band-limit sections and the dither gate + are not pulled while their switch is off; the sections kept their + memory (13 646 LSB out of silence) and the gate stayed open on the + dither (32). Each is cleared when it comes back.""" + + CLS = rebuilt.Bitcrusher + + def test_band_limit_back_on_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual(stale.blip(self.CLS, 3, 127, 0, rate, + channels), (0, 0), + (rate, channels)) + for patch in sorted(self.CLS.PATCHES): + self.assertEqual(stale.blip(self.CLS, 3, 127, 0, patch=patch), + (0, 0), patch) + + def test_dither_back_on_plays_nothing_it_did_not_already(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual(stale.blip(self.CLS, 2, 127, 0, rate, + channels), (0, 0), + (rate, channels)) + # At the low-bit patches the dither is never silent at 127, + # moved or not: the move adds nothing to what the class plays. + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, 2, 127, 0, patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, stale.twin(self.CLS, 2, 127, patch=patch), patch) + + def test_the_old_class_and_a_partial_clear_are_red(self): + self.assertGreater(stale.blip(NoBranchClear, 3, 127, 0)[1], 5000) + self.assertGreater(stale.blip(NoBranchClear, 2, 127, 0)[1], 0) + self.assertGreater(stale.blip(FirstSectionClear, 3, 127, 0)[1], 100) diff --git a/tests/test_cpython_effects_combfilter.py b/tests/test_cpython_effects_combfilter.py index 5b53397..5153c0a 100644 --- a/tests/test_cpython_effects_combfilter.py +++ b/tests/test_cpython_effects_combfilter.py @@ -31,6 +31,7 @@ from audioeffects import _component # noqa: E402 from audioeffects import combfilter # noqa: E402 +from audioeffects.rebuilt.digitaldelay import clear_of_stalls # noqa: E402 #: The subject is named directly. `CombFilter` has come home to #: `audioeffects/combfilter.py`. These tests still import the home module so #: a planted-fault subclass is measured against this file, not only @@ -185,6 +186,66 @@ class ShortLatencyCombFilter(combfilter.CombFilter): LATENCY_SAMPLES = 256 +class FrozenToneCombFilter(combfilter.CombFilter): + """Tone's off stop leaving the low-pass in at 0.001 Hz, where its + float32 coefficient is one step above 0 and its state cannot move: it + holds what it held, as the node's off stop did up to audiodsp v0.6.2 + (15 070 LSB at 48 kHz on `TheToneOffStopIsTheFilterOut`'s move then), + and plays it out of silence.""" + + NAME = 'CombFilter' + + def _tone_damping(self, tone): + if tone >= combfilter.TONE_OFF_HZ: + return 0.001 + return self._hz(tone) + + +class _ToneMemory: + """Remembers whether Tone has been in since the last reset, for the + off-stop faults below (the class itself no longer needs to).""" + + def _tone_damping(self, tone): + if tone < combfilter.TONE_OFF_HZ: + self._was_in = True + return combfilter.CombFilter._tone_damping(self, tone) + + def reset(self): + self._was_in = False + combfilter.CombFilter.reset(self) + + def _off_after_in(self, tone): + return getattr(self, "_was_in", False) and \ + tone >= combfilter.TONE_OFF_HZ + + +class TrackingCombFilter(_ToneMemory, combfilter.CombFilter): + """The workaround retired at audiodsp v0.6.3rc1: once Tone has been in, + the off stop hands `damping_hz` at 32 x the rate instead of 0.""" + + NAME = 'CombFilter' + + def _tone_damping(self, tone): + damping = _ToneMemory._tone_damping(self, tone) + if self._off_after_in(tone): + return 32.0 * self._sample_rate + return damping + + +class LeakyTrackCombFilter(_ToneMemory, combfilter.CombFilter): + """The off stop after Tone has been in, tracking the tap at a + coefficient under 1: `damping_hz` at half the rate (a = 1 - e^-pi, + 0.957), a low-pass left in the loop where the knob says off.""" + + NAME = 'CombFilter' + + def _tone_damping(self, tone): + damping = _ToneMemory._tone_damping(self, tone) + if self._off_after_in(tone): + return 0.5 * self._sample_rate + return damping + + # -- helpers ---------------------------------------------------------------- def silence(frames, rate=SAMPLE_RATE, channels=CHANNELS): @@ -220,6 +281,11 @@ def pull(node, frames, channels=CHANNELS): return out[:frames * channels] +def silence_source(rate=SAMPLE_RATE, channels=CHANNELS, frames=512): + return audiocore.RawSample(silence(frames, rate, channels), + sample_rate=rate, channel_count=channels) + + def left(values, channels=CHANNELS): return values[0::channels] @@ -240,6 +306,46 @@ def cents(measured, ideal): return 1200.0 * math.log(ideal / measured, 2.0) if measured else 1e9 +def laps_to_exact_zero(feedback, peak=32768.0): + """Laps of the line after which no sample can be non-zero, on audiodsp + v0.6.2 and later. + + Rounding to nearest bounds a lap's peak by x' <= g x + 0.5, so + x_k <= g^k (peak - c) + c with c = 0.5 / (1 - g); and since audiodsp#154 + no lap hands back a sample as large as the one it sent, so once the peak + is at most floor(c) it takes at most floor(c) more laps to reach 0. The + 1e-6 keeps a c that is a whole number in exact arithmetic (10 at 0.95) + from flooring one short in float, where the loop would never end. + """ + c = 0.5 / (1.0 - feedback) + stall = math.floor(c + 1e-6) + laps = 0 + while peak >= stall + 1: + peak = feedback * (peak - c) + c + laps += 1 + return laps + int(stall) + + +def zero_bound_frames(hz, feedback, rate=SAMPLE_RATE): + """The tail bound in frames: `laps_to_exact_zero` laps, each at most one + frame past the line (the read interpolates towards the next older + frame).""" + return laps_to_exact_zero(feedback) * (math.ceil(rate / hz) + 1) + + +def ring_period(values, asked, repeats=10): + """The ring's period, from the centroid of its `repeats`-th repeat, + found by walking repeat to repeat from the first one. A pulse keeps its + first moment through linear interpolation, so this reads the delay the + loop really has, fraction and all, while the ring is loud enough to + carry one.""" + first = centroid(values, asked) + position = first + for _ in range(repeats - 1): + position = centroid(values, position + first) + return position / repeats + + def bin_db(values, hz, rate=SAMPLE_RATE, skip=0): """One DFT bin, in dB relative to full scale of the probe's own units.""" real = imaginary = 0.0 @@ -553,6 +659,17 @@ class ResetEmptiesTheLine(unittest.TestCase): #: after `reset()` and reads exactly like a line that was never cleared. BURST_FROM = 12000 BURST_TO = 22000 + #: `reset()` lands the moment the burst stops, with the line at full + #: ring. Up to audiodsp v0.6.1 it could land anywhere after the burst, + #: because a line left alone parked on a few LSB for ever; since v0.6.2 + #: (audiodsp#154) a line left alone empties itself, inside 11 544 frames + #: at 440 Hz / Feedback 0.9 (`zero_bound_frames`), so a reset 10 000 + #: frames late found a line nearly drained by itself and the planted + #: fault below read 0 -- a control with no teeth. Here the 8192 frames + #: read after the reset hold the loudest part of the ring an un-reset + #: line still carries, from either end of the source (its first 12 000 + #: frames are silence, and so is everything after 22 000). + RESET_AT = BURST_TO def residue(self, cls): values = array.array("h") @@ -567,7 +684,7 @@ def residue(self, cls): channel_count=CHANNELS) effect = cls(source, frequency=440.0, feedback=0.9, mix=2.0, glide=0.0) - pull(effect.output, 32000) + pull(effect.output, self.RESET_AT) effect.reset() return max(abs(v) for v in pull(effect.output, 8192)), effect, source @@ -581,8 +698,10 @@ def test_reset_leaves_the_line_silent_and_the_source_rendering(self): self.assertGreater(max(abs(v) for v in pull(source, 48000)), 0) def test_a_line_left_full_is_red(self): + # Measured at v0.6.2: 32 768, the rail. A full line, not a parked + # LSB; the same reset 10 000 frames later read 0 here. residue, _effect, _source = self.residue(UnresetCombFilter) - self.assertGreater(residue, 0) + self.assertGreater(residue, 1000) def test_deinit_releases_the_nodes_and_leaves_the_source(self): source = tone(220.0, 8192) @@ -594,16 +713,23 @@ def test_deinit_releases_the_nodes_and_leaves_the_source(self): self.assertGreater(max(abs(v) for v in pull(source, 2048)), 0) -class TheTailIsBoundedRatherThanZero(unittest.TestCase): - """The one Tier 1 invariant this class does not always meet, held to - both ends of what it does. `to_s16` rounds, so every |c| <= 0.5/(1-g) is - a fixed point of the loop -- but whether the loop can sit on one is - decided by the *fractional part* of `sample_rate / Frequency`, not by the - feedback alone. 440 Hz is 109.09 frames: the read is nearly exact and a - lone LSB survives its round trip. 438.3 Hz is 109.51: the interpolator - averages it with a zero neighbour and rounds it away. Both are asserted, - because the first alone would read as "this class never settles" and the - second alone as "it always does".""" +class TheTailReachesExactZero(unittest.TestCase): + """Silence in, silence out, at every Feedback and every tuning, inside + a stated bound. + + Up to audiodsp v0.6.1 this class could not meet it above Feedback 0.5: + the node's feedback write rounded to nearest, so every + |c| <= 0.5/(1-g) was a fixed point of the loop, and at a tuning whose + read lands nearly on a whole sample (440 Hz, 109.09 frames) a few LSB + went round for ever -- this class was `TheTailIsBoundedRatherThanZero` + and held the residue under that bound. v0.6.2 (audiodsp#154) truncates + the fed-back term toward zero exactly where rounding would hand it back + unchanged, so the tail now reaches exact zero at whole-sample and + half-sample tunings alike, inside `zero_bound_frames` of the burst's + end. Measured at v0.6.2 on this burst at 440 Hz: the last non-zero + frame is 3 164 / 4 906 / 9 597 / 18 109 frames after the burst at + Feedback 0.7 / 0.8 / 0.9 / 0.95, against bounds of 3 774 / 5 772 / + 11 544 / 23 643.""" #: 48 000 / 440 = 109.09 frames -- 0.09 of a sample off the grid. PARKS_HZ = 440.0 @@ -630,13 +756,36 @@ def residue(self, feedback, seconds=4, tuned=None): def test_below_half_the_line_reaches_exact_zero(self): self.assertEqual(self.residue(0.45), 0) - def test_a_whole_sample_tuning_parks_inside_the_closed_form_bound(self): - for feedback in (0.7, 0.8, 0.9): + def last_sound(self, feedback, seconds=4, tuned=None): + """Frames from the burst's last frame to the last non-zero one.""" + values = array.array("h") + for frame in range(SAMPLE_RATE * seconds): + value = 0 + if 2048 <= frame < 6848: + value = int(20000 * math.sin(2.0 * math.pi * 440.0 * frame + / SAMPLE_RATE)) + for _ in range(CHANNELS): + values.append(value) + effect = combfilter.CombFilter( + audiocore.RawSample(values, sample_rate=SAMPLE_RATE, + channel_count=CHANNELS), + frequency=self.PARKS_HZ if tuned is None else tuned, + feedback=feedback, mix=2.0, glide=0.0) + y = pull(effect.output, SAMPLE_RATE * seconds) + last = max(i for i in range(len(y)) if y[i]) // CHANNELS + return last - 6847 + + def test_a_whole_sample_tuning_reaches_exact_zero_inside_the_bound(self): + for feedback in (0.7, 0.8, 0.9, 0.95): with self.subTest(feedback=feedback): - bound = math.floor(0.5 / (1.0 - feedback)) - measured = self.residue(feedback) + bound = zero_bound_frames(self.PARKS_HZ, feedback) + measured = self.last_sound(feedback) self.assertGreater(measured, 0) - self.assertLessEqual(measured, bound) + self.assertLessEqual(measured, bound, + "still sounding %d frames after the " + "burst, over the %d-frame bound" + % (measured, bound)) + self.assertEqual(self.residue(feedback), 0) def test_a_half_sample_tuning_still_reaches_exact_zero(self): # The other end, and the reason the docstring's number is a bound @@ -647,33 +796,322 @@ def test_a_half_sample_tuning_still_reaches_exact_zero(self): tuned=self.DRAINS_HZ), 0) -class TheParkedRingIsTheNearestSample(unittest.TestCase): - """The TAIL-pitch bound. Above Feedback 0.5 the parked ring's period - is the nearest whole number of samples to F_s/Frequency, not the - fractional delay the comb was asked for: +17.4 cents at 1760 Hz / - Feedback 0.8 (27 samples at 48 kHz) and at most a half-sample — - about 70 cents — near 4 kHz. The first-repeat tap still lands - within 0.01 cents. Below Feedback 0.5, and at half-sample tunings, - the tail reaches exact zero.""" +class LapShortCombFilter(combfilter.CombFilter): + """`tail_samples` one lap short. On full-scale DC at 20 Hz / Feedback + 0.7, the bound's tightest cell, the tail outlives it.""" + + NAME = 'CombFilter' + + @property + def tail_samples(self): + memory = combfilter._tone_excess(self._damping, + self._sample_rate)[0] + return (combfilter.CombFilter._tail_bound(self) + - (self._reach + 1 + memory)) + + +class NoTrimTermCombFilter(combfilter.CombFilter): + """`tail_samples` without the trim's term: the comb's laps only, while + the fixed-point shelf in front is still letting its last LSB out.""" + + NAME = 'CombFilter' + + @property + def tail_samples(self): + bound = combfilter.CombFilter._tail_bound(self) + if self._trim.mix > 0.0: + bound -= int(math.ceil(combfilter.TRIM_TAIL_S + * self._sample_rate)) + return bound + + +class SteppedCombFilter(combfilter.CombFilter): + """The workaround retired at audiodsp v0.6.3rc1: with Tone in, the + Feedback handed at the nearer edge of a stall window + (`clear_of_stalls`), a Feedback nobody set.""" + + NAME = 'CombFilter' + + def _refresh(self): + combfilter.CombFilter._refresh(self) + if self._damping > 0.0 and self._feedback > 0.0: + excess = combfilter._tone_excess(self._damping, + self._sample_rate)[1] + self._feedback = clear_of_stalls(self._feedback, excess) + self._comb.set(feedback=self._feedback) + + +class TheTailIsDeclared(unittest.TestCase): + """`tail_samples` is finite at every setting (housekeeping, + 2026-09-28, Brad's ruling of that date): the comb's lap bound, each lap + one frame past the longest line the read head may be at plus the Tone + low-pass's memory, plus `TRIM_TAIL_S` while the trim is in circuit. + Every macro's stops and interior points, every patch, the long corners + and the stall centres, at three rates, stereo and mono, end inside it + (`housekeeping_cf_tail.py`, 900 cells); these are the cells where each + part of the bound is tight, each beside a bound that is short there.""" + + def render(self, cls, material, **options): + """(declared, last non-zero frame after a 50 ms burst, peak of the + last 0.25 s), silence running to the declared bound plus 0.5 s.""" + rate = SAMPLE_RATE + probe = cls(audiocore.RawSample(array.array("h", [0] * 512), + sample_rate=rate, + channel_count=CHANNELS), + **options) + declared = probe.tail_samples + probe.deinit() + lead = rate // 20 + frames = lead + (rate if declared is None else declared) + rate // 2 + values = array.array("h", [0] * (frames * CHANNELS)) + for index in range(lead * CHANNELS): + values[index] = material + effect = cls(audiocore.RawSample(values, sample_rate=rate, + channel_count=CHANNELS), + **options) + y = pull(effect.output, frames) + effect.deinit() + last = 0 + for index in range(len(y) - 1, lead * CHANNELS - 1, -1): + if y[index]: + last = index // CHANNELS - lead + 1 + break + held = max(abs(v) for v in y[-(rate // 4) * CHANNELS:]) + return declared, last, held + + def test_finite_at_every_patch_and_every_stop(self): + effect = combfilter.CombFilter( + audiocore.RawSample(array.array("h", [0] * 512), + sample_rate=SAMPLE_RATE, + channel_count=CHANNELS)) + for patch in range(6): + effect.program_change(patch) + self.assertIsInstance(effect.tail_samples, int, patch) + for index in range(6): + for midi in (0, 64, 127): + effect.program_change(0) + effect.set_macro(index, midi) + self.assertIsInstance(effect.tail_samples, int, + (index, midi)) + effect.deinit() - #: Half a sample at 4 kHz / 48 kHz is 70.67 cents; the bound the - #: class states is "about 70 cents". - HALF_SAMPLE_CENTS = 70.0 + def test_the_lap_bound_holds_where_it_is_tight(self): + # Full-scale DC at 20 Hz / Feedback 0.7: 69 600 frames against + # 69 629 when this test was written. + options = {"frequency": 20.0, "glide": 0.0} + declared, last, held = self.render(combfilter.CombFilter, 32767, + **options) + self.assertLessEqual(last, declared) + self.assertGreater(last, declared - 2402) + self.assertEqual(held, 0) + declared, last, _held = self.render(LapShortCombFilter, 32767, + **options) + self.assertGreater(last, declared) + + def test_the_trim_term_covers_the_shelf(self): + options = {"trim_db": -18.0, "feedback": 0.0, "glide": 0.0} + declared, last, held = self.render(combfilter.CombFilter, 32767, + **options) + self.assertLessEqual(last, declared) + self.assertGreater(last, 20000) + self.assertEqual(held, 0) + declared, last, held = self.render(NoTrimTermCombFilter, 32767, + **options) + self.assertTrue(last > declared or held, (declared, last, held)) + + def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): + # Feedback 0.5 with Tone at 2 kHz is a stall centre: up to audiodsp + # v0.6.2 the node held 1 LSB there for ever and the class stepped + # the Feedback clear. Since v0.6.3rc1 (#157) the node lands the + # stalled state: 0.5 is handed as set, and the tail ends inside the + # bound, which counts its landing lap. + options = {"frequency": 1000.0, "feedback": 0.5, "tone_hz": 2000.0, + "glide": 0.0} + declared, last, held = self.render(combfilter.CombFilter, 2, + **options) + self.assertGreater(last, 0) + self.assertLessEqual(last, declared) + self.assertEqual(held, 0) + for cls, handed_as_set in ((combfilter.CombFilter, True), + (SteppedCombFilter, False)): + effect = cls(silence_source(SAMPLE_RATE), **options) + self.assertEqual(effect._feedback == effect._value(1), + handed_as_set, cls) + self.assertLess(abs(effect._feedback - 0.5), 3e-5) + effect.deinit() + + +class TheToneOffStopIsTheFilterOut(unittest.TestCase): + """Tone off after Tone has been in. Up to audiodsp v0.6.2 the node froze + its loop low-pass while `damping_hz` was 0, and from 2026-09-28 this + class handed 32 x the rate there instead (a coefficient of exactly 1). + Since v0.6.3rc1 the node keeps an off low-pass's state on the tap + (audiodsp#158), the off stop is exactly 0 again, and these assert what + that buys: silence when Tone comes back, and the same bytes and bound + as a fresh instance. The retired cure is planted beside each.""" + + def tone_back_in(self, cls, rate=SAMPLE_RATE, channels=CHANNELS, + mix=2.0): + """300 Hz at 30 000 LSB for 0.5 s with Tone 2 kHz, Feedback 0; Tone + to its off stop as the input stops, 2 s of silence, Tone to MIDI 0: + the output's peak after that move. The source is exactly as long as + what is read, so it never wraps round and replays the tone.""" + loud = (rate // 2) // 256 * 256 + back = (loud + 2 * rate) // 256 * 256 + frames = back + rate // 4 + values = silence(frames, rate, channels) + for frame in range(loud): + value = int(round(30000 * math.sin(2.0 * math.pi * 300.0 * frame + / rate))) + for channel in range(channels): + values[frame * channels + channel] = value + effect = cls(audiocore.RawSample(values, sample_rate=rate, + channel_count=channels), + sample_rate=rate, frequency=440.0, feedback=0.0, + mix=mix, tone_hz=2000.0) + out = array.array("h") + while len(out) < frames * channels: + done = len(out) // channels + if done == loud: + effect.set_macro(3, 127) + elif done == back: + effect.set_macro(3, 0) + out.extend(memoryview(bytes(audiocore.get_buffer( + effect.output)[1])).cast("h")) + effect.deinit() + return max(abs(v) for v in out[back * channels:frames * channels]) - def parked_period(self, cls, hz, feedback, rate=SAMPLE_RATE, seconds=4): + def test_tone_back_in_after_silence_stays_silent(self): + for rate in (48000, 44100, 22050): + for channels in (2, 1): + self.assertEqual(self.tone_back_in( + combfilter.CombFilter, rate, channels), 0, + (rate, channels)) + self.assertEqual(self.tone_back_in(combfilter.CombFilter, mix=1.0), + 0) + # Planted: a low-pass left in with a frozen state (0.001 Hz) plays + # what it held (at v0.6.2 the node's own off stop played 15 070 LSB + # at 48 kHz, 10 110 at 44.1, 8 828 at 22.05 here). + self.assertGreater(self.tone_back_in(FrozenToneCombFilter), 10000) + self.assertGreater(self.tone_back_in(FrozenToneCombFilter, 22050), + 5000) + + def test_the_off_stop_hands_exactly_zero(self): + # Fresh, after Tone has been in, after a Tone in the constructor or + # a patch, and at the 0.95 stall centre, which is handed as set. + # The retired tracking cure, planted, hands 32 x the rate. + for rate in (48000, 44100, 22050): + for cls, expected in ((combfilter.CombFilter, 0.0), + (TrackingCombFilter, 32.0 * rate)): + effect = cls(silence_source(rate), sample_rate=rate) + self.assertEqual(effect._damping, 0.0) + effect.set_macro(1, 127) + effect.set_macro(3, 0) + effect.set_macro(3, 127) + self.assertEqual(effect._damping, expected, (cls, rate)) + self.assertEqual(effect._feedback, 0.95) + self.assertIsInstance(effect.tail_samples, int) + effect.deinit() + for options in ({"tone_hz": 5000.0}, {"patch": 3}): + effect = cls(silence_source(rate), sample_rate=rate, + **options) + effect.program_change(0) + self.assertEqual(effect._damping, expected, + (cls, rate, options)) + effect.deinit() + + def off_after_tone(self, cls, rate=SAMPLE_RATE, channels=CHANNELS, + **options): + """(samples, worst LSB) by which the off stop after Tone has been + in differs from a fresh instance's off stop, on 1 s of full-scale + noise.""" + frames = rate + seed = 12345 + values = array.array("h") + for _ in range(frames * channels): + seed = (1103515245 * seed + 12345) & 0x7FFFFFFF + values.append((seed >> 15) - 32768) + tracked = cls(audiocore.RawSample(values, sample_rate=rate, + channel_count=channels), + sample_rate=rate, **options) + tracked.set_macro(3, 0) + tracked.set_macro(3, 127) + fresh = combfilter.CombFilter( + audiocore.RawSample(array.array("h", values), sample_rate=rate, + channel_count=channels), + sample_rate=rate, **options) + a = pull(tracked.output, frames, channels) + b = pull(fresh.output, frames, channels) + tracked.deinit() + fresh.deinit() + diff = [abs(x - y) for x, y in zip(a, b)] + return sum(1 for d in diff if d), max(diff) + + def test_the_off_stop_after_tone_is_the_filter_out(self): + # Byte for byte a fresh instance's off stop, at a whole-frame read + # (1000 Hz at 48 kHz) and a fractional one (440 Hz, 109.09 frames, + # at 48 and 22.05 kHz mono), where the retired tracking cure was + # within 1 LSB. + options = {"frequency": 1000.0, "feedback": 0.8, "mix": 2.0} + self.assertEqual(self.off_after_tone(combfilter.CombFilter, + **options), (0, 0)) + for rate in (48000, 22050): + self.assertEqual(self.off_after_tone(combfilter.CombFilter, + rate, 1), (0, 0), rate) + # Planted: the retired cure moves samples at the fractional read, + # and a coefficient of 0.957 is a low-pass left in the loop. + count, _worst = self.off_after_tone(TrackingCombFilter, 48000, 1) + self.assertGreater(count, 0) + _count, worst = self.off_after_tone(LeakyTrackCombFilter, **options) + self.assertGreater(worst, 1000) + + def test_the_tail_bound_after_tone_is_the_fresh_one(self): + # Tone off after Tone is the filter off, so the bound is the fresh + # instance's, with no memory term; the retired cure counted one + # frame of memory a lap. The 0.95 stop at 20 Hz is the long corner. + for rate in (48000, 22050): + bounds = [] + for cls in (combfilter.CombFilter, TrackingCombFilter): + effect = cls(silence_source(rate), sample_rate=rate, + frequency=20.0, feedback=0.95, glide=0.0) + fresh_bound = effect.tail_samples + effect.set_macro(3, 0) + effect.set_macro(3, 127) + bounds.append((fresh_bound, effect.tail_samples)) + effect.deinit() + self.assertEqual(bounds[0][0], bounds[0][1], rate) + self.assertGreater(bounds[1][1], bounds[1][0], rate) + + +class TheRingIsTheAskedPitchAndEnds(unittest.TestCase): + """The TAIL-pitch trait. The ring plays the fractional delay the comb + was asked for, through its tenth repeat within 1 cent, and then reaches + exact zero inside `zero_bound_frames`. + + Up to audiodsp v0.6.1 this class was `TheParkedRingIsTheNearestSample`: + above Feedback 0.5 the tail parked on a ring whose period was the + nearest whole number of samples, +17.4 cents at 1760 Hz / Feedback 0.8, + for ever. v0.6.2 (audiodsp#154) empties the line, so there is no parked + ring left to measure; what the ear gets instead is the ring itself, and + that is what this class holds. Measured at v0.6.2: 1760 Hz / 0.8 rings + at 27.262 frames against the asked 27.273 (0.7 cents) and is silent + 928 frames after the impulse (bound 1 508); 1000 Hz / 0.8 rings at + exactly 48 and is silent after 2 016 (bound 2 548). The line two samples + short rings at 25.000, 150.6 cents sharp.""" + + #: The ring's period is held to this many cents of the asked delay. + RING_CENTS = 1.0 + + def ring(self, cls, hz, feedback, rate=SAMPLE_RATE, seconds=1): + """(period of the ring in frames, frames to its last non-zero + sample) for an impulse at frame 0.""" frames = rate * seconds effect = cls(impulse_at(0, frames + 512, rate), frequency=hz, feedback=feedback, mix=2.0, glide=0.0, sample_rate=rate) y = left(pull(effect.output, frames)) - edges = [i for i in range(1, len(y) - 2048, 1) - if y[i - 1] == 0 and y[i] != 0 and i >= frames - rate] - if len(edges) < 4: - edges = [i for i in range(max(1, frames - rate), len(y)) - if y[i - 1] == 0 and y[i] != 0] - self.assertGreater(len(edges), 3, "tail did not oscillate") - gaps = [edges[i] - edges[i - 1] for i in range(1, len(edges))] - return sum(gaps) / len(gaps) + last = max(i for i in range(len(y)) if y[i]) + return ring_period(y, rate / hz), last def delay_skew_frames(self, effect): """Asked delay minus the nearest whole sample. The clean class @@ -683,18 +1121,18 @@ def delay_skew_frames(self, effect): return float(round(asked) - 2) - round(asked) return asked - round(asked) - def test_1760_at_feedback_08_parks_27_samples_17_cents_sharp(self): - period = self.parked_period(combfilter.CombFilter, 1760.0, 0.8) - self.assertAlmostEqual(period, 27.0, delta=0.05) + def test_1760_at_feedback_08_rings_on_the_asked_pitch_then_ends(self): + period, last = self.ring(combfilter.CombFilter, 1760.0, 0.8) error = 1200.0 * math.log((SAMPLE_RATE / period) / 1760.0, 2.0) - self.assertAlmostEqual(error, 17.4, delta=0.2) - self.assertLess(abs(error), self.HALF_SAMPLE_CENTS) + self.assertLess(abs(error), self.RING_CENTS, period) + self.assertLessEqual(last, zero_bound_frames(1760.0, 0.8)) - def test_1000_parks_on_the_asked_pitch(self): - period = self.parked_period(combfilter.CombFilter, 1000.0, 0.8) - self.assertAlmostEqual(period, 48.0, delta=0.05) + def test_1000_rings_on_the_asked_pitch_then_ends(self): + period, last = self.ring(combfilter.CombFilter, 1000.0, 0.8) + self.assertAlmostEqual(period, 48.0, delta=0.005) error = 1200.0 * math.log((SAMPLE_RATE / period) / 1000.0, 2.0) - self.assertAlmostEqual(error, 0.0, delta=0.2) + self.assertLess(abs(error), self.RING_CENTS, period) + self.assertLessEqual(last, zero_bound_frames(1000.0, 0.8)) def test_the_first_repeat_at_1760_is_still_the_asked_tap(self): ideal = SAMPLE_RATE / 1760.0 @@ -705,10 +1143,10 @@ def test_the_first_repeat_at_1760_is_still_the_asked_tap(self): self.assertLess(abs(cents(centroid(y, ideal), ideal)), 0.01) def test_a_two_sample_shorter_line_is_outside_the_bound(self): - period = self.parked_period(TwoSampleShortCombFilter, 1760.0, 0.8) + period, _last = self.ring(TwoSampleShortCombFilter, 1760.0, 0.8) self.assertAlmostEqual(period, 25.0, delta=0.05) error = 1200.0 * math.log((SAMPLE_RATE / period) / 1760.0, 2.0) - self.assertGreater(abs(error), self.HALF_SAMPLE_CENTS) + self.assertGreater(abs(error), self.RING_CENTS) def test_the_surface_cannot_dial_the_two_sample_short(self): def build(cls): diff --git a/tests/test_cpython_effects_compressor.py b/tests/test_cpython_effects_compressor.py index 8bbe0fa..35363fa 100644 --- a/tests/test_cpython_effects_compressor.py +++ b/tests/test_cpython_effects_compressor.py @@ -74,15 +74,22 @@ class MixZeroIsAWireTest(unittest.TestCase): 16384 on a full-scale ramp, all in the right channel, because a stereo voice at pan 0 gets 32767 on the left and 32768 on the right. Nothing peaking below -6.02 dBFS reaches the mechanism. + + audiodsp v0.6.1 (#129) made the voice at 1.0 exact, and the pin moved + there on 2026-09-27 (`AUDIODSP_PIN`). So the unity plant is a control + now, and the fault the row is shown catching is the kit spec's own: + the dry voice a hair under unity, 32767/32768. """ - def _rendered(self, cls=None, channels=2, frames=8192): + def _rendered(self, cls=None, channels=2, frames=8192, dry_level=None): values = probes.ramp_fs(frames, channels) source = audiocore.RawSample(values, sample_rate=RATE, channel_count=channels) effect = (cls or ev.Compressor).create(source, RATE, mix=0.0) try: self.assertEqual(effect.latency_samples, 0) + if dry_level is not None: + effect._mixer.voice[1].level = dry_level wet = probes.render(effect.output, frames, rate=RATE, channels=channels, block=256) finally: @@ -97,10 +104,28 @@ def test_mix_zero_is_a_wire_at_both_channel_counts(self): self.assertTrue(result["passed"], result["red"]) self.assertEqual(result["values"]["differing_samples"], 0) - def test_the_dry_voice_at_unity_is_the_fault_the_wire_catches(self): + def test_the_dry_voice_at_unity_is_a_wire_since_audiodsp_v0_6_1(self): + """audiodsp#95's plant, kept as a control now that it cannot fire. + + The wiring this class shipped with before #95 renders byte-exact on + the floor the pin names. If this ever goes red, the floor has moved + back under the suite. + """ result = self._rendered(ThroughTheDryVoice) + self.assertTrue(result["passed"], result["red"]) + self.assertEqual(result["values"]["differing_samples"], 0) + + def test_the_wire_goes_red_on_a_one_lsb_dry_path(self): + """The fault the WIRE row is shown catching: the dry voice at + 32767/32768, inaudible, and the byte compare must still refuse it. + It has to put the mixer back in the path to be planted at all, + because Mix 0 no longer runs through one. + """ + result = self._rendered(ThroughTheDryVoice, + dry_level=32767.0 / 32768.0) self.assertFalse(result["passed"]) self.assertEqual(result["values"]["max_abs_difference_lsb"], 1) + self.assertGreater(result["values"]["differing_samples"], 0) def test_the_head_of_the_render_is_not_a_hole(self): """The second defect the ramp found: this class opened no level @@ -214,5 +239,47 @@ def measure(cls): self.assertFalse(result["null"]["passed"]) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +from audioeffects import compressor # noqa: E402 + + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix up from 0 plays nothing from before (audiocomponents#113; Brad, + 2026-09-28: "fix the stale blocks"). An instance built wet primes its + mixer voices with the first block of the source; moved to 0 before it + played, and back up after the input stopped, that block came out 256 + frames later. `_component.Component._rejoin` clears the graph and the + voices are primed afresh.""" + + CLS = compressor.Compressor + MIX = 13 + + def test_the_block_primed_at_construction_is_not_replayed(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual(stale.first_blip( + self.CLS, self.MIX, rate, channels), 0, (rate, channels)) + for patch in sorted(self.CLS.PATCHES): + self.assertEqual(stale.first_blip( + self.CLS, self.MIX, patch=patch), 0, patch) + + def test_mix_back_after_silence_plays_nothing(self): + for channels in (2, 1): + self.assertEqual(stale.blip(self.CLS, self.MIX, 127, 0, + channels=channels), (0, 0)) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + for fault in (stale.StaleRejoin, stale.ClearOnlyRejoin): + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, fault), self.MIX), 10000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py new file mode 100644 index 0000000..999668a --- /dev/null +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -0,0 +1,3270 @@ +"""`ConvolutionReverb`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/ConvolutionReverb.md` +(frozen at anchor 85cc2bf); its Tier 2 rows are D1-D6. Each row here is the +measurement over a slice of the span the row quantifies over, the same +measurement red on the row's planted fault at the constructor defaults +(D1's at the measured-mode defaults), the fault shown out of reach of every +macro position and shipped patch, and the measurement red on the class +built as a wire (D2: on the class with its checks removed, since the wire +keeps the class's raise). Exhaustive spans and rates live in the evidence +pack, not in this file. + +Every measurement here computes its reference itself - D1's direct sum and +its load gain, D5's Decay law from the seconds the test handed the +constructor - and never reads it back from the class. The M1 reference +uses numpy and runs on CPython only. + +Fix round 1 (gate audit round 1, 2026-09-28) added four readings that can +fail where the frozen ones could not, each with a control that goes red on +it and passes the old reading: D1 at an interior trim where truncation and +rounding differ (`TrimRounded`), D2's allocation read as the node's +capacity through `load()` rather than `node.taps` (`ExtraPartition` in +measured mode), D5's law from the handed seconds (`AllocatedSeconds`), and +D6's absolute clause |wet/dry| <= 0.5 dB (`HotRoom`, a constant +3 dB). + +Fix round 2 (gate audit round 2) took D5's law off the class's positions +too: the Decay and Predelay positions come from what the test handed the +instance (`handed_build`), with `DecayKeptSquared` and `DecayMidiSquared` +as the controls, red on the handed law at Decay 64/127 and green on the +law read back off `effect._macros`. It also pins two edges the docstring +now states: a negative `damping_hz` is out of circuit, and `impulse=b""` +holds one partition. + +The re-audit's fix round 1 (gate audit round 3) added three checks, each +shown red on its plant: D5's Predelay control (`PredelayKeptSquared`, red +on the handed law at Predelay 64/127 and green read back), a NaN +`damping_hz` pinned out of circuit (`NanIsSpanBottom`), and `D6Balance`, +which pins the widest per-side balance the docstring prints to the room at +the cell the walk named (the fix-round-2 docstring, a figure 0.1 dB narrow +and `SideTilt` are red on it). + +The re-audit's fix round 2 (gate audit, re-audit round 1) added three +more, each shown red: `D5SingleRoom`, which pins the single-Room figures +the docstring prints as floors to their named cells (the `1c9308b` words +"up to about 22 %" and a figure half a point off are red on it); +`D6OneSided`, which reads the one-sided example at the Rooms it names (red +with its seed 36 changed to 1); and a `set_macro` leg for D5's Predelay +control (`PredelayMidiSquared`, which passed the constructor-only test). +`D6Balance` now wants the class summary's floor with its rates. + +The re-audit at audiodsp v0.6.3rc2 moved the class to the fixed +convolution node (audiodsp#165), and five tests that pinned a defect or a +figure of the old node went red there. Each is restated to assert the good +behaviour and shown red on a planted copy of the old one: the two room-move +tests on `ResetOnMove` (the node cleared after every re-synthesis, which +renders the v0.6.3rc1 node's bytes); `D6Balance` and `D6OneSided`, which +now pin each side's level (D6's clause 4, dossier section 3.6), on +`SideTilt`, `SideNudge` and the v0.6.2 words; and `D5SingleRoom` on the +v0.6.2 stereo figures. The reset test now covers the plain defaults too, +where `NoReset` was inert while the node emptied itself on a re-synthesis. + +The re-audit's fix round 2 at v0.6.3rc2 pins the room-move paragraph to +the room (`RoomMoveWords`): the jump two moves before one pull make at the +cell the docstring names (red on `OneSynthesisPerBlock`, which gathers +them into one synthesis), the step one move takes on a low sine (red on the +figure moved), and a patch change held to the straight line (red on +`PatchPerKnob`, one synthesis a knob). The `1b3bb94` words, which printed +neither figure, are red on the first two. + +The re-audit round 2 at v0.6.3rc2 parked the class on three of those +sentences, and re-audit fix round 1 after it restates the paragraph as a +rule with no mechanism in it: what holds after any number of moves, the two +conditions for the straight line (one room change between two pulls, and a +source that has not run dry part-way through a block since the last +`reset()`), and that anything else can jump. `RoomMoveWords` now reads each +of its sentences: the rule's legs (`OneSynthesisPerBlock` red on the pairs, +`RetryOnEmpty`, which never lets the node see the empty buffer, red on the +under-run), the dry wire after every pair and an under-run (`ResetOnMove`), +the Mix sentence (`MixOnePullLate`) and the reset sentence (`NoReset`). The +`cf17a88` words are red on all seven. + +The audit after that round parked the class on four sentences that said +more than the node does with a source that comes up short, and re-audit +fix round 2 after the re-audit round 2 restates them, each read by a test +that is red on the `8a57282` words and on a plant: coming up short is an +empty buffer, one shorter than a frame or an error result +(`test_every_short_read_part_way_counts_as_coming_up_short`, `RetryOnShort`; +`RetryOnEmpty` is the clean class on the byte and error legs); a pull that +comes up short before it has a frame is 256 frames of silence and moves +the rest 256 frames later, and an error result's frames and a part frame +never reach the node (the dry test's source cases, `ResetOnMove` and +`KeepShortReads`); `reset()` silences `latency_samples` frames, none on the +empty impulse (`ResetSilentOnEmpty`, `NoReset`); and the tail is counted in +the frames the source hands (`test_the_tail_counts_the_frames_the_source_hands`, +`TailTwoShort`). + +The trial of the second process (2026-09-29) made the class docstring the +player's text and tied each of its claims to a test through `CLAIMS` +(`TheClaims`). The module docstring no longer carries figures, so the tests +that parsed it hold their cells as constants here, and the two that pinned +struck figures (the jump two moves make, the step one move takes) are +gone; the fade, jump and short-read mechanisms stay pinned as tests, not +promises. New: a reset while the node holds part of a source buffer keeps +the whole frames it holds (`ReplugOnReset` red), the empty impulse's Mix +and reset do nothing (`ResetSilentOnEmpty` red), a host `reset_buffer` +drops what the node held, and what each knob does. +""" + +import os +import sys +import tempfile +import unittest +import wave +from unittest import mock + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audioconvolve # noqa: E402 +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects.rebuilt import convolutionreverb as rebuilt # noqa: E402 + +VENDOR = "PyDevices" + +ConvolutionReverb = rebuilt.ConvolutionReverb +NAME = "ConvolutionReverb" +RATE = 48000 +RATES = (48000, 44100, 22050) +LATENCY = 256 +DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I, MIX_I = range(6) +GRID = tuple(range(0, 128, 8)) + (127,) + +#: The constructor's defaults as positions, computed here from section 6 +#: (Damping 6 kHz on the 500 Hz-7.5 kHz log law). +DEFAULT_POS = (1.0, float(np.log(12.0) / np.log(15.0)), 0.0, 0.5, 0.0, 0.3) + + +# -------------------------------------------------------------------------- +# Material, rendering, and the laws computed independently of the class +# -------------------------------------------------------------------------- + +def silence(frames, channels=2): + return np.zeros((frames, channels), dtype=np.int16) + + +def click(frames, channels=2, value=32767, at=0): + pcm = silence(frames, channels) + pcm[at, :] = value + return pcm + + +def white(frames, channels=2, peak_dbfs=-12.0, seed=12345): + rng = np.random.RandomState(seed) + peak = 32767.0 * 10 ** (peak_dbfs / 20.0) + return np.round(rng.uniform(-1, 1, (frames, channels)) * peak).astype( + np.int16) + + +def alt_fs(frames, channels=2): + pcm = np.empty((frames, channels), dtype=np.int16) + pcm[0::2] = 32767 + pcm[1::2] = -32768 + return pcm + + +def ramp_fs(channels=2): + """Every int16 value in turn, in every channel.""" + values = (np.arange(65536) - 32768).astype(np.int16) + return np.repeat(values[:, None], channels, axis=1) + + +def switchable(channels=2, rate=RATE): + return probes.SwitchableSource( + probes.ArraySource(silence(256, channels), rate=rate, + channels=channels)) + + +def build(cls=None, rate=RATE, channels=2, **options): + """`cls` around a SwitchableSource, so `run` can hand it any material.""" + cls = cls or ConvolutionReverb + return cls(switchable(channels, rate), sample_rate=rate, **options) + + +def run(effect, pcm, frames=None): + """Swap `pcm` in behind `effect`, empty the node, pull `frames` frames + (default: len(pcm)) and return them as (frames, channels) int16.""" + channels = effect.channel_count + pcm = np.asarray(pcm, dtype=np.int16).reshape(-1, channels) + total = pcm.shape[0] if frames is None else frames + if pcm.shape[0] < total: + pcm = np.vstack([pcm, silence(total - pcm.shape[0], channels)]) + effect._source.swap(probes.ArraySource(pcm, rate=effect.sample_rate, + channels=channels)) + audiocore.reset_buffer(effect.node) + out = bytearray() + want = total * channels * 2 + while len(out) < want: + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + break + out += data + return np.frombuffer(bytes(out[:want]), dtype=np.int16).reshape( + -1, channels) + + +def at_mix(effect, midi, pcm, frames=None): + """Render at Mix `midi`, then put Mix back where it was.""" + before = effect.get_macro(MIX_I) + effect.set_macro(MIX_I, midi) + try: + return run(effect, pcm, frames) + finally: + effect.set_macro(MIX_I, before) + + +def digest(pcm): + return np.asarray(pcm, dtype=np.int16).tobytes() + + +def first_arrival(out): + hits = np.nonzero(np.any(out != 0, axis=1))[0] + return int(hits[0]) if len(hits) else None + + +def law_t60(decay_pos, predelay_pos, seconds): + """Section 6: T60 = 50 ms * ((S - P) / 50 ms) ** position.""" + predelay = predelay_pos * min(200.0, (seconds - 0.05) * 1000.0 / 2.0) + room = seconds - predelay / 1000.0 + return 0.05 * (room / 0.05) ** decay_pos + + +#: Each shipped patch's Decay and Predelay MIDI, copied from dossier +#: section 6's patch table, so a law over a patch never reads the class. +PATCH_DECAY_PREDELAY_MIDI = { + 0: (127, 0), 1: (38, 13), 2: (127, 76), 3: (102, 25), + 4: (102, 0), 5: (127, 38), 6: (0, 25), 7: (127, 0), +} + + +def handed_build(cls=None, rate=RATE, channels=2, **options): + """`build`, keeping `effect.handed`: the [Decay, Predelay] positions + this test handed the instance, from the constructor's arguments, every + `set_macro` (MIDI / 127, both macros UNIPOLAR) and every + `program_change` (the dossier's patch MIDI). D5's law takes these, + never `effect._macros` or `get_macro` (fix round 2): a class that held + a position other than the one it was handed would move a law read back + from it (DecayKeptSquared, DecayMidiSquared, below).""" + effect = build(cls, rate, channels, **options) + handed = [float(options.get("decay", 1.0)), + float(options.get("predelay", 0.0))] + if options.get("patch") is not None: + handed[:] = [m / 127.0 for m in + PATCH_DECAY_PREDELAY_MIDI[options["patch"]]] + set_macro = effect.set_macro + program_change = effect.program_change + + def spy_set(index, value, *args, **kwargs): + set_macro(index, value, *args, **kwargs) + if index == DECAY_I: + handed[0] = value / 127.0 + elif index == PREDELAY_I: + handed[1] = value / 127.0 + + def spy_program(index, *args, **kwargs): + program_change(index, *args, **kwargs) + if index in PATCH_DECAY_PREDELAY_MIDI: + handed[:] = [m / 127.0 for m in PATCH_DECAY_PREDELAY_MIDI[index]] + + effect.set_macro = spy_set + effect.program_change = spy_program + effect.handed = handed + return effect + + +def schroeder_t60(energy, rate): + """T60 from the Schroeder curve of `energy`, fit over -5..-35 dB and + extrapolated to -60 dB; None when the fit region is empty.""" + e = np.asarray(energy, dtype=np.float64) + if not np.any(e): + return None + edc = np.cumsum(e[::-1])[::-1] + edc_db = 10 * np.log10(edc / edc[0] + 1e-300) + idx = np.where((edc_db <= -5.0) & (edc_db >= -35.0))[0] + if len(idx) < 2: + return None + slope, _ = np.polyfit(idx / float(rate), edc_db[idx], 1) + return -60.0 / slope + + +def m5_cell(effect): + """(fitted T60 or None, floor clean): a 0 dBFS click at Mix 2, L^2+R^2.""" + taps = effect.node.taps + out = at_mix(effect, 127, click(LATENCY + taps + 1024, 2)) + ir = out[LATENCY:LATENCY + taps].astype(np.float64) + t60 = schroeder_t60(np.sum(ir ** 2, axis=1), effect.sample_rate) + return t60, not np.any(out[LATENCY + taps:]) + + +def m4_verdict(out, pcm, latency): + head = not np.any(out[:latency]) + same = digest(out[latency:]) == digest(pcm[:len(pcm) - latency]) + return head and same + + +def m4(effect, pcm): + return m4_verdict(at_mix(effect, 0, pcm), pcm, effect.latency_samples) + + +def fftconv(x, h): + n = len(x) + len(h) - 1 + size = 1 << (n - 1).bit_length() + return np.fft.irfft(np.fft.rfft(x, size) * np.fft.rfft(h, size), + size)[:len(x)] + + +def rho(out, pcm, lags): + worst = 0.0 + n = len(out) + size = 1 << (2 * n - 1).bit_length() + for c in range(out.shape[1]): + y = out[:, c].astype(np.float64) + x = pcm[:n, c].astype(np.float64) + r = np.fft.irfft(np.fft.rfft(y, size) * np.conj(np.fft.rfft(x, size)), + size)[:lags + 1] + den = np.sqrt(np.sum(y * y) * np.sum(x * x)) + worst = max(worst, float(np.max(np.abs(r)) / den) if den else 0.0) + return worst + + +def m6_cell(effect, pcm): + """(wet/dry dB after the room has built, rho) at Mix 2.""" + taps = effect.node.taps + out = at_mix(effect, 127, pcm) + start = LATENCY + taps + wet = np.sqrt(np.mean(out[start:].astype(np.float64) ** 2)) + dry = np.sqrt(np.mean(pcm[start - LATENCY:len(pcm) - LATENCY].astype( + np.float64) ** 2)) + level = 20 * np.log10(wet / dry) if wet > 0 else -np.inf + return level, rho(out, pcm, LATENCY + taps) + + +def balance(effect): + """(L - R dB, pooled dB) of the room's own impulse: a 0 dBFS click at + Mix 2, each side's energy summed over the loaded taps. It is what white + noise reads per side in expectation (D6's Not claimed line; re-audit + fix round 1). Mix is put back after.""" + taps = effect.node.taps + out = at_mix(effect, 127, click(LATENCY + taps + 256, 2)) + ir = out[LATENCY:LATENCY + taps].astype(np.float64) / 32767.0 + energy = np.sum(ir * ir, axis=0) + return (float(10 * np.log10(energy[0] / energy[1])), + float(10 * np.log10(np.mean(energy)))) + + +# -- D1's impulse, trim and gain ------------------------------------------ + +def make_impulse(taps=3840, channels=1, seed=7): + rng = np.random.RandomState(seed) + env = 3000.0 * np.exp(-np.arange(taps) / (0.25 * taps)) + return np.round(env[:, None] * rng.uniform(-1, 1, (taps, channels)) + ).astype(np.int16) + + +def trim_frames(start_ms, rate): + return int(start_ms * rate / 1000.0) + + +def unit_gain(h, room_channels, gain_db): + kept = h[:, :room_channels].astype(np.float64) / 32768.0 + return 10 ** (gain_db / 20.0) / np.sqrt(np.sum(kept ** 2) + / room_channels) + + +def m1_error(cls, rate, ir_channels, channels, kind, target, gain_db=0.0, + start_ms=0.0, mix_midi=127, h_full=None): + """M1's peak error at Mix `mix_midi` through the Mix law, or None when + the input would have to leave int16 to put the reference at `target`. + At Mix 2 (127) this is M1 exactly.""" + if h_full is None: + h_full = make_impulse(3840, ir_channels) + h = h_full[trim_frames(start_ms, rate):] + room = min(ir_channels, channels) + g = unit_gain(h, room, gain_db) + hf = h.astype(np.float64) * g / 32768.0 + frames = len(h) + rate // 4 + if kind == "sine": + t = np.arange(frames) / float(rate) + unit = np.repeat(np.sin(2 * np.pi * 997.0 * t)[:, None], channels, 1) + else: + unit = np.random.RandomState(12345).uniform(-1, 1, (frames, channels)) + mix = 2.0 * mix_midi / 127.0 + dry, wet = min(2.0 - mix, 1.0), min(mix, 1.0) + + def reference(x): + return np.stack([dry * x[:, c] + wet * fftconv( + x[:, c], hf[:, c if room == 2 else 0]) for c in range(channels)], + axis=1) + + ref_unit = reference(unit) + x = np.round(unit * target / np.max(np.abs(ref_unit))) + if np.max(np.abs(x)) > 32767: + return None + x = x.astype(np.int16) + ref = reference(x.astype(np.float64)) + assert np.max(np.abs(ref)) <= 32767 + effect = build(cls, rate, channels, impulse=h_full.reshape(-1).tobytes(), + impulse_channels=ir_channels, ir_gain_db=gain_db, + start_ms=start_ms, mix=mix) + try: + out = run(effect, x, frames + LATENCY) + finally: + effect.deinit() + return float(np.max(np.abs(out[LATENCY:].astype(np.float64) - ref))) + + +# -------------------------------------------------------------------------- +# The planted faults. Each is something the class or the kit can build; +# none reaches inside the C node. +# -------------------------------------------------------------------------- + +class TrimLate(ConvolutionReverb): + """D1: the trim one frame late - at start_ms 0 it drops the first tap.""" + + NAME = NAME + + def _trim_frames(self, start_ms): + return ConvolutionReverb._trim_frames(self, start_ms) + 1 + + +class TrimRounded(ConvolutionReverb): + """D1's control for the truncation clause: the trim rounded to the + nearest frame. At 0 and 10 ms the two agree, so only an interior trim + (41.7 ms at 48 kHz, 3.3 ms at 44.1 and 22.05 kHz) can see it.""" + + NAME = NAME + + def _trim_frames(self, start_ms): + return int(round(start_ms * self._sample_rate / 1000.0)) + + +class ExtraPartition(ConvolutionReverb): + """D2: one partition too many.""" + + NAME = NAME + + def _partitions(self, taps): + return ConvolutionReverb._partitions(self, taps) + 1 + + +class NoChecks(ConvolutionReverb): + """D2's null: the class with its allocation checks removed.""" + + NAME = NAME + + def _check_allocation(self, taps, seconds=None): + return None + + +class ZeroLatency(ConvolutionReverb): + """D3: `latency_samples` hard-wired 0, the old class's own defect.""" + + NAME = NAME + + @property + def latency_samples(self): + self._check_live() + return 0 + + +class _After(ConvolutionReverb): + """A kit fault node after the convolver.""" + + NAME = NAME + + def _fault(self, node): + raise NotImplementedError + + def _build(self, *arguments, **options): + ConvolutionReverb._build(self, *arguments, **options) + self._planted = self._fault(self._node) + self._output = self._planted + + +class OneLsbAfter(_After): + """D4: `kit_faults.OneLsbScale` after the node.""" + + NAME = NAME + + def _fault(self, node): + return kit_faults.OneLsbScale(node) + + +class StuckDcAfter(_After): + """D5 (2): `kit_faults.StuckDc` after the node.""" + + NAME = NAME + + def _fault(self, node): + return kit_faults.StuckDc(node) + + +class UnnormalisedAfter(_After): + """D6: `kit_faults.HiddenGain` at 10 log10(T60 / 50 ms) dB, the level an + unnormalised room would have, which tracks the decay. The gain follows + every re-synthesis, so a host walking Decay by `set_macro` sees it move + (fix round 1: built once, it held the constructor's gain and a walk read + a 0.010 dB spread).""" + + NAME = NAME + + def _gain_db(self): + return 10.0 * np.log10(self._synthesis()[0] / 0.05) + + def _fault(self, node): + return kit_faults.HiddenGain(node, self._gain_db()) + + def _refresh(self): + ConvolutionReverb._refresh(self) + planted = getattr(self, "_planted", None) + if planted is not None: + planted.gain_db = self._gain_db() + planted._gain = 10.0 ** (planted.gain_db / 20.0) + + +class HotRoom(_After): + """D6's absolute clause: a room a constant +3 dB hot at every setting. + The spread and rho clauses both pass it; only |wet/dry| <= 0.5 dB sees + it.""" + + NAME = NAME + GAIN_DB = 3.0 + + def _fault(self, node): + return kit_faults.HiddenGain(node, self.GAIN_DB) + + +class _LeftGain(kit_faults.HiddenGain): + """`kit_faults.HiddenGain` on the left channel only.""" + + def _process(self, frames): + out = frames.copy() + out[0::self.channel_count] *= self._gain + return out + + +class SideTilt(_After): + """D6's balance disclosure: the left side 0.5 dB hot after the node, a + balance fault the pooled clauses barely see (+0.25 dB).""" + + NAME = NAME + GAIN_DB = 0.5 + + def _fault(self, node): + return _LeftGain(node, self.GAIN_DB) + + +class SideNudge(SideTilt): + """D6 (4)'s fine control: the left side 0.05 dB hot after the node, ten + times narrower than SideTilt and five times the 0.01 dB bar (re-audit + fix round 1, audiodsp v0.6.3rc2).""" + + NAME = NAME + GAIN_DB = 0.05 + + +class ResetOnMove(ConvolutionReverb): + """The v0.6.3rc1 node's room move, planted from the class side + (re-audit fix round 1, audiodsp#163): the node cleared after every + re-synthesis on a playing node, so the tail stops dead and the 256 + frames in flight come out as exact zero, dry included, at every Mix. + `clear()` drops the history and the block in flight, which is what the + old node's `synthesize()` ended in; the pack shows this plant renders + the old node's bytes.""" + + NAME = NAME + + def _refresh(self): + before = self._loaded + ConvolutionReverb._refresh(self) + if before is not None and self._loaded != before: + self._node.clear() + + +class AllocatedSeconds(ConvolutionReverb): + """D5's control for a law that shares the class's inputs: the class + keeps the allocation it holds (partitions x 256 / fs) as `seconds`, so + every law over the allocation stretches to the partition edge. A law + read from `effect.seconds` moves with it and stays green; a law from + the seconds the test handed the constructor goes red (at 0.06 s: 64-Room + mean +6.62 / +6.39 / +16.18 % at 48 / 44.1 / 22.05 kHz, audit round 1). + It is inert at 0.08 s at 48 kHz, where 3 840 taps is a whole number of + partitions.""" + + NAME = NAME + + def _check_allocation(self, taps, seconds=None): + ConvolutionReverb._check_allocation(self, taps, seconds) + if seconds is not None: + self._stretched = (self._partitions(taps) * 256 + / float(self._sample_rate)) + + def _refresh(self): + stretched = getattr(self, "_stretched", None) + if stretched is not None and not self._measured: + self._seconds = stretched + ConvolutionReverb._refresh(self) + + +class DecayKeptSquared(ConvolutionReverb): + """D5's control for a law read back off the class's positions (fix + round 2): the constructor's Decay kept squared. 0 and 1 are fixed + points, so it is inert at Decay 1.0 and visible at 64/127: the + re-refuter read 1.907 % on a law from `effect._macros` (green) and + -12.781 % on the handed law at 48 kHz (-13.147 % at 22.05 kHz).""" + + NAME = NAME + + def _init_macros(self, values, patch=None): + values = list(values) + values[DECAY_I] = values[DECAY_I] ** 2 + ConvolutionReverb._init_macros(self, tuple(values), patch) + + +class DecayMidiSquared(ConvolutionReverb): + """The same control through `set_macro`: a Decay move is kept + squared.""" + + NAME = NAME + + def set_macro(self, index, value, channel=0, note_id=-1, + sample_position=0): + ConvolutionReverb.set_macro(self, index, value, channel, note_id, + sample_position) + if index == DECAY_I: + self._macros[DECAY_I] = self._macros[DECAY_I] ** 2 + self._apply_macro(DECAY_I, self._macros[DECAY_I]) + + +class PredelayKeptSquared(ConvolutionReverb): + """D5's Predelay control (re-audit fix round 1, from the round-3 + audit's item 3): the constructor's Predelay kept squared. It is + Predelay's shape of DecayKeptSquared, and it passed every D5 test + before this one. At Predelay 64/127, Decay 127, Damping out, Diffusion + 0, at audiodsp v0.6.3rc2 (re-audit fix round 2 there), it reads worst + +6.540 % (mean +5.194 %) on the handed law at 48 kHz and +7.214 % + (+5.197 %) at 22.05 kHz, and +1.296 / -2.360 % on a law read back off + the class; the clean class reads +1.376 / +2.322 %. (At v0.6.2, before + each side of a stereo room was scaled on its own: +6.518 / +7.244 %, + clean +1.375 / +2.347 %.)""" + + NAME = NAME + + def _init_macros(self, values, patch=None): + values = list(values) + values[PREDELAY_I] = values[PREDELAY_I] ** 2 + ConvolutionReverb._init_macros(self, tuple(values), patch) + + +class PredelayMidiSquared(ConvolutionReverb): + """The same control through `set_macro` (re-audit fix round 2, from + the re-audit round-1 audit's item 5): a Predelay move is kept squared, + the constructor's Predelay held exactly. It passed the Predelay test + while that test's clean leg went through the constructor. At + `set_macro` Predelay 64, Decay 127, Damping out, Diffusion 0 it reads + what PredelayKeptSquared does at audiodsp v0.6.3rc2: worst +6.540 % on + the handed law at 48 kHz and +7.214 % at 22.05 kHz, clean +1.376 / + +2.322 % by this route too (v0.6.2: +6.518 / +7.244 %).""" + + NAME = NAME + + def set_macro(self, index, value, channel=0, note_id=-1, + sample_position=0): + ConvolutionReverb.set_macro(self, index, value, channel, note_id, + sample_position) + if index == PREDELAY_I: + self._macros[PREDELAY_I] = self._macros[PREDELAY_I] ** 2 + self._apply_macro(PREDELAY_I, self._macros[PREDELAY_I]) + + +class NanIsSpanBottom(ConvolutionReverb): + """The damping pin's control: a NaN `damping_hz` taken as the 500 Hz + stop instead of out of circuit, which is what the docstring said of + NaN before it named it.""" + + NAME = NAME + + def _build(self, *arguments, **options): + hz = options.get("damping_hz", 6000.0) + if hz != hz: + options["damping_hz"] = 100.0 + ConvolutionReverb._build(self, *arguments, **options) + + +class LongDecay(ConvolutionReverb): + """D5 (1): the decay handed to the node 5 % long against the law.""" + + NAME = NAME + + def _synthesis(self): + room = ConvolutionReverb._synthesis(self) + return (room[0] * 1.05,) + tuple(room[1:]) + + +class NoReset(ConvolutionReverb): + """Tier 1 reset: `reset()` that leaves the history in the node.""" + + NAME = NAME + + def reset(self): + self._check_live() + self.program_change(0) + + +class _PullHook(kit_faults.HiddenGain): + """A 0 dB `HiddenGain` (a gain of exactly 1.0, so every frame passes + unchanged) that tells its owner a pull is about to happen.""" + + def __init__(self, source, owner): + kit_faults.HiddenGain.__init__(self, source, 0.0) + self._owner = owner + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self._owner._flush() + return kit_faults.HiddenGain._get_buffer( + self, single_channel_output, audio_channel) + + +class OneSynthesisPerBlock(ConvolutionReverb): + """The two-move test's control (re-audit fix round 2 at audiodsp + v0.6.3rc2): room moves that land between two pulls are gathered into + one synthesis at the next pull. It is what the class would do with a + hook at the block edge, and what a node that kept fading from the room + that played would render (the node ask): the block starts on that + room, not on the room before the last move, which is where the node at + v0.6.3rc2 starts it. Two or more changes before one pull then read like + one, on the straight line from the old room to the new, and the + docstring's jump is gone (the rule test's pairs too).""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._hooked = False + ConvolutionReverb._build(self, *arguments, **options) + self._pending = False + self._output = _PullHook(self._node, self) + self._hooked = True + + def _refresh(self): + if not self._hooked: + ConvolutionReverb._refresh(self) + return + self._pending = True + self._node.set(mix=self._value(MIX_I) * 0.5) + + def _flush(self): + if self._pending: + self._pending = False + ConvolutionReverb._refresh(self) + + +class PatchPerKnob(ConvolutionReverb): + """The patch-change test's control: a patch change after construction + applied one knob at a time, one synthesis per room knob, as five moves + before one pull.""" + + NAME = NAME + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + patch = type(self).PATCHES.get(index) + if patch is None or self._deferred: + ConvolutionReverb.program_change(self, index, channel, note_id, + sample_position) + return + for macro, value in enumerate(patch[1]): + self.set_macro(macro, value) + + +class _RetrySource: + """The class's source behind a proxy that never hands the node an + empty buffer: an empty read is followed at once by another.""" + + def __init__(self, inner): + self.inner = inner + for name in ("sample_rate", "channel_count", "bits_per_sample", + "samples_signed"): + setattr(self, name, getattr(inner, name)) + + def swap(self, source): + self.inner.swap(source) + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + self.inner._reset_buffer(single_channel_output, audio_channel) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + for _ in range(8): + result, data = self.inner._get_buffer(single_channel_output, + audio_channel) + if len(data): + break + return result, data + + +class RetryOnEmpty(ConvolutionReverb): + """The under-run leg's control (re-audit fix round 1 after the re-audit + round 2 at audiodsp v0.6.3rc2): the class pulls again when its source + hands back an empty buffer, so the node never returns a short block and + stays at its block edge. A move after an under-run then fades over the + whole block, as if the source had never run dry.""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._source = _RetrySource(self._source) + ConvolutionReverb._build(self, *arguments, **options) + + +class _RetryShortSource(_RetrySource): + """`_RetrySource` for every short read the binding stops on: an error + result, or a buffer shorter than one frame.""" + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + width = 2 * self.channel_count + for _ in range(8): + result, data = self.inner._get_buffer(single_channel_output, + audio_channel) + if result != audiocore.GET_BUFFER_ERROR and len(data) >= width: + break + return result, data + + +class RetryOnShort(ConvolutionReverb): + """The short-read legs' control (re-audit fix round 2 after the re-audit + round 2 at audiodsp v0.6.3rc2): the class pulls again on every short + read the binding stops on (`Convolver.c:278` at 0d35a90), an empty + buffer, one shorter than a frame or an error result, so the node never + returns a short block. A move after any of them then fades over the + whole block. `RetryOnEmpty`, which tests only for an empty buffer, is + the clean class on the byte and error legs.""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._source = _RetryShortSource(self._source) + ConvolutionReverb._build(self, *arguments, **options) + + +class _KeepShortSource(_RetrySource): + """The class's source behind a proxy that hands the node what the + binding would drop: an error result's frames as plain data, and a + part frame at the end of a buffer padded with zero bytes to a whole + frame.""" + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + result, data = self.inner._get_buffer(single_channel_output, + audio_channel) + data = bytes(data) + width = 2 * self.channel_count + if len(data) > width and len(data) % width: + data += bytes(width - len(data) % width) + if result == audiocore.GET_BUFFER_ERROR: + result = audiocore.GET_BUFFER_MORE_DATA + return result, memoryview(data) + + +class KeepShortReads(ConvolutionReverb): + """The whole-frames sentence's control: the part frame and the error + result's frames reach the node (`_KeepShortSource`).""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._source = _KeepShortSource(self._source) + ConvolutionReverb._build(self, *arguments, **options) + + +class _SilentBlock(kit_faults.HiddenGain): + """A 0 dB pass-through that, once armed, answers one pull with a + 256-frame block of silence without pulling what is behind it.""" + + def __init__(self, source): + kit_faults.HiddenGain.__init__(self, source, 0.0) + self.armed = False + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + if self.armed: + self.armed = False + return (audiocore.GET_BUFFER_MORE_DATA, + memoryview(bytes(256 * 2 * self.channel_count))) + return kit_faults.HiddenGain._get_buffer( + self, single_channel_output, audio_channel) + + +class ResetSilentOnEmpty(ConvolutionReverb): + """The empty-impulse reset leg's control: on the empty impulse, + `reset()` plays a 256-frame block of silence, which is what "the next + 256 frames come out as exact zero" (the `8a57282` words) promised + there too. With a room loaded it is the clean class.""" + + NAME = NAME + + def _build(self, *arguments, **options): + ConvolutionReverb._build(self, *arguments, **options) + self._silent = _SilentBlock(self._node) + self._output = self._silent + + def reset(self): + ConvolutionReverb.reset(self) + self._silent.armed = not int(self._node.taps) + + +class TailTwoShort(ConvolutionReverb): + """The tail leg's control: `tail_samples` two frames short.""" + + NAME = NAME + + @property + def tail_samples(self): + self._check_live() + taps = int(self._node.taps) + return (self._latency() + taps if taps else 0) - 2 + + +class MixOnePullLate(ConvolutionReverb): + """The Mix sentence's control: a Mix move handed to the node one pull + late, so 512 frames come out at the old Mix, not 256.""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._hooked = False + ConvolutionReverb._build(self, *arguments, **options) + self._mix_wait = 0 + self._output = _PullHook(self._node, self) + self._hooked = True + + def _apply_macro(self, index, position): + if self._hooked and not self._deferred and index == MIX_I: + self._mix_wait = 2 + return + ConvolutionReverb._apply_macro(self, index, position) + + def _flush(self): + if self._mix_wait: + self._mix_wait -= 1 + if not self._mix_wait: + self._node.set(mix=self._value(MIX_I) * 0.5) + + +class ReplugOnReset(ConvolutionReverb): + """The reset-mid-buffer leg's control (the trial of the second process, + 2026-09-29): `reset()` plugs the source back into the node, which drops + the frames the node held of a source buffer it had not finished.""" + + NAME = NAME + + def reset(self): + ConvolutionReverb.reset(self) + self._node.play(self._source) + + +def reach(faulted, reading, tolerance=0.0, rate=RATE, channels=2, + builder=build): + return kit_faults.fault_reachability( + ConvolutionReverb, faulted, reading, + lambda cls: builder(cls, rate, channels), tolerance=tolerance) + + +WALKED = 6 * len(GRID) + 8 + + +# -------------------------------------------------------------------------- +# The surface +# -------------------------------------------------------------------------- + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_capabilities(self): + cls = ConvolutionReverb + self.assertEqual(cls.MACRO_LABELS, ("Decay", "Damping", "Predelay", + "Diffusion", "Room", "Mix")) + self.assertEqual(len(cls.PATCHES), 8) + self.assertEqual(cls.CAPABILITIES, ()) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioconvolve",)) + effect = build() + self.assertEqual(effect.capabilities, ()) + self.assertEqual(effect.patch_index, 0) + self.assertEqual(effect.live_macros, (0, 1, 2, 3, 4, 5)) + self.assertFalse(effect.measured) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + effect.deinit() + + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects + from audioeffects import rebuilt as registry + self.assertIs(registry.module_class(NAME), ConvolutionReverb) + self.assertIn(NAME, registry.ADOPTED) + self.assertNotIn(NAME, registry.parked()) + self.assertIs(audioeffects.ConvolutionReverb, ConvolutionReverb) + served = audioeffects.create(NAME, switchable(), RATE) + self.assertIsInstance(served, ConvolutionReverb) + served.deinit() + + def test_patch_0_is_the_constructor_grid(self): + effect = build() + grid = ConvolutionReverb.PATCHES[0][1] + for index, expected in enumerate(grid): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + effect.deinit() + + def test_patch_table_is_macro_of_the_dossier_settings(self): + spans = ConvolutionReverb._MACRO_RANGES + settings = { + 0: (1.0, 6000.0, 0.0, 0.5, 1, 0.6), + 1: (0.3, 4000.0, 0.1, 0.5, 3, 0.7), + 2: (1.0, 7000.0, 0.6, 0.5, 7, 0.8), + 3: (0.8, 1500.0, 0.2, 0.5, 11, 0.7), + 4: (0.8, 7500.0, 0.0, 0.25, 5, 0.8), + 5: (1.0, 5000.0, 0.3, 1.0, 13, 0.99), + 6: (0.0, 7000.0, 0.2, 0.0, 2, 1.2), + 7: (1.0, 6000.0, 0.0, 0.5, 1, 2.0), + } + for index, values in settings.items(): + grid = tuple(_component.macro_of(span, value) + for span, value in zip(spans, values)) + self.assertEqual(ConvolutionReverb.PATCHES[index][1], grid, + index) + + def test_the_laws_at_the_defaults(self): + effect = build() + room = effect._synthesis() + self.assertAlmostEqual(room[0], 0.08, places=12) # T60 + self.assertAlmostEqual(room[1], 6000.0, places=6) # Damping + self.assertEqual(room[2], 0.0) # Predelay + self.assertAlmostEqual(room[3], 10.0, places=9) # Diffusion + self.assertEqual(room[4], 1) # Room seed + self.assertAlmostEqual(effect.decay_seconds, 0.08, places=12) + self.assertEqual(effect.seconds, 0.08) + self.assertAlmostEqual(effect.allocated_seconds, 0.08, places=12) + effect.set_macro(DAMPING_I, 127) + self.assertEqual(effect._synthesis()[1], 0.0) # out + effect.set_macro(DAMPING_I, 126) + self.assertAlmostEqual(effect._synthesis()[1], + 500.0 * 15.0 ** (126 / 127.0), places=6) + effect.set_macro(PREDELAY_I, 127) + self.assertAlmostEqual(effect._synthesis()[2], 15.0, places=9) + effect.set_macro(ROOM_I, 127) + self.assertEqual(effect._synthesis()[4], 64) + effect.deinit() + + def test_damping_clamps_at_the_rate(self): + # 0.159 fs: at 22.05 kHz the default is the 3 506 Hz clamp, and + # every position from 92 to 126 is that one room (the room's own + # impulse, compared byte for byte); at 48 kHz no two positions are. + effect = build(rate=22050) + self.assertAlmostEqual(effect._synthesis()[1], 0.159 * 22050, + places=6) + pulse = click(LATENCY + effect.node.taps + 256) + rooms = {} + for midi in range(88, 127): + effect.set_macro(DAMPING_I, midi) + rooms[midi] = digest(at_mix(effect, 127, pulse)) + effect.deinit() + self.assertEqual(len(set(rooms[m] for m in range(92, 127))), 1) + self.assertNotEqual(rooms[91], rooms[92]) + effect = build() + pulse = click(LATENCY + effect.node.taps + 256) + seen = set() + for midi in range(0, 127): + effect.set_macro(DAMPING_I, midi) + seen.add(digest(at_mix(effect, 127, pulse))) + effect.deinit() + self.assertEqual(len(seen), 127) + + def test_measured_mode_refuses_the_synthesis_macros(self): + effect = build(impulse=make_impulse().tobytes()) + self.assertTrue(effect.measured) + self.assertEqual(effect.live_macros, (5,)) + self.assertIsNone(effect.decay_seconds) + for index in range(5): + with self.assertRaises(IndexError) as caught: + effect.set_macro(index, 10) + self.assertIn(NAME, str(caught.exception)) + self.assertIn(ConvolutionReverb.MACRO_LABELS[index], + str(caught.exception)) + with self.assertRaises(IndexError): + effect.get_macro(index) + effect.set_macro(MIX_I, 127) + self.assertEqual(effect.get_macro(MIX_I), 127) + with self.assertRaises(IndexError): + effect.set_macro(6, 0) + # A patch sets Mix and leaves the synthesis positions inert. + with mock.patch.object(audioconvolve.Convolver, "synthesize") as s: + effect.program_change(6) + self.assertEqual(s.call_count, 0) + self.assertAlmostEqual(effect.get_macro(MIX_I), 76, places=9) + self.assertEqual(effect.patch_index, 6) + effect.deinit() + + def test_impulse_level_and_shape_are_checked(self): + with self.assertRaises(ValueError): + build(impulse=np.zeros(512, dtype=np.int16).tobytes()) + with self.assertRaises(ValueError): + build(impulse=b"\x00\x00\x00") + with self.assertRaises(ValueError): + build(impulse=make_impulse().tobytes(), ir_gain_db=12.5) + with self.assertRaises(ValueError): + build(impulse=make_impulse().tobytes(), ir_gain_db=-24.5) + with self.assertRaises(ValueError): + build(impulse=make_impulse().tobytes(), start_ms=-0.5) + for gain_db, start_ms in ((-24.0, 0.0), (12.0, 200.0)): + build(impulse=make_impulse(12000).tobytes(), ir_gain_db=gain_db, + start_ms=start_ms).deinit() + with self.assertRaises(ValueError): + build(impulse=make_impulse().tobytes(), start_ms=200.5) + + def test_a_trim_past_the_impulse_raises(self): + # A clamped trim would build the unloaded wire, whose Mix does + # nothing, with no error (review probe + # convolutionreverb_review_trimall.py; ruling (o)). 100 frames is + # 2.083 ms at 48 kHz: 2.0 ms trims 96 and builds a 256-tap room. + h = make_impulse(100).tobytes() + effect = build(impulse=h, start_ms=2.0) + self.assertEqual(effect.node.taps, 256) + self.assertEqual(effect.latency_samples, LATENCY) + effect.deinit() + for start_ms, trim in ((2.1, 100), (10.0, 480)): + with self.assertRaises(ValueError) as caught: + build(impulse=h, start_ms=start_ms) + text = str(caught.exception) + for field in (NAME, "start_ms=%r" % start_ms, "%d frames" % trim, + "48000 Hz", "has 100"): + self.assertIn(field, text) + # The deliberate empty room stays the undelayed wire (D3). + effect = build(impulse=b"", start_ms=10.0) + self.assertEqual((effect.node.taps, effect.latency_samples), (0, 0)) + effect.deinit() + + def test_a_two_dimensional_impulse_raises_naming_the_class(self): + # It used to reach the trim's slice and raise Python's bare + # NotImplementedError (surface refuter, item 9). + for start_ms in (0.0, 5.0): + with self.assertRaises(TypeError) as caught: + build(impulse=make_impulse(1000, 2), impulse_channels=2, + start_ms=start_ms) + self.assertIn(NAME, str(caught.exception)) + self.assertIn("one-dimensional", str(caught.exception)) + effect = build(impulse=make_impulse(1000, 2).reshape(-1), + impulse_channels=2) + self.assertEqual(effect.node.taps, 1024) + effect.deinit() + + def test_damping_under_the_span_is_the_span_bottom(self): + # Disclosed in the docstring: no error, the 500 Hz stop. + for hz in (100.0, 499.0, 500.0): + effect = build(damping_hz=hz) + self.assertAlmostEqual(effect._synthesis()[1], 500.0, places=6) + effect.deinit() + # Fix round 2 (gate audit round 2, item 7b): a negative damping_hz + # is out of circuit, like 0, not the 500 Hz stop. + # Re-audit fix round 1 (gate audit round 3, item 6): NaN is out of + # circuit too, and the docstring now says so (NanIsSpanBottom is + # the control, red here). + for hz in (-100.0, 0.0, float("nan")): + effect = build(damping_hz=hz) + self.assertEqual(effect._synthesis()[1], 0.0, hz) + self.assertEqual(effect.get_macro(DAMPING_I), 127, hz) + effect.deinit() + + def test_an_empty_impulse_holds_one_partition(self): + # Fix round 2 (item 7c): impulse=b"" reports taps 0 and latency 0 + # (D3's unloaded wire), and its node is built with one partition, + # so it accepts a 256-frame load. Measured mode's allocation starts + # at one frame; zero frames is this one partition. + effect = build(impulse=b"") + self.assertEqual(effect.node.taps, 0) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.tail_samples, 0) + # Read directly, since `capacity()` starts at one partition. + effect.node.load(bytes(2 * 256), 1, 1.0) + with self.assertRaises(ValueError): + effect.node.load(bytes(2 * 257), 1, 1.0) + effect.deinit() + + def test_an_int16_array_is_trimmed_by_frames(self): + from array import array + h = make_impulse(1000, 2) + as_array = array("h") + as_array.frombytes(h.tobytes()) + one = build(impulse=h.tobytes(), impulse_channels=2, start_ms=5.0) + two = build(impulse=as_array, impulse_channels=2, start_ms=5.0) + pcm = white(4096) + self.assertEqual(digest(run(one, pcm)), digest(run(two, pcm))) + self.assertEqual(one.node.taps, 768) # 1000 - 240 -> 3 x 256 + one.deinit() + two.deinit() + + +class ResynthesisIsDeduplicated(unittest.TestCase): + """Section 4 and 8.6: one synthesis per construction, patch and reset; + none for a move that lands on the room already loaded, or for Mix.""" + + def count(self, action): + with mock.patch.object(audioconvolve.Convolver, "synthesize", + autospec=True, + side_effect=audioconvolve.Convolver.synthesize + ) as spy: + result = action() + return spy.call_count, result + + def test_construction_patch_and_moves(self): + calls, effect = self.count(lambda: build(patch=3)) + self.assertEqual(calls, 1) + self.assertEqual(effect.patch_index, 3) + calls, _ = self.count(lambda: effect.set_macro(MIX_I, 90)) + self.assertEqual(calls, 0) + # Patch 3's own Decay, written back: the room it already holds. + calls, _ = self.count(lambda: effect.set_macro( + DECAY_I, ConvolutionReverb.PATCHES[3][1][DECAY_I])) + self.assertEqual(calls, 0) + calls, _ = self.count(lambda: effect.set_macro(DECAY_I, 10)) + self.assertEqual(calls, 1) + calls, _ = self.count(lambda: effect.program_change(1)) + self.assertEqual(calls, 1) + calls, _ = self.count(lambda: effect.program_change(1)) + self.assertEqual(calls, 0) + calls, _ = self.count(effect.reset) + self.assertEqual(calls, 1) + self.assertEqual(effect.patch_index, 0) + effect.deinit() + + def pull(self, effect, pcm, action=None, block=256, blocks=40): + """`blocks` output pulls over `pcm` from a source handing `block` + frames a call; `action(effect)` runs before pull 10.""" + channels = effect.channel_count + effect._source.swap(probes.ArraySource( + pcm, rate=effect.sample_rate, channels=channels, block=block)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for number in range(blocks): + if number == 10 and action is not None: + action(effect) + out += bytes(audiocore.get_buffer(effect.output)[1]) + return np.frombuffer(bytes(out), dtype=np.int16).reshape( + -1, channels) + + def test_a_room_move_keeps_the_room_ringing_and_lands_on_the_new_room( + self, cls=None): + # Restated at audiodsp v0.6.3rc2 (audiodsp#163; re-audit fix round + # 1). Up to v0.6.3rc1 the node emptied itself on a re-synthesis, so + # a tail ringing at a room-knob move stopped dead; this test pinned + # that (13 561 LSB where it asserted 0 at the fix). Now: before the + # block in flight the output is the old room's; the block in flight + # (frames 2 560..2 815 for a move before pull 10) runs in a straight + # line from the old room's frames to the new room's, within 1 LSB; + # from the next block on it is, byte for byte, an instance that had + # the new room from the start; and the tail rings on across the + # move. ResetOnMove (the old node's behaviour, planted) is red. + moves = ((DECAY_I, 64), (DAMPING_I, 30), (PREDELAY_I, 40), + (DIFFUSION_I, 100), (ROOM_I, 50)) + k = (np.arange(1, 257, dtype=np.float64) / 256.0)[:, None] + for rate in RATES: + for channels in (2, 1): + burst = np.vstack([white(8 * 256 + 37, channels, + peak_dbfs=-12.0), + silence(32 * 256, channels)]) + for mix in (2.0, 0.6): + old = self.pull(build(cls, rate, channels, mix=mix), + burst) + for index, value in moves: + fresh = build(cls, rate, channels, mix=mix) + fresh.set_macro(index, value) + new = self.pull(fresh, burst) + fresh.deinit() + effect = build(cls, rate, channels, mix=mix) + out = self.pull(effect, burst, + lambda e: e.set_macro(index, value)) + effect.deinit() + label = (rate, channels, mix, index) + self.assertEqual(digest(out[:2560]), + digest(old[:2560]), label) + self.assertEqual(digest(out[2816:]), + digest(new[2816:]), label) + line = old[2560:2816] + k * ( + new[2560:2816].astype(np.float64) + - old[2560:2816]) + # The node fades in float and clips after, so a + # sample at full scale in either room is off the + # line by design; at -12 dBFS none is. + self.assertLess(int(max(np.max(np.abs(old)), + np.max(np.abs(new)))), + 32767, label) + self.assertLessEqual( + float(np.max(np.abs(out[2560:2816] - line))), + 1.0, label) + self.assertGreater( + int(np.max(np.abs(out[2560:4096]))), 1000, label) + + def test_a_room_move_drops_no_dry_frame(self, cls=None): + # Restated at audiodsp v0.6.3rc2 (audiodsp#163; re-audit fix round + # 1). Up to v0.6.3rc1 the reset at the end of the node's + # synthesize() zeroed the frames in flight too, so at Mix 0 the + # source frames 2 304..2 559 came out as exact zero at a move + # before pull 10, and this test pinned that gap. Now Mix 0 is the + # source delayed by `latency_samples`, byte for byte, across every + # room knob's move, at three rates, stereo and mono, from a source + # in 256-frame blocks and from one in 100-frame blocks; so is a + # move onto the room already loaded, and a Mix move that stays at + # 0. ResetOnMove is red. + frames = 40 * 256 + moves = ((DECAY_I, 64), (DAMPING_I, 30), (PREDELAY_I, 40), + (DIFFUSION_I, 100), (ROOM_I, 50), (DECAY_I, None), + (MIX_I, 0)) + for rate in RATES: + for channels in (2, 1): + pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype( + np.int16) + pcm = np.repeat(pcm[:, None], channels, axis=1) + want = np.vstack([silence(LATENCY, channels), + pcm[:frames - LATENCY]]) + self.assertTrue(np.any(pcm[2304:2560] != 0)) + for block in (256, 100): + for index, value in moves: + effect = build(cls, rate, channels, mix=0.0) + out = self.pull( + effect, pcm, + lambda e: e.set_macro(index, e.get_macro(index) + if value is None + else value), + block=block) + effect.deinit() + self.assertEqual( + digest(out), digest(want), + (rate, channels, block, index, value)) + + def test_a_mix_move_lands_a_partition_late_and_reset_drops_one(self): + # The docstring's mid-stream lines (fix round 1, audit item 7): a + # Mix move before block 10 leaves the 256 frames in flight + # (2 560..2 815) at the old Mix and the wire exact after; reset() + # mid-stream zeroes that partition, dry included. + frames = 32 * 256 + pcm = white(frames, peak_dbfs=-1.0) + wire = np.vstack([silence(LATENCY), pcm[:frames - LATENCY]]) + + def pull(effect, action): + effect._source.swap(probes.ArraySource(pcm, rate=RATE, + channels=2)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for block in range(32): + if block == 10: + action(effect) + out += bytes(audiocore.get_buffer(effect.output)[1]) + return np.frombuffer(bytes(out), dtype=np.int16).reshape(-1, 2) + + effect = build() + out = pull(effect, lambda e: e.set_macro(MIX_I, 0)) + effect.deinit() + late = np.any(out != wire, axis=1) + self.assertEqual(int(np.sum(late[2560:])), 256) + self.assertTrue(np.all(late[2560:2816])) + effect = build(mix=0.0) + + def reset_then_wire(e): + e.reset() + e.set_macro(MIX_I, 0) + + out = pull(effect, reset_then_wire) + effect.deinit() + self.assertEqual(int(np.max(np.abs(out[2560:2816]))), 0) + self.assertEqual(digest(out[2816:]), digest(wire[2816:])) + + +#: The pull the moves land before, and the block in flight it plays. +MOVE_AT = 10 +BLOCK_IN_FLIGHT = slice(MOVE_AT * 256, MOVE_AT * 256 + 256) + + +def sine(frames, channels, hz, rate, peak): + t = np.arange(frames) / float(rate) + x = np.round(peak * np.sin(2 * np.pi * hz * t)).astype(np.int16) + return np.repeat(x[:, None], channels, axis=1) + + +def pulled(effect, pcm, actions=None, blocks=40): + """`blocks` pulls over `pcm` from a source in 256-frame blocks; + `actions[n](effect)` runs just before pull n.""" + channels = effect.channel_count + effect._source.swap(probes.ArraySource(pcm, rate=effect.sample_rate, + channels=channels)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for number in range(blocks): + if actions and number in actions: + actions[number](effect) + out += bytes(audiocore.get_buffer(effect.output)[1]) + return np.frombuffer(bytes(out), dtype=np.int16).reshape(-1, channels) + + +def apply_moves(effect, moves): + for index, value in moves: + effect.set_macro(index, value) + + +def room_render(cls, rate, channels, moves, pcm, mix=2.0, actions=None): + effect = build(cls, rate, channels, mix=mix) + apply_moves(effect, moves) + out = pulled(effect, pcm, actions) + effect.deinit() + return out + + +def fade_reading(old, new, moved, first=None, at=None): + """The block in flight of `moved` (the 256 frames from frame `at`, + default the block after MOVE_AT pulls) against the straight line from + `old` to `new` (full-scale samples left out: the node clips after its + fade), the jump into it, its largest step and the rooms' own largest + step from the frame before it to the frame after it, how many frames + before it are off `old` and after it off `new`, and how far its first + frame sits from `first` (the room of a first move) and from `old`.""" + o, n, m = (x.astype(np.float64) for x in (old, new, moved)) + a = BLOCK_IN_FLIGHT.start if at is None else at + b = a + 256 + k = (np.arange(1, 257, dtype=np.float64) / 256.0)[:, None] + line = o[a:b] + k * (n[a:b] - o[a:b]) + full = (np.abs(o[a:b]) >= 32767) | (np.abs(n[a:b]) >= 32767) + span = slice(a - 1, b + 1) + reading = dict( + pre=int(np.sum(np.any(moved[:a] != old[:a], axis=1))), + post=int(np.sum(np.any(moved[b:] != new[b:], axis=1))), + off_line=float(np.max(np.where(full, 0.0, np.abs(m[a:b] - line)))), + jump=int(np.max(np.abs(m[a] - m[a - 1]))), + step=float(np.max(np.abs(np.diff(m[span], axis=0)))), + own=float(max(np.max(np.abs(np.diff(o[span], axis=0))), + np.max(np.abs(np.diff(n[span], axis=0))))), + near_old=float(np.max(np.abs(m[a] - o[a])))) + if first is not None: + reading["near_first"] = float(np.max(np.abs( + m[a] - first[a].astype(np.float64)))) + return reading + + +class DryOnce(probes.ArraySource): + """int16 frames in 256-frame calls (or `size`-frame calls), except + where `plan` maps a call number (from 1) to what that call hands: a + number is that many frames of the material, 0 an empty buffer, the + source running dry for one call and then going on; ("bytes", n) is n + stray bytes of 0x11 that take nothing from the material; ("error", n) + is `GET_BUFFER_ERROR` carrying the next n frames of the material.""" + + def __init__(self, data, rate, channels, plan=None, size=None): + probes.ArraySource.__init__(self, data, rate=rate, channels=channels) + self.plan = dict(plan or {}) + self.size = size + self.calls = 0 + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + probes.ArraySource._reset_buffer(self) + self.calls = 0 + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self.calls += 1 + take = self.plan.get(self.calls, self.size) + if take is None: + return probes.ArraySource._get_buffer(self) + result = audiocore.GET_BUFFER_MORE_DATA + if isinstance(take, tuple): + kind, take = take + if kind == "bytes": + return result, memoryview(b"\x11" * take) + result = audiocore.GET_BUFFER_ERROR + stride = take * self.channel_count * 2 + chunk = bytes(self._pcm[self._position:self._position + stride]) + self._position += len(chunk) + return result, memoryview(chunk) + + +#: The source hands 255 frames on its fourth call and an empty buffer on +#: its fifth, then goes on: the node's block phase is 255 from then on. +UNDERRUN = {4: 255, 5: 0} + + +def act(effect, moves): + """Apply `moves`: (macro, MIDI), ("patch", index) or ("reset", None).""" + for index, value in moves: + if index == "patch": + effect.program_change(value) + elif index == "reset": + effect.reset() + else: + effect.set_macro(index, value) + + +def dry_render(cls, rate, channels, start, pcm, plan=None, actions=None, + mix=2.0, blocks=40, size=None, **options): + """`blocks` host pulls over `pcm` from a DryOnce source (calls of `size` + frames, default 256), `start` applied at construction and `actions[n]` + just before pull n; `options` go to the constructor. Returns the output + as (frames, channels) and the frame count of each pull.""" + effect = build(cls, rate, channels, mix=mix, **options) + act(effect, start) + effect._source.swap(DryOnce(pcm, rate, channels, plan, size)) + audiocore.reset_buffer(effect.node) + out, sizes = [], [] + for number in range(blocks): + if actions and number in actions: + act(effect, actions[number]) + data = np.frombuffer(bytes(audiocore.get_buffer(effect.output)[1]), + dtype=np.int16).reshape(-1, channels) + out.append(data) + sizes.append(len(data)) + effect.deinit() + return np.vstack(out), sizes + + +#: What the rule says breaks the line, each before one pull, on the dark +#: room (Damping 0) with Mix held at 2: (label, start, the changes). +DARK = ((DAMPING_I, 0),) +HOLD_MIX = ((MIX_I, 127),) +TWO_CHANGES = ( + ("two knobs", DARK, ((ROOM_I, 50), (PREDELAY_I, 40))), + ("three knobs", DARK, ((ROOM_I, 50), (PREDELAY_I, 40), (DECAY_I, 30))), + ("a knob, then a patch", (("patch", 1),) + DARK, + ((PREDELAY_I, 40), ("patch", 3))), + ("a patch, then a knob", (("patch", 1),), (("patch", 3), (ROOM_I, 50))), + ("two patches", (("patch", 0),), (("patch", 1), ("patch", 3))), +) + + +class RoomMoveWords(unittest.TestCase): + """What a move does while audio plays. Since the trial of the second + process (2026-09-29) the docstring claims only what holds in every + case: no dry frame drops or repeats, every move has landed from the end + of the block in flight, a Mix move leaves that block at the old Mix, and + a reset silences it. The straight-line fade, the jumps and the short + reads these tests also pin are the node's mechanism, stated in the + dossier, not promises in the docstring.""" + + def test_a_patch_change_is_one_synthesis_on_the_line(self, cls=None): + # "A patch change counts as one room change however many knobs it + # moves": patch 1 -> 3 (every room knob moves, Mix does not) and + # patch 1 -> 5 (Mix moves too, 44 -> 63) before one pull, at three + # rates, stereo and mono, on white noise and on the low sine. The + # block in flight is on the straight line from the old room to the + # new at the Mix already in flight (patch 5's room at patch 1's + # Mix), and after it the output is the new patch's exactly. + # PatchPerKnob (one synthesis a knob) is red. + for rate in RATES: + for channels in (2, 1): + for pcm in (white(40 * 256, channels, -6.0, seed=4242), + sine(40 * 256, channels, 40.0, rate, 2000.0)): + for target in (3, 5): + old_mix = ConvolutionReverb.PATCHES[1][1][MIX_I] + renders = [] + for action in ("old", "line", "built", "moved"): + effect = build(cls, rate, channels, patch=1) + acts = None + if action in ("line", "built"): + effect.program_change(target) + if action == "line": + effect.set_macro(MIX_I, old_mix) + if action == "moved": + acts = {MOVE_AT: lambda e, t=target: + e.program_change(t)} + renders.append(pulled(effect, pcm, acts)) + effect.deinit() + old, line, built, moved = renders + r = fade_reading(old, line, moved) + post = fade_reading(old, built, moved)["post"] + label = (rate, channels, target, r) + self.assertEqual((r["pre"], post), (0, 0), label) + self.assertLessEqual(r["off_line"], 1.0, label) + + def test_every_move_has_landed_from_the_end_of_the_block_in_flight( + self, cls=None): + # The rule, read at three rates, stereo and mono, on a 40 Hz sine at + # 2 000 LSB through the dark room, Mix held at 2: + # - on the line within 1 LSB, frames before and after exact: one + # knob move; one knob a pull (Room, then Predelay the next pull); + # a move after an under-run and a reset(); + # - the audio untouched: a move onto the room already loaded (Room + # 0 -> 1, both seed 1); + # - frames before and after exact, but a step past ten times the + # rooms' own: every pair of changes before one pull the rule + # names (two knobs, three, a knob and a patch either way, two + # patches), and one knob move after the source ran dry 255 + # frames into a block. + # OneSynthesisPerBlock (changes gathered into one synthesis) is red + # on the pairs, RetryOnEmpty (the class never lets the node see the + # empty buffer) on the under-run, and the cf17a88 words on the + # parse. + for rate in RATES: + for channels in (2, 1): + pcm = sine(40 * 256, channels, 40.0, rate, 2000.0) + + def render(start, actions=None, plan=None): + return dry_render(cls, rate, channels, start + HOLD_MIX, + pcm, plan, actions) + + def on_line(old, new, moved, at, label, post=True): + r = fade_reading(old, new, moved, at=at) + self.assertEqual(r["pre"], 0, (label, r)) + if post: + self.assertEqual(r["post"], 0, (label, r)) + self.assertLessEqual(r["off_line"], 1.0, (label, r)) + + def jumps(old, new, moved, at, label): + r = fade_reading(old, new, moved, at=at) + self.assertEqual((r["pre"], r["post"]), (0, 0), + (label, r)) + self.assertGreater(r["step"], 10 * r["own"], (label, r)) + + label = (rate, channels) + a = MOVE_AT * 256 + room = ((ROOM_I, 50),) + pre = ((PREDELAY_I, 40),) + old = render(DARK)[0] + one = render(DARK + room)[0] + on_line(old, one, render(DARK, {MOVE_AT: room})[0], a, + label + ("one knob",)) + both = render(DARK + room + pre)[0] + paced = render(DARK, {MOVE_AT: room, MOVE_AT + 1: pre})[0] + on_line(old, one, paced, a, label + ("paced, first",), + post=False) + first = render(DARK, {MOVE_AT: room})[0] + on_line(first, both, paced, a + 256, + label + ("paced, second",)) + same = render(DARK, {MOVE_AT: ((ROOM_I, 1),)})[0] + self.assertEqual(digest(same), digest(old), + label + ("the room already loaded",)) + for name, start, changes in TWO_CHANGES: + changes = changes + HOLD_MIX + jumps(render(start)[0], render(start + changes)[0], + render(start, {MOVE_AT: changes})[0], a, + label + (name,)) + # The under-run: the move twenty pulls on, and the same move + # after a reset() that follows the under-run. + p0, p127 = ((PREDELAY_I, 0),), ((PREDELAY_I, 127),) + old, sizes = render(DARK + p0, plan=UNDERRUN) + new = render(DARK + p127, plan=UNDERRUN)[0] + moved = render(DARK + p0, {20: p127}, UNDERRUN)[0] + u = sum(sizes[:20]) + jumps(old, new, moved, u, label + ("under-run",)) + self.assertEqual(sizes[3], 255, label) + # The block in flight is the one frame left of the node's + # block; from its end the output is the new room's. + self.assertEqual(digest(moved[u + 1:]), digest(new[u + 1:]), + label) + # An empty buffer at a block edge is not part-way through + # one: the node plays 256 frames of silence and no phase + # moves, and a move made before that pull fades, on the + # line, over the block the node plays next. + edge = {11: 0} + old, sizes = render(DARK, plan=edge) + self.assertEqual(sizes, [256] * 40, label) + self.assertEqual(int(np.max(np.abs(old[a:a + 256]))), 0, + label) + on_line(old, render(DARK + room, plan=edge)[0], + render(DARK, {MOVE_AT: room}, edge)[0], a + 256, + label + ("an empty buffer at a block edge",)) + again = (("reset", None),) + DARK + HOLD_MIX + p0 + old, sizes = render(DARK + p0, {12: again}, UNDERRUN) + new = render(DARK + p0, {12: again + p127}, UNDERRUN)[0] + moved = render(DARK + p0, {12: again, 24: p127}, + UNDERRUN)[0] + on_line(old, new, moved, sum(sizes[:24]), + label + ("under-run, reset()",)) + + def test_every_short_read_part_way_counts_as_coming_up_short( + self, cls=None): + # "comes up short (an empty buffer, one shorter than a frame, or an + # error result)", and the rule's "has not come up short (above) + # part-way through a block": the source hands 100 frames on its + # fourth call and, on its fifth, an empty buffer, 1 stray byte, 3 + # stray bytes (stereo) or an error result carrying 7 frames. Each + # returns a short block of 100 frames, and a Predelay 0 -> 127 move + # twenty pulls on is off the new room over at most 155 frames, not + # 255, and off the 256-frame line by far more than 1 LSB; a Mix + # 0 -> 2 move there leaves 156 frames at the old Mix, not 256. The + # controls, 3 whole frames and (stereo) 5 bytes, a frame and a + # part, are not short: the node pulls again, the block is whole, + # and the move is on the line (255 frames off the new room, 256 at + # the old Mix). RetryOnShort (the class pulls again on every short + # read) is red on the short legs; RetryOnEmpty is red only on the + # empty one. + start = DARK + HOLD_MIX + move = ((PREDELAY_I, 127),) + for rate in RATES: + for channels in (2, 1): + pcm = sine(44 * 256, channels, 40.0, rate, 2000.0) + legs = [("empty", 0, True), ("1 byte", ("bytes", 1), True), + ("error, 7 frames", ("error", 7), True), + ("3 frames", 3, False)] + if channels == 2: + legs += [("3 bytes", ("bytes", 3), True), + ("5 bytes", ("bytes", 5), False)] + for name, what, short in legs: + plan = {4: 100, 5: what} + label = (rate, channels, name) + old, sizes = dry_render(cls, rate, channels, start, pcm, + plan, blocks=44) + new = dry_render(cls, rate, channels, start + move, pcm, + plan, blocks=44)[0] + moved = dry_render(cls, rate, channels, start, pcm, plan, + {24: move}, blocks=44)[0] + a = sum(sizes[:24]) + r = fade_reading(old, new, moved, at=a) + off = np.nonzero(np.any(moved[a:] != new[a:], axis=1))[0] + fade = int(off[-1]) + 1 + m0 = dry_render(cls, rate, channels, (), pcm, plan, + mix=0.0, blocks=44)[0] + m2 = dry_render(cls, rate, channels, (), pcm, plan, + {24: ((MIX_I, 127),)}, mix=0.0, + blocks=44)[0] + late = int(np.nonzero(np.any(m0[a:] != m2[a:], + axis=1))[0][0]) + self.assertEqual(r["pre"], 0, (label, r)) + if short: + self.assertEqual(sizes[3], 100, label) + self.assertLessEqual(fade, 155, (label, fade)) + self.assertGreater(r["off_line"], 100.0, (label, r)) + self.assertEqual(late, 156, label) + else: + self.assertEqual(sizes[3], 256, label) + self.assertEqual(fade, 255, label) + self.assertLessEqual(r["off_line"], 1.0, (label, r)) + self.assertEqual(late, 256, label) + + def test_no_dry_frame_drops_after_any_number_of_changes(self, cls=None): + # "No frame of your dry signal drops or repeats, at any Mix and + # after any number of moves: at Mix 0 the output is byte for byte + # what it would have been with no move", across every pair of + # changes the rule names (Mix put back to 0 after a patch), across + # moves after the source ran dry 100 and 255 frames into a block, + # and across a move made before a pull the source leaves empty at + # a block edge. And it reads the latency paragraph against the same + # renders: Mix 0 is the source delayed by `latency_samples`, byte + # for byte, from a source in 256-frame calls, in 100-frame calls and + # in 1 000-frame calls, and after it comes up short part-way through + # a block; a pull in which it comes up short before the pull has a + # frame (an empty buffer, 1 stray byte or an error result at a block + # edge, and two such pulls) comes out as 256 frames of silence with + # everything after it 256 frames later; an error result's frames + # never reach the node, and neither does the part frame at the end + # of a buffer of a frame and a byte (its whole frame does). The + # moves come before and after the starved pull. ResetOnMove is red, + # and so are the 8a57282 words, whose one exception was the reset. + wire_back = ((MIX_I, 0),) + stray = np.frombuffer(b"\x11\x11", dtype=np.int16)[0] + room = ((ROOM_I, 50),) + for rate in RATES: + for channels in (2, 1): + frames = 40 * 256 + pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype( + np.int16) + pcm = np.repeat(pcm[:, None], channels, axis=1) + + def wire(source, silent=()): + """`source` delayed by `latency_samples`, with 256 + frames of silence at each output frame in `silent` + (output frames, the earlier silences counted).""" + out = np.vstack([silence(LATENCY, channels), source]) + for at in sorted(silent): + out = np.vstack([out[:at], silence(256, channels), + out[at:]]) + return out + + edge = MOVE_AT * 256 # the starved pull, call 11 + dropped = np.vstack([pcm[:edge], pcm[edge + 7:]]) + part = np.vstack([pcm[:edge], + np.full((1, channels), stray, np.int16), + pcm[edge:]]) + # (name, source plan, moves, the call size, what Mix 0 is) + cases = [(name, None, {MOVE_AT: changes + wire_back}, None, + wire(pcm)) for name, _, changes in TWO_CHANGES] + for p in (100, 255): + cases.append(("under-run %d" % p, {4: p, 5: 0}, { + 20: room, 21: ((PREDELAY_I, 40), (DECAY_I, 30))}, + None, wire(pcm))) + cases += [ + ("100-frame calls, one empty", {30: 0}, {20: room}, 100, + wire(pcm)), + ("1 000-frame calls, one empty", {5: 0}, {6: room}, 1000, + wire(pcm)), + ("empty at a block edge, a move before it", {11: 0}, + {MOVE_AT: room}, None, wire(pcm, (edge,))), + ("empty at a block edge, a move after it", {11: 0}, + {20: room}, None, wire(pcm, (edge,))), + ("1 byte at a block edge", {11: ("bytes", 1)}, + {20: room}, None, wire(pcm, (edge,))), + ("an error with 7 frames at a block edge", + {11: ("error", 7)}, {20: room}, None, + wire(dropped, (edge,))), + ("two empty pulls", {11: 0, 15: 0}, {20: room}, None, + wire(pcm, (edge, 14 * 256))), + ("a frame and a byte at a block edge", + {11: ("bytes", 2 * channels + 1)}, {20: room}, None, + wire(part)), + ] + for name, plan, actions, size, want in cases: + label = (rate, channels, name) + out = dry_render(cls, rate, channels, (), pcm, plan, + actions, mix=0.0, size=size)[0] + still = dry_render(cls, rate, channels, (), pcm, plan, + mix=0.0, size=size)[0] + self.assertEqual(digest(out), digest(still), label) + self.assertEqual(digest(out), digest(want[:len(out)]), + label) + + def test_a_mix_move_leaves_the_block_in_flight_at_the_old_mix( + self, cls=None): + # "A Mix move ... acts on the audio entering the node after it, so + # the block already in flight, at most 256 frames, comes out at the + # old Mix": Mix 0 -> 2 twenty pulls in. + # The frames after the move at the old Mix are never more than the + # printed count, and from the first one at the new Mix the output + # is an instance that always had it; from a steady source the count + # is the printed one exactly, and after the source ran dry 100 + # frames into a block it is 156. MixOnePullLate (512) is red, and + # so are the cf17a88 words, which said the 256 frames in flight + # come out at the old Mix. + most = 256 + for rate in RATES: + for channels in (2, 1): + pcm = white(40 * 256, channels, -6.0, seed=4243) + for plan, frames in ((None, most), ({4: 100, 5: 0}, 156)): + old, sizes = dry_render(cls, rate, channels, (), pcm, + plan, mix=0.0) + new = dry_render(cls, rate, channels, (), pcm, plan, + mix=2.0)[0] + moved = dry_render(cls, rate, channels, (), pcm, plan, + {20: ((MIX_I, 127),)}, mix=0.0)[0] + a = sum(sizes[:20]) + self.assertEqual(digest(moved[:a]), digest(old[:a])) + off = np.nonzero(np.any(moved[a:] != old[a:], axis=1))[0] + late = int(off[0]) + label = (rate, channels, plan, late) + self.assertLessEqual(late, most, label) + self.assertEqual(late, frames, label) + self.assertEqual(digest(moved[a + late:]), + digest(new[a + late:]), label) + + def test_reset_silences_the_next_256_frames(self, cls=None): + # "`reset()` in the middle of a stream empties the room: the next + # `latency_samples` frames come out as exact zero, dry included. + # That is 256 with a room loaded": at Mix 0, 1.2 and 2, on the + # synthesized room from a steady source and after one that ran dry + # 100 frames into a block, and on a measured impulse of 1 000 taps, + # the 256 frames after a reset() twenty pulls in are + # exact zero and the frames either side of them are not; at Mix 0 + # the output is the source delayed by `latency_samples` but for + # those 256 frames (the latency paragraph's first exception). On + # the empty impulse (`latency_samples` 0) the reset silences + # nothing: at Mix 0, 0.6, 1.2 and 2 no output frame is zero and the + # output is the source, frame for frame. NoReset (a reset that + # keeps the history) is red on the room, ResetSilentOnEmpty (a + # reset that plays 256 frames of silence whatever the node holds) + # on the empty impulse, and so are the 8a57282 words, which said + # the next 256 frames at every Mix. + for rate in RATES: + for channels in (2, 1): + pcm = white(40 * 256, channels, -6.0, seed=4244) + pcm[pcm == 0] = 1 + want = np.vstack([silence(LATENCY, channels), pcm]) + rooms = ({}, dict(impulse=make_impulse(1000).tobytes())) + for plan, room in ((None, rooms[0]), ({4: 100, 5: 0}, + rooms[0]), + (None, rooms[1])): + for midi in (0, 76, 127): + out, sizes = dry_render( + cls, rate, channels, ((MIX_I, midi),), pcm, plan, + {20: (("reset", None), (MIX_I, midi))}, mix=0.0, + **room) + a = sum(sizes[:20]) + label = (rate, channels, plan, bool(room), midi) + self.assertEqual( + int(np.max(np.abs(out[a:a + 256]))), 0, label) + self.assertGreater( + int(np.max(np.abs(out[a - 256:a]))), 0, label) + self.assertGreater( + int(np.max(np.abs(out[a + 256:a + 512]))), 0, + label) + if midi == 0: + wire = want[:len(out)].copy() + wire[a:a + 256] = 0 + self.assertEqual(digest(out), digest(wire), + label) + for midi in (0, 38, 76, 127): + label = (rate, channels, "empty impulse", midi) + out = dry_render( + cls, rate, channels, ((MIX_I, midi),), pcm, None, + {20: (("reset", None), (MIX_I, midi))}, mix=0.0, + impulse=b"")[0] + self.assertTrue(np.all(np.any(out != 0, axis=1)), label) + self.assertEqual(digest(out), digest(pcm[:len(out)]), + label) + + def test_the_tail_counts_the_frames_the_source_hands(self, cls=None): + # The Tail paragraph: white noise at -6 dBFS over frames 0..4999, + # then zero frames, at Mix 1.2 and 2, on the synthesized room at + # patch 0 and on the empty impulse, three rates, stereo and mono; + # the source leaves no pull empty, one pull (call 23) empty inside + # the tail, or three in a row (calls 21 to 23), which is a source + # that stops handing frames mid-tail and then goes on. With the + # starved pulls' output taken out (each exact zero, 256 frames), + # the output is the no-starve render frame for frame, so the tail + # waits and then goes on where it was; and every frame more than + # `tail_samples` past the last non-zero input frame is exact zero, + # counted in the frames the source hands, while the room's last + # non-zero frame lands within one frame of that edge. TailTwoShort + # (`tail_samples` two frames short) is red, and so are the 8a57282 + # words, which said only that the output is zero after the tail. + burst = 5000 + for rate in RATES: + for channels in (2, 1): + pcm = silence(40 * 256, channels) + pcm[:burst] = white(burst, channels, -6.0, seed=808) + for options in ({}, dict(impulse=b"")): + probe = build(cls, rate, channels, **options) + tail = probe.tail_samples + probe.deinit() + for midi in (76, 127): + steady = None + for plan in (None, {23: 0}, {21: 0, 22: 0, 23: 0}): + label = (rate, channels, options, midi, plan) + out, sizes = dry_render( + cls, rate, channels, ((MIX_I, midi),), pcm, + plan, mix=0.0, **options) + self.assertEqual(sizes, [256] * 40, label) + handed = out + if plan: + starved = [call - 1 for call in plan] + for pull in starved: + self.assertEqual(int(np.max(np.abs( + out[pull * 256:pull * 256 + 256]))), + 0, label) + keep = np.ones(len(out), bool) + for pull in starved: + keep[pull * 256:pull * 256 + 256] = False + handed = out[keep] + self.assertEqual( + digest(handed), + digest(steady[:len(handed)]), label) + else: + steady = out + edge = burst - 1 + tail + self.assertEqual( + int(np.max(np.abs(handed[edge + 1:]))), 0, + label) + last = int(np.nonzero(np.any(handed != 0, + axis=1))[0][-1]) + self.assertGreaterEqual(last, edge - 1, label) + + +# -------------------------------------------------------------------------- +# D1 - measured mode is exactly convolution, within one output LSB +# -------------------------------------------------------------------------- + +class D1ExactConvolution(unittest.TestCase): + def test_within_one_lsb_across_layouts_levels_gain_and_trim(self): + worst = 0.0 + cells = 0 + for ir_channels, channels in ((1, 2), (2, 2), (1, 1), (2, 1)): + for kind in ("sine", "noise"): + for target in (2000.0, 8000.0, 30000.0): + for gain_db, start_ms in ((0.0, 0.0), (0.0, 10.0), + (12.0, 10.0), (-24.0, 0.0)): + error = m1_error(ConvolutionReverb, RATE, ir_channels, + channels, kind, target, gain_db, + start_ms) + if error is None: + continue + cells += 1 + worst = max(worst, error) + self.assertGreater(cells, 50) + self.assertLessEqual(worst, 1.0) + + def test_within_one_lsb_at_the_lower_rates(self): + for rate in (44100, 22050): + for start_ms in (0.0, 10.0): + error = m1_error(ConvolutionReverb, rate, 2, 2, "noise", + 8000.0, 0.0, start_ms) + self.assertLessEqual(error, 1.0, (rate, start_ms)) + + def test_an_interior_trim_where_truncation_and_rounding_differ(self): + # The row's trims 0 and 10 ms are whole-frame at every rate, where a + # rounded trim equals a truncated one, so they cannot carry the + # truncation clause (audit round 1). 41.7 ms at 48 kHz is 2 001.6 + # frames and 3.3 ms at 44.1 / 22.05 kHz is 145.53 / 72.765: the + # class keeps 2 001 / 145 / 72, a rounded trim 2 002 / 146 / 73. + h = make_impulse(3840) + for rate, start_ms in ((48000, 41.7), (44100, 3.3), (22050, 3.3)): + self.assertNotEqual(trim_frames(start_ms, rate), + int(round(start_ms * rate / 1000.0))) + clean = m1_error(ConvolutionReverb, rate, 1, 2, "noise", 8000.0, + 0.0, start_ms, h_full=h) + planted = m1_error(TrimRounded, rate, 1, 2, "noise", 8000.0, + 0.0, start_ms, h_full=h) + self.assertLessEqual(clean, 1.0, (rate, start_ms)) + self.assertGreater(planted, 1000.0, (rate, start_ms)) + # And the control is invisible at the row's own trims. + for start_ms in (0.0, 10.0): + self.assertLessEqual(m1_error(TrimRounded, RATE, 1, 2, "noise", + 8000.0, 0.0, start_ms, h_full=h), + 1.0) + + def test_trim_one_frame_late_is_red_at_the_measured_defaults(self): + # The measured-mode defaults: Mix 0.6 (grid 38), ir_gain_db 0, + # start_ms 0. M1 through the Mix law, and at Mix 2. + for mix_midi in (38, 127): + clean = m1_error(ConvolutionReverb, RATE, 1, 2, "noise", 3000.0, + mix_midi=mix_midi) + planted = m1_error(TrimLate, RATE, 1, 2, "noise", 3000.0, + mix_midi=mix_midi) + self.assertLessEqual(clean, 1.0) + self.assertGreater(planted, 1000.0, mix_midi) + + def test_the_mix_walk_never_reaches_the_plant(self): + # fault_reachability walks every label, and in measured mode five of + # them raise IndexError by design; the walk here is its positions + # over the one live macro, Mix, and the 8 patches (which set only + # Mix in this mode). + h = make_impulse() + planted = m1_error(TrimLate, RATE, 1, 2, "noise", 3000.0, h_full=h) + readings = [] + mixes = list(GRID) + [p[1][MIX_I] for p in + ConvolutionReverb.PATCHES.values()] + for mix_midi in mixes: + readings.append(m1_error(ConvolutionReverb, RATE, 1, 2, "noise", + 3000.0, mix_midi=mix_midi, h_full=h)) + self.assertEqual(len(readings), 25) + self.assertLessEqual(max(readings), 1.0) + self.assertGreater(planted, 1000.0) + + def test_null_build_is_red(self): + def measure(cls): + error = m1_error(cls, RATE, 1, 2, "noise", 8000.0) + return {"passed": error is not None and error <= 1.0} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D1") + self.assertFalse(result["null"]["passed"]) + + +# -------------------------------------------------------------------------- +# D2 - the allocation has a ceiling and a floor, and the class names both +# -------------------------------------------------------------------------- + +def capacity(node): + """The node's allocation in taps, read as a board would: the longest + impulse its `load()` accepts (the native binding refuses more, + `Convolver.c:183-186` at 0d35a90; the twin at `audioconvolve.py:127`). + `node.taps` cannot stand in for it: in measured mode it reports the + loaded length rounded to a partition, which is the law whatever the + allocation (audit round 1). Destructive - it replaces the room - so it + is the last thing read off an instance.""" + taps = max(256, (int(node.taps) + 255) // 256 * 256) + while taps <= 131072 + 256: + try: + node.load(bytes(2 * (taps + 1)), 1, 1.0) + except ValueError: + return taps + taps += 256 + return taps + + +def m2(cls, cells, reading=None): + """Red entries over (label, seconds, rate, impulse frames, expect). + + A build reads both `node.taps` and the capacity against the law; + `reading="taps"` reads `node.taps` alone, the frozen reading, kept so + the test can show what it missed.""" + reds = [] + for label, seconds, rate, frames, expect in cells: + taps = int(round(seconds * rate)) if frames is None else frames + law = max(1, (taps + 255) // 256) * 256 + options = {"seconds": seconds} + if frames is not None: + options = {"impulse": make_impulse(frames).tobytes()} + try: + effect = build(cls, rate, **options) + except ValueError as exc: + text = str(exc) + if expect != "raise": + reds.append("%s: raised %r" % (label, text)) + continue + if taps > 131072: + want = (NAME, str(taps), str(rate), + "%.3f s" % (int(131072.0 / rate * 1000) / 1000.0)) + else: + want = (NAME, "0.060 s") + missing = [w for w in want if w not in text] + if missing: + reds.append("%s: %r lacks %s" % (label, text, missing)) + continue + got = effect.node.taps + held = got if reading == "taps" else capacity(effect.node) + effect.deinit() + if expect != "build": + reds.append("%s: built" % label) + elif got != law: + reds.append("%s: %d taps, the law says %d" % (label, got, law)) + elif held != law: + reds.append("%s: capacity %d taps, the law says %d" + % (label, held, law)) + return reds + + +def d2_cells(): + cells = [] + for rate in RATES: + ceiling = 131072.0 / rate + printed = int(ceiling * 1000) / 1000.0 + cells += [("ceiling %d" % rate, ceiling, rate, None, "build"), + ("ceiling+256 %d" % rate, 131328.0 / rate, rate, None, + "raise"), + ("printed %.3f %d" % (printed, rate), printed, rate, None, + "build"), + ("floor 0.06 %d" % rate, 0.06, rate, None, "build"), + ("floor 0.0599 %d" % rate, 0.0599, rate, None, "raise"), + ("default 0.08 %d" % rate, 0.08, rate, None, "build")] + cells += [("measured 131072", 0.0, RATE, 131072, "build"), + ("measured 131073", 0.0, RATE, 131073, "raise")] + cells += MEASURED_BELOW + return cells + + +#: Measured mode below the ceiling (fix round 1): lengths off, on and one +#: past a partition edge, and a one-second room. +MEASURED_BELOW = [("measured %d" % frames, 0.0, RATE, frames, "build") + for frames in (1000, 3840, 3841, 48000)] + + +class D2Allocation(unittest.TestCase): + def test_every_cell_builds_or_raises_by_the_law(self): + self.assertEqual(m2(ConvolutionReverb, d2_cells()), []) + + def test_the_message_at_three_seconds(self): + with self.assertRaises(ValueError) as caught: + build(seconds=3.0) + self.assertEqual( + str(caught.exception), + "ConvolutionReverb: 144000 taps at 48000 Hz is over the ceiling " + "of 131072 taps (512 partitions), 2.730 s at this rate") + + def test_the_default_allocation_at_three_rates(self): + for rate, taps in ((48000, 3840), (44100, 3584), (22050, 1792)): + effect = build(rate=rate) + self.assertEqual(effect.node.taps, taps) + effect.deinit() + + def test_null_with_the_checks_removed_is_red(self): + reds = m2(NoChecks, d2_cells()) + self.assertGreaterEqual(len(reds), 7, reds) + self.assertTrue(any("impulse is too long" in r for r in reds)) + self.assertTrue(any("0.0599" in r and "built" in r for r in reds)) + + def test_one_partition_too_many_is_red_at_the_defaults(self): + for rate, law in ((48000, 3840), (44100, 3584), (22050, 1792)): + effect = build(ExtraPartition, rate) + self.assertEqual(effect.node.taps, law + 256) + effect.deinit() + self.assertTrue(m2(ExtraPartition, d2_cells())) + + def test_one_partition_too_many_is_red_in_measured_mode(self): + # node.taps reads the law on the planted class too (1 024 / 3 840 / + # 4 096 / 48 128), so the frozen reading is green there; the + # capacity reads 1 280 / 4 096 / 4 352 / 48 384 and is red. + self.assertEqual(m2(ExtraPartition, MEASURED_BELOW, reading="taps"), + []) + reds = m2(ExtraPartition, MEASURED_BELOW) + self.assertEqual(len(reds), 4, reds) + for (label, _, _, frames, _), red in zip(MEASURED_BELOW, reds): + law = (frames + 255) // 256 * 256 + self.assertIn("capacity %d taps" % (law + 256), red) + self.assertEqual(m2(ConvolutionReverb, MEASURED_BELOW), []) + + def test_the_plant_is_not_on_the_surface(self): + result = reach(ExtraPartition, lambda e: e.node.taps) + self.assertEqual(result["target"], 4096) + self.assertEqual(result["clean"], 3840) + self.assertEqual(result["checked"], WALKED) + + +# -------------------------------------------------------------------------- +# D3 - latency is one partition loaded, zero unloaded, and reported so +# -------------------------------------------------------------------------- + +class D3Latency(unittest.TestCase): + def test_loaded_every_patch_and_the_predelay_stop(self): + for rate in RATES: + for channels in ((2, 1) if rate == RATE else (2,)): + effect = build(rate=rate, channels=channels) + pulse = click(4096, channels) + settings = [("patch", p) for p in sorted( + ConvolutionReverb.PATCHES)] + [("predelay", 127)] + for kind, value in settings: + if kind == "patch": + effect.program_change(value) + else: + effect.program_change(0) + effect.set_macro(PREDELAY_I, value) + self.assertEqual(effect.latency_samples, 256) + for mix in (0, 63): + self.assertEqual( + first_arrival(at_mix(effect, mix, pulse)), 256, + (rate, channels, kind, value, mix)) + effect.deinit() + + def test_wet_arrives_at_the_partition_over_64_rooms(self): + effect = build(diffusion=0.0, mix=2.0) + pulse = click(4096) + for room in range(0, 128, 2): + effect.set_macro(ROOM_I, room) + self.assertEqual(first_arrival(run(effect, pulse)), 256, room) + effect.deinit() + + def test_unloaded_is_an_undelayed_wire(self): + for rate in RATES: + effect = build(rate=rate, impulse=b"") + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.tail_samples, 0) + pcm = white(2048) + for mix in (0, 63, 127): + out = at_mix(effect, mix, pcm) + self.assertEqual(digest(out), digest(pcm), (rate, mix)) + self.assertEqual(first_arrival(at_mix(effect, mix, + click(1024))), 0) + effect.deinit() + + def test_tail_is_latency_plus_the_loaded_impulse(self): + for rate, tail in ((48000, 4096), (44100, 3840), (22050, 2048)): + effect = build(rate=rate) + self.assertEqual(effect.tail_samples, tail) + effect.deinit() + effect = build(impulse=make_impulse(1000).tobytes()) + self.assertEqual(effect.tail_samples, 256 + 1024) + effect.deinit() + + def test_zero_latency_is_red_at_the_defaults(self): + effect = build(ZeroLatency) + measured = first_arrival(at_mix(effect, 63, click(4096))) + self.assertEqual(measured, 256) + self.assertNotEqual(effect.latency_samples, measured) + effect.deinit() + + def test_the_plant_is_not_on_the_surface(self): + result = reach(ZeroLatency, lambda e: e.latency_samples) + self.assertEqual(result["target"], 0) + self.assertEqual(result["clean"], 256) + self.assertEqual(result["checked"], WALKED) + + def test_null_build_is_red(self): + def measure(cls): + effect = build(cls) + arrived = first_arrival(at_mix(effect, 63, click(4096))) + reported = effect.latency_samples + effect.deinit() + return {"passed": arrived == reported == 256} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D3") + self.assertFalse(result["null"]["passed"]) + + +# -------------------------------------------------------------------------- +# D4 - Mix 0 is the source delayed by latency_samples, byte for byte +# -------------------------------------------------------------------------- + +class D4DelayedWire(unittest.TestCase): + def test_full_scale_at_three_rates_stereo_and_mono(self): + for rate in RATES: + for channels in (2, 1): + effect = build(rate=rate, channels=channels, mix=0.0) + for pcm in (alt_fs(4096, channels), ramp_fs(channels)): + self.assertTrue(m4(effect, pcm), (rate, channels)) + effect.deinit() + + def test_every_macro_stop_and_patch_with_mix_at_0(self): + effect = build() + ramp = ramp_fs(2) + for index in range(5): + for stop in (0, 127): + effect.program_change(0) + effect.set_macro(index, stop) + self.assertTrue(m4(effect, ramp), (index, stop)) + for patch in sorted(ConvolutionReverb.PATCHES): + effect.program_change(patch) + self.assertTrue(m4(effect, ramp), patch) + effect.deinit() + + def test_measured_and_unloaded(self): + measured = build(impulse=make_impulse().tobytes()) + self.assertTrue(m4(measured, ramp_fs(2))) + measured.deinit() + empty = build(impulse=b"") + self.assertEqual(empty.latency_samples, 0) + self.assertTrue(m4(empty, ramp_fs(2))) + empty.deinit() + + def test_first_256_zero_is_not_redundant(self): + # On alt_fs, whose period divides 256, a wire's out[256:] equals + # in[:-256]: the digest alone reads green. + pcm = alt_fs(4096) + self.assertEqual(digest(pcm[256:]), digest(pcm[:-256])) + self.assertFalse(m4_verdict(pcm, pcm, 256)) + + def test_one_lsb_scale_is_red_at_the_defaults(self): + for rate in RATES: + for channels in (2, 1): + effect = build(OneLsbAfter, rate, channels) + for pcm in (alt_fs(4096, channels), ramp_fs(channels)): + self.assertFalse(m4(effect, pcm), (rate, channels)) + effect.deinit() + + def test_the_plant_is_not_on_the_surface(self): + alt, ramp = alt_fs(2048), ramp_fs(2) + result = reach(OneLsbAfter, lambda e: m4(e, alt) and m4(e, ramp)) + self.assertIs(result["target"], False) + self.assertIs(result["clean"], True) + self.assertEqual(result["checked"], WALKED) + + def test_null_build_is_red(self): + def measure(cls): + effect = build(cls) + verdict = m4(effect, ramp_fs(2)) + effect.deinit() + return {"passed": verdict} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D4") + self.assertFalse(result["null"]["passed"]) + + +# -------------------------------------------------------------------------- +# D5 - the synthesized room decays at the Decay law's T60, to exact zero +# -------------------------------------------------------------------------- + +class D5DecayLaw(unittest.TestCase): + def errors_over_rooms(self, cls=None, rate=RATE, moves=(), + read_back=False, **options): + # The law takes the seconds and the Decay and Predelay positions + # this test handed the instance, never `effect.seconds` or + # `effect._macros`: a class that stretched its own allocation, or + # held a position other than the one it was handed, would move a + # law read back from it (AllocatedSeconds; DecayKeptSquared and + # DecayMidiSquared, below). `moves` are (macro, MIDI) handed after + # construction; `read_back=True` is the frozen reading, kept only + # to show the hole the handed law closes. + seconds = options.get("seconds", 0.08) + effect = handed_build(cls, rate, **options) + for index, midi in moves: + effect.set_macro(index, midi) + errors = [] + for room in range(0, 128, 2): + effect.set_macro(ROOM_I, room) + t60, floor = m5_cell(effect) + self.assertTrue(floor, room) + self.assertIsNotNone(t60, room) + if read_back: + decay, predelay = (effect._macros[DECAY_I], + effect._macros[PREDELAY_I]) + else: + decay, predelay = effect.handed + errors.append(t60 / law_t60(decay, predelay, seconds) - 1.0) + effect.deinit() + return np.array(errors) + + def test_damping_out_every_room_within_3_percent(self): + for rate, decays in ((48000, (0.0, 64 / 127.0, 1.0)), + (22050, (1.0,))): + for decay in decays: + errors = self.errors_over_rooms(rate=rate, decay=decay, + damping_hz=0.0, + diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(errors))), 0.03, + (rate, decay)) + + def test_damping_in_the_64_room_mean_within_2_percent(self): + for rate in RATES: + errors = self.errors_over_rooms(rate=rate) + self.assertLessEqual(abs(float(np.mean(errors))), 0.02, rate) + errors = self.errors_over_rooms(decay=0.0, damping_hz=500.0, + diffusion=0.0) + self.assertLessEqual(abs(float(np.mean(errors))), 0.02) + + def test_every_patch_mean_within_2_percent_at_one_second(self): + for patch in (1, 3, 5): + effect = handed_build(seconds=1.0, patch=patch) + errors = [] + for room in range(0, 128, 8): + effect.set_macro(ROOM_I, room) + t60, floor = m5_cell(effect) + self.assertTrue(floor) + law = law_t60(effect.handed[0], effect.handed[1], 1.0) + errors.append(t60 / law - 1.0) + effect.deinit() + self.assertLessEqual(abs(float(np.mean(errors))), 0.02, patch) + + def test_a_decay_5_percent_long_is_red_at_the_defaults(self): + errors = self.errors_over_rooms(LongDecay) + self.assertGreater(float(np.mean(errors)), 0.02) + + def test_a_stretched_allocation_is_red_on_the_handed_law(self): + # At 0.06 s the allocation rounds up to a whole partition (2 880 -> + # 3 072 taps at 48 kHz, 0.064 s; 0.0639 s at 44.1 kHz, 0.0697 s at + # 22.05 kHz); a class keeping that as `seconds` puts every Room long + # against the law of the seconds it was handed. + for rate in RATES: + errors = self.errors_over_rooms(AllocatedSeconds, rate, + seconds=0.06, damping_hz=0.0, + diffusion=0.0) + self.assertGreater(float(np.max(np.abs(errors))), 0.03, rate) + self.assertGreater(float(np.mean(errors)), 0.05, rate) + clean = self.errors_over_rooms(rate=rate, seconds=0.06, + damping_hz=0.0, diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + # The frozen reading, the law from `effect.seconds`, stays green on + # the plant: that is the hole the handed law closes. + effect = build(AllocatedSeconds, RATE, seconds=0.06, damping_hz=0.0, + diffusion=0.0) + self.assertAlmostEqual(effect.seconds, 0.064, places=12) + read_back = [] + for room in range(0, 128, 2): + effect.set_macro(ROOM_I, room) + t60, _ = m5_cell(effect) + read_back.append(t60 / law_t60(1.0, 0.0, effect.seconds) - 1.0) + effect.deinit() + self.assertLessEqual(float(np.max(np.abs(read_back))), 0.03) + + def test_a_decay_held_off_the_handed_position_is_red(self): + # Fix round 2 (gate audit round 2, item 4). At Decay 64/127, where + # squaring moves the position (0.504 -> 0.254), a class that keeps + # the constructor's Decay squared, or a set_macro Decay squared, + # is red on the law from the handed position and green on the law + # read back off the class: that is the hole the handed law closes. + for rate in (48000, 22050): + planted = self.errors_over_rooms(DecayKeptSquared, rate, + decay=64 / 127.0, + damping_hz=0.0, diffusion=0.0) + self.assertGreater(float(np.max(np.abs(planted))), 0.03, rate) + held = self.errors_over_rooms(DecayKeptSquared, rate, + read_back=True, decay=64 / 127.0, + damping_hz=0.0, diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) + moved = self.errors_over_rooms(DecayMidiSquared, rate, + moves=((DECAY_I, 64),), + damping_hz=0.0, diffusion=0.0) + self.assertGreater(float(np.max(np.abs(moved))), 0.03, rate) + held = self.errors_over_rooms(DecayMidiSquared, rate, + moves=((DECAY_I, 64),), + read_back=True, damping_hz=0.0, + diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) + clean = self.errors_over_rooms(rate=rate, moves=((DECAY_I, 64),), + damping_hz=0.0, diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + + def test_a_predelay_held_off_the_handed_position_is_red(self): + # Re-audit fix round 1 (gate audit round 3, item 3). The law takes + # the Predelay position the test handed too, and until this test no + # D5 cell sat at an interior Predelay at 0.08 s, where the law + # depends on it most: PredelayKeptSquared passed every D5 test. At + # Predelay 64/127, Decay 127, Damping out, Diffusion 0 it is red on + # the handed law and green on the law read back off the class; + # the clean class is green on the handed law. + for rate in (48000, 22050): + options = dict(predelay=64 / 127.0, decay=1.0, damping_hz=0.0, + diffusion=0.0) + planted = self.errors_over_rooms(PredelayKeptSquared, rate, + **options) + self.assertGreater(float(np.max(np.abs(planted))), 0.03, rate) + self.assertGreater(float(np.mean(planted)), 0.03, rate) + held = self.errors_over_rooms(PredelayKeptSquared, rate, + read_back=True, **options) + self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) + clean = self.errors_over_rooms(rate=rate, **options) + self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + # Re-audit fix round 2: the same by `set_macro`, Predelay 64 + # handed after construction, with PredelayMidiSquared as the + # control. The clean leg goes through `set_macro` too, so a + # class that held a moved Predelay wrong fails it. + moved = dict(decay=1.0, damping_hz=0.0, diffusion=0.0) + planted = self.errors_over_rooms(PredelayMidiSquared, rate, + moves=((PREDELAY_I, 64),), + **moved) + self.assertGreater(float(np.max(np.abs(planted))), 0.03, rate) + self.assertGreater(float(np.mean(planted)), 0.03, rate) + held = self.errors_over_rooms(PredelayMidiSquared, rate, + moves=((PREDELAY_I, 64),), + read_back=True, **moved) + self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) + clean = self.errors_over_rooms(rate=rate, + moves=((PREDELAY_I, 64),), + **moved) + self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + + def test_stuck_dc_turns_the_floor_red(self): + effect = build(StuckDcAfter) + t60, floor = m5_cell(effect) + self.assertFalse(floor) + effect.deinit() + + def test_the_plants_are_not_on_the_surface(self): + def handed(effect): + law = law_t60(effect.handed[0], effect.handed[1], 0.08) + return effect._loaded[0] / law + + result = reach(LongDecay, handed, tolerance=0.01, + builder=handed_build) + self.assertAlmostEqual(result["target"], 1.05, places=9) + self.assertAlmostEqual(result["clean"], 1.0, places=9) + self.assertEqual(result["checked"], WALKED) + result = reach(StuckDcAfter, lambda e: m5_cell(e)[1]) + self.assertIs(result["target"], False) + self.assertEqual(result["checked"], WALKED) + + def test_null_build_is_red(self): + def measure(cls): + effect = build(cls) + t60, floor = m5_cell(effect) + law = law_t60(1.0, 0.0, 0.08) + effect.deinit() + return {"passed": floor and t60 is not None + and abs(t60 / law - 1.0) <= 0.03} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D5") + self.assertFalse(result["null"]["passed"]) + + +#: The single Rooms the walks found furthest off the law, each with its +#: reading in % (the figures the docstring printed until the trial of the +#: second process, 2026-09-29): (rate, channels, click LSB, Decay MIDI, +#: Predelay MIDI, Diffusion MIDI, Room seed), all at Damping 500 Hz and +#: 0.08 s. The walk behind the mono cells is the +#: re-audit round-1 audit's (`convolutionreverb_reaudit1_audit.py mono +#: monowalk`); the stereo cells are its re-refuter's walk +#: (`convolutionreverb_reaudit1_refute.py single`) re-run on the fixed node +#: at audiodsp v0.6.3rc2, where every stereo room moved (re-audit fix round +#: 1: v0.6.2's +16.20 % cell reads otherwise there). +SINGLE_ROOM_CELLS = ( + ((44100, 1, 32767, 0, 0, 32, 43), 23.70), + ((44100, 1, 3277, 0, 127, 28, 43), 24.02), + ((48000, 1, 32767, 0, 0, 10, 43), 22.83), + ((22050, 1, 32767, 0, 0, 46, 61), 21.00), + ((48000, 2, 32767, 8, 0, 32, 43), 16.56), + ((22050, 2, 3277, 127, 0, 0, 27), 16.05), +) + + +def single_room_error(cell, cls=None): + """(% off the Decay law, floor clean) of one Room at `cell`: a click at + Mix 2, the Schroeder fit on the sum of the channels' energy, the law + from the positions this test hands the constructor.""" + rate, channels, value, decay, predelay, diffusion, seed = cell + effect = build(cls, rate, channels, decay=decay / 127.0, + damping_hz=500.0, predelay=predelay / 127.0, + diffusion=diffusion / 127.0, room=seed) + taps = effect.node.taps + out = at_mix(effect, 127, click(LATENCY + taps + 1024, channels, + value=value)) + effect.deinit() + ir = out[LATENCY:LATENCY + taps].astype(np.float64) + t60 = schroeder_t60(np.sum(ir ** 2, axis=1), rate) + law = law_t60(decay / 127.0, predelay / 127.0, 0.08) + return 100.0 * (t60 / law - 1.0), not np.any(out[LATENCY + taps:]) + + +class D5SingleRoom(unittest.TestCase): + """D5's Not claimed line, a single Room with Damping in: the docstring + says only that one can take more than 15 % longer than the Decay time. + Each cell the walks found is held to its reading, to the hundredth, and + every one is past the 15 %.""" + + def test_a_single_room_can_take_more_than_15_percent_longer(self): + for cell, figure in SINGLE_ROOM_CELLS: + error, floor_clean = single_room_error(cell) + self.assertTrue(floor_clean, cell) + self.assertLessEqual(abs(error - figure), 0.006, + (cell, error, figure)) + self.assertGreater(error, 15.0, cell) + + +# -------------------------------------------------------------------------- +# D6 - unit energy, not the source: no synthesis macro is a level control +# -------------------------------------------------------------------------- + +class D6UnitEnergy(unittest.TestCase): + """Three clauses since fix round 1: the pooled wet/dry spread <= 0.5 dB, + |wet/dry| <= 0.5 dB at every cell (the absolute level, which the null + test already read), and rho < 0.5. Level is both channels pooled. Since + audiodsp v0.6.3rc2 each side is normalised on its own, and D6's clause + 4 claims it (re-audit fix round 1): `D6Balance` and `D6OneSided`.""" + + def spread(self, cls, index, rate=RATE, peak_dbfs=-12.0, grid=GRID): + """(spread dB, worst |level| dB, worst rho), walking `index` by + `set_macro` on one instance as a host would.""" + pcm = white(int(1.5 * rate), peak_dbfs=peak_dbfs) + levels, rhos = [], [] + effect = build(cls, rate) + for position in grid: + effect.set_macro(index, position) + level, r = m6_cell(effect, pcm) + levels.append(level) + rhos.append(r) + effect.deinit() + return (max(levels) - min(levels), max(abs(v) for v in levels), + max(rhos)) + + def assertGreen(self, reading, label): + spread, level, worst = reading + self.assertLessEqual(spread, 0.5, label) + self.assertLessEqual(level, 0.5, label) + self.assertLess(worst, 0.5, label) + + def test_decay_and_damping_at_two_levels(self): + for peak in (-12.0, -30.0): + for index in (DECAY_I, DAMPING_I): + self.assertGreen(self.spread(ConvolutionReverb, index, + peak_dbfs=peak), (index, peak)) + + def test_predelay_diffusion_room_and_the_lower_rates(self): + for index in (PREDELAY_I, DIFFUSION_I, ROOM_I): + self.assertGreen(self.spread(ConvolutionReverb, index), index) + for rate in (44100, 22050): + self.assertGreen(self.spread(ConvolutionReverb, DECAY_I, rate), + rate) + + def test_an_unnormalised_room_is_red_at_the_defaults(self): + # Walked by set_macro: the plant's gain follows each re-synthesis. + spread, _, _ = self.spread(UnnormalisedAfter, DECAY_I) + self.assertGreater(spread, 0.5) + + def test_a_constantly_hot_room_is_red_on_the_absolute_clause(self): + # +3 dB at every setting: the spread and rho clauses pass it (the + # frozen criterion's hole, audit round 1: spread 0.010 dB, rho + # 0.118); the absolute clause does not. + spread, level, worst = self.spread(HotRoom, DECAY_I) + self.assertLessEqual(spread, 0.5) + self.assertLess(worst, 0.5) + self.assertGreater(level, 2.5) + + def test_the_hot_room_is_not_on_the_surface(self): + pcm = white(RATE) + result = reach(HotRoom, lambda e: m6_cell(e, pcm)[0], tolerance=1.0) + self.assertGreater(result["target"], 2.5) + self.assertLess(abs(result["clean"]), 0.5) + self.assertEqual(result["checked"], WALKED) + + def test_the_plant_is_not_on_the_surface(self): + pcm = white(RATE) + result = reach(UnnormalisedAfter, lambda e: m6_cell(e, pcm)[0], + tolerance=1.0) + self.assertGreater(result["target"], 1.5) + self.assertLess(abs(result["clean"]), 0.5) + self.assertEqual(result["checked"], WALKED) + + def test_null_build_is_red(self): + pcm = white(int(1.5 * RATE)) + + def measure(cls): + effect = build(cls) + level, r = m6_cell(effect, pcm) + effect.deinit() + return {"passed": abs(level) <= 0.5 and r < 0.5} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D6") + self.assertFalse(result["null"]["passed"]) + + +#: D6 (4)'s bar: each side of a stereo room within 0.01 dB of the other on +#: the room's own impulse (dossier section 3.6). +BALANCE_BAR_DB = 0.01 + +#: Where v0.6.2's node leaned widest (re-audit fix round 1 at v0.6.2): +#: (Decay, Damping, Predelay, Diffusion, Room) as MIDI positions, 0.08 s; +#: the fixed node is held to the bar there too, and on a slice through it. +OLD_WIDEST_CELL = { + 48000: (0, 0, 0, 12, 70), + 44100: (0, 0, 0, 13, 70), + 22050: (0, 0, 0, 22, 70), +} +OLD_WIDEST_OTHER_CELL = (0, 0, 0, 0, 6) + +#: The bound over the walk and the widest setting it found (re-audit fix +#: round 1 at v0.6.3rc2; printed in the docstring until the trial of the +#: second process): L - R +0.0005 dB at 44.1 kHz, Decay 66, Damping 18, +#: Predelay 34, Diffusion 62, Room seed 48. +BALANCE_BOUND_DB = 0.001 +BALANCE_WIDEST = (0.0005, 44100, (66, 18, 34, 62), 48) + + +def build_at_cell(rate, cell, cls=None): + effect = build(cls, rate=rate) + for index, midi in zip((DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, + ROOM_I), cell): + effect.set_macro(index, midi) + return effect + + +def room_midi(seed): + """The Room MIDI position whose seed is `seed` (1 + round(m/127*63)).""" + for midi in range(128): + if 1 + int(round(midi / 127.0 * 63)) == seed: + return midi + raise ValueError(seed) + + +class D6Balance(unittest.TestCase): + """D6 (4), each side on its own (re-audit fix round 1, audiodsp + v0.6.3rc2). Up to v0.6.3rc1 the node scaled a stereo room by the mean + of its two sides' energies, so the left-right balance moved by at + least 5.3 dB, and these tests pinned that disclosure. Since audiodsp#164 + each side is unit energy on its own. They now pin the docstring's + bound and its widest setting to the room, hold v0.6.2's widest cells + and a slice through them to the bar, and show a side tilted after the + node red and out of reach of the surface (SideTilt, 0.5 dB; SideNudge, + 0.05 dB).""" + + def test_the_documented_balance_is_what_the_room_reads(self): + bound = BALANCE_BOUND_DB + widest, rate, cell, seed = BALANCE_WIDEST + self.assertLessEqual(bound, BALANCE_BAR_DB) + self.assertLessEqual(abs(widest), bound) + effect = build_at_cell(rate, cell + (room_midi(seed),)) + side, pooled = balance(effect) + effect.deinit() + self.assertLessEqual(abs(side - widest), 0.00006, (rate, side)) + self.assertLessEqual(abs(pooled), 0.01, rate) + for rate in RATES: + for cell in (OLD_WIDEST_CELL[rate], OLD_WIDEST_OTHER_CELL): + effect = build_at_cell(rate, cell) + side, pooled = balance(effect) + effect.deinit() + self.assertLessEqual(abs(side), bound, (rate, cell, side)) + self.assertLessEqual(abs(pooled), 0.01, (rate, cell)) + + def test_no_cell_of_the_slice_is_past_the_bound(self): + bound = BALANCE_BOUND_DB + for rate in RATES: + diffusion = OLD_WIDEST_CELL[rate][3] + effect = build_at_cell(rate, OLD_WIDEST_CELL[rate]) + readings = [] + for position in range(128): + effect.set_macro(DIFFUSION_I, position) + readings.append(balance(effect)[0]) + effect.set_macro(DIFFUSION_I, diffusion) + for position in range(0, 128, 2): + effect.set_macro(ROOM_I, position) + readings.append(balance(effect)[0]) + effect.deinit() + self.assertLessEqual(max(abs(v) for v in readings), bound, rate) + + def test_a_tilted_side_is_red_and_not_on_the_surface(self): + for plant, gain in ((SideTilt, 0.5), (SideNudge, 0.05)): + for rate in RATES: + effect = build(plant, rate) + side, _ = balance(effect) + effect.deinit() + self.assertGreater(abs(side), BALANCE_BAR_DB, (plant, rate)) + self.assertAlmostEqual(side, gain, delta=0.002) + result = reach(SideNudge, lambda e: balance(e)[0], tolerance=0.02) + self.assertAlmostEqual(result["target"], 0.05, delta=0.002) + self.assertLessEqual(abs(result["clean"]), BALANCE_BAR_DB) + self.assertEqual(result["checked"], WALKED) + + +def one_sided_level(side, cls=None, rate=RATE, **options): + """Pooled wet/dry dB at Mix 2 of the kit's white noise (seed 12345, + -12 dBFS peak, 3 s) on `side` alone, the other side silent, read after + the room has built.""" + effect = build(cls, rate, predelay=0.0, **options) + pcm = white(3 * rate) + pcm[:, 1 - side] = 0 + out = at_mix(effect, 127, pcm) + start = LATENCY + effect.node.taps + effect.deinit() + wet = out[start:].astype(np.float64) + dry = pcm[start - LATENCY:len(pcm) - LATENCY].astype(np.float64) + return float(10 * np.log10(np.mean(wet ** 2) / np.mean(dry ** 2))) + + +class D6OneSided(unittest.TestCase): + """The one-sided example the docstring printed until the trial of the + second process, read at its Rooms (re-audit fix round 1, audiodsp + v0.6.3rc2). Up to v0.6.3rc1 white noise on + one side came back up to 2.7 dB off its level, with a sign that turned + with the Room; since each side is normalised on its own it comes back + within a few tenths. Each printed figure is held to its printed + hundredth, and each within D6's 0.5 dB.""" + + def test_the_documented_one_sided_example_is_what_the_room_reads(self): + corner = dict(decay=0.0, damping_hz=500.0, diffusion=0.0) + cells = [(dict(corner, room=36), -0.06, 0.15), + (dict(corner, room=1), 0.02, -0.21)] + for options, left, right in cells: + for side, printed in ((0, left), (1, right)): + level = one_sided_level(side, **options) + self.assertLessEqual(abs(level - printed), 0.006, + (options, side, level, printed)) + self.assertLessEqual(abs(level), 0.5, (options, side)) + + +# -------------------------------------------------------------------------- +# Tier 1, the fast half +# -------------------------------------------------------------------------- + +class Tier1Fast(unittest.TestCase): + def test_silence_stays_silence(self): + for channels in (2, 1): + effect = build(channels=channels) + for patch in sorted(ConvolutionReverb.PATCHES): + effect.program_change(patch) + out = run(effect, silence(8192, channels)) + self.assertEqual(int(np.max(np.abs(out))), 0, patch) + effect.deinit() + + def test_reset_empties_the_room(self): + # Built at patch 0, reset's program_change(0) finds the room it + # holds and does not re-synthesize, so only the reset clears it. + # Built from the plain defaults, whose exact Damping 6 000 Hz and + # Mix 0.6 are not patch 0's grid values (6 059.8 Hz, 0.598), it + # re-synthesizes. Up to audiodsp v0.6.3rc1 the node emptied itself + # on that re-synthesis and NoReset was inert there (audit round 1: + # peak 0 clean and planted); since v0.6.3rc2 (#163) a re-synthesis + # keeps the history, so NoReset is red from both (re-audit fix + # round 1). + for options in (dict(patch=0), {}): + for cls, silent in ((ConvolutionReverb, True), (NoReset, False)): + effect = build(cls, **options) + burst = np.vstack([white(1024), silence(8192)]) + effect._source.swap(probes.ArraySource(burst, rate=RATE, + channels=2)) + audiocore.reset_buffer(effect.node) + for _ in range(6): + audiocore.get_buffer(effect.output) + effect.reset() + self.assertEqual(effect.patch_index, 0) + out = bytearray() + for _ in range(8): + out += bytes(audiocore.get_buffer(effect.output)[1]) + peak = int(np.max(np.abs(np.frombuffer(bytes(out), + dtype=np.int16)))) + if silent: + self.assertEqual(peak, 0, options) + else: + self.assertGreater(peak, 0, options) + effect.deinit() + + def test_deinit_leaves_the_source(self): + pcm = white(2048) + source = probes.ArraySource(pcm, rate=RATE, channels=2) + effect = ConvolutionReverb(source) + audiocore.get_buffer(effect.output) + node = effect.node + effect.deinit() + effect.deinit() + self.assertTrue(node._deinited) + data = bytes(audiocore.get_buffer(source)[1]) + self.assertGreater(int(np.max(np.abs(np.frombuffer( + data, dtype=np.int16)))), 0) + with self.assertRaises(RuntimeError): + effect.latency_samples + + def test_the_tail_reaches_exact_zero(self): + effect = build(mix=1.2) + burst = np.vstack([white(4096), silence(8192)]) + out = run(effect, burst) + self.assertEqual(int(np.max(np.abs(out[4096 + effect.tail_samples:]))), + 0) + self.assertGreater(int(np.max(np.abs( + out[4096 + effect.tail_samples - 512: + 4096 + effect.tail_samples]))), 0) + effect.deinit() + + +class ImpulseFiles(unittest.TestCase): + """The loader: a 16-bit WAV at the graph's rate, read once.""" + + def write(self, folder, rate, channels, frames): + path = os.path.join(folder, "ir_%d_%d.wav" % (rate, channels)) + with wave.open(path, "wb") as handle: + handle.setnchannels(channels) + handle.setsampwidth(2) + handle.setframerate(rate) + handle.writeframes(make_impulse(frames, channels).tobytes()) + return path + + def test_a_wav_loads_the_same_room_as_its_frames(self): + with tempfile.TemporaryDirectory() as folder: + for channels in (1, 2): + path = self.write(folder, RATE, channels, 900) + from_file = build(impulse=path) + from_bytes = build( + impulse=make_impulse(900, channels).tobytes(), + impulse_channels=channels) + pcm = white(4096) + self.assertEqual(digest(run(from_file, pcm)), + digest(run(from_bytes, pcm))) + self.assertEqual(from_file.node.taps, 1024) + from_file.deinit() + from_bytes.deinit() + + def test_a_wav_at_another_rate_raises_naming_both(self): + with tempfile.TemporaryDirectory() as folder: + path = self.write(folder, 44100, 1, 900) + with self.assertRaises(ValueError) as caught: + build(impulse=path) + self.assertIn("44100", str(caught.exception)) + self.assertIn("48000", str(caught.exception)) + +# -------------------------------------------------------------------------- +# The claims (the trial of the second process, 2026-09-29) +# -------------------------------------------------------------------------- + +#: (sentence, word for word as the class docstring has it, and the test +#: that asserts it). Every sentence in the docstring that makes a claim is +#: here; one that could not be tied to a test was struck. +CLAIMS = ( + ("By default the room is 0.08 s long.", "test_the_laws_at_the_defaults"), + ("Decay is how long the room rings, Damping darkens its tail, Predelay " + "puts silence between the dry and the room, and Diffusion fades the " + "room in instead of starting it as a burst.", + "test_what_each_knob_does"), + ("Room picks one of 64 rooms of the same size.", + "test_room_picks_one_of_64_rooms"), + ("Damping runs from 500 Hz at its bottom stop to out at its top stop.", + "test_what_each_knob_does"), + ("At 22.05 kHz its brightest positions below the top stop clamp and all " + "make the same room.", "test_damping_clamps_at_the_rate"), + ("Mix runs from 0 to 2: the dry at unity up to 1, the room alone at 2.", + "test_the_mix_walk_never_reaches_the_plant"), + ("With Damping out, each of the 64 Rooms falls 60 dB within 3 % of the " + "Decay time.", "test_damping_out_every_room_within_3_percent"), + ("With Damping in, the 64 Rooms fall 60 dB within 2 % of the Decay time " + "on average.", "test_damping_in_the_64_room_mean_within_2_percent"), + ("A single Room with Damping in can take more than 15 % longer.", + "test_a_single_room_can_take_more_than_15_percent_longer"), + ("At Damping's 500 Hz stop, low material comes back louder than it went " + "in.", "test_a_dark_room_lifts_low_material"), + ("Each side of a stereo room is normalised on its own, so the room sits " + "in the middle.", "test_no_cell_of_the_slice_is_past_the_bound"), + ("Hand it `impulse=`, int16 frames or the path to a 16-bit PCM WAV at " + "the graph's rate, and the room is that recording.", + "test_a_wav_loads_the_same_room_as_its_frames"), + ("The room is then your source convolved with the impulse at unit " + "energy, within 1 LSB.", + "test_within_one_lsb_across_layouts_levels_gain_and_trim"), + ("`ir_gain_db` trims it from -24 to +12 dB, and `start_ms` cuts up to " + "200 ms from its start.", "test_impulse_level_and_shape_are_checked"), + ("Only Mix is live then: the other five knobs raise `IndexError`.", + "test_measured_mode_refuses_the_synthesis_macros"), + ("A WAV at another rate raises `ValueError`, and so does an impulse with " + "no energy or a `start_ms` that trims away every frame.", + "test_what_measured_mode_refuses"), + ("An empty impulse, `impulse=b\"\"`, is an undelayed wire whose Mix does " + "nothing and whose `reset()` silences nothing.", + "test_an_empty_impulse_is_a_wire_whatever_mix_and_reset"), + ("`seconds` is the longest room the instance can hold, carved once when " + "you build it: from 0.06 s up to 131 072 frames, and outside that the " + "constructor raises `ValueError`.", + "test_every_cell_builds_or_raises_by_the_law"), + ("`latency_samples` reads 256 while an impulse is loaded and 0 on the " + "empty impulse.", "test_loaded_every_patch_and_the_predelay_stop"), + ("Held at Mix 0, the output is your source, byte for byte, " + "`latency_samples` late, while the source keeps feeding it and nothing " + "resets it.", "test_every_macro_stop_and_patch_with_mix_at_0"), + ("`tail_samples` is `latency_samples` plus the loaded room rounded up to " + "a whole block of 256 frames.", + "test_tail_is_latency_plus_the_loaded_impulse"), + ("More than `tail_samples` frames after your input's last non-zero " + "frame, the output is exact zero.", + "test_the_tail_counts_the_frames_the_source_hands"), + ("No frame of your dry signal drops or repeats when you move a room " + "knob, at any Mix, however many moves you make.", + "test_no_dry_frame_drops_after_any_number_of_changes"), + ("From the end of the block in flight, the output is that of an " + "instance that always had the new settings.", + "test_every_move_has_landed_from_the_end_of_the_block_in_flight"), + ("A Mix move acts from the end of the block in flight, so Mix 0 reaches " + "the plain source up to 256 frames late.", + "test_a_mix_move_lands_a_partition_late_and_reset_drops_one"), + ("`reset()` empties the room and returns to patch 0.", + "test_reset_empties_the_room"), + ("With an impulse loaded, `reset()` in the middle of a stream silences " + "the block in flight, 256 frames, dry included, and with Mix set back " + "to 0 your source carries on on time after it.", + "test_a_reset_keeps_the_whole_frames_the_node_holds"), + ("A host that calls `audiocore.reset_buffer` on the output silences the " + "block in flight too, but also drops the frames the node holds from a " + "source buffer it had not finished.", + "test_a_host_reset_buffer_silences_the_block_and_drops_held_frames"), +) + +#: The family's two limits, ruled by Brad on 2026-09-28, word for word. +FAMILY = ( + "A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "The tail rings only while the source keeps feeding: feed silence to let " + "it ring out. A tail cut short by a source that stopped carries on when " + "the source comes back.", +) + + +def _flat(text): + return " ".join((text or "").split()) + + +def chord(rate, frames, channels): + """220, 277 and 330 Hz summed, at an 8 000 LSB peak.""" + t = np.arange(frames) / float(rate) + x = sum(np.sin(2 * np.pi * hz * t) for hz in (220.0, 277.0, 330.0)) + x = np.round(8000.0 * x / np.max(np.abs(x))).astype(np.int16) + return np.repeat(x[:, None], channels, axis=1) + + +def wire_render(cls, rate, channels, pcm, size, at, action, blocks=40, + **options): + """`blocks` pulls at Mix 0 from a source in `size`-frame calls; + `action(effect)` just before pull `at`, then Mix put back to 0. Returns + the output and the frame where pull `at` starts.""" + effect = build(cls, rate, channels, mix=0.0, **options) + effect._source.swap(DryOnce(pcm, rate, channels, None, size)) + audiocore.reset_buffer(effect.node) + out = [] + for number in range(blocks): + if number == at: + action(effect) + effect.set_macro(MIX_I, 0) + out.append(np.frombuffer(bytes(audiocore.get_buffer(effect.output)[1]), + dtype=np.int16).reshape(-1, channels)) + effect.deinit() + return np.vstack(out), at * 256 + + +def counting(frames, channels): + """A source with no zero frame and no two frames alike nearby.""" + pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype(np.int16) + pcm[pcm == 0] = 1 + return np.repeat(pcm[:, None], channels, axis=1) + + +class TheClaims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + doc = _flat(ConvolutionReverb.__doc__) + tests = set() + for value in globals().values(): + if isinstance(value, type) and issubclass(value, + unittest.TestCase): + tests.update(n for n in dir(value) if n.startswith("test_")) + rest = doc + for sentence, test in CLAIMS: + self.assertIn(sentence, doc, sentence) + self.assertIn(test, tests, sentence) + rest = rest.replace(sentence, " ") + self.assertIn("**Limits shared by the family.**", doc) + for sentence in FAMILY: + self.assertIn(sentence, doc, sentence) + numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] + self.assertEqual(numbers, []) + + def test_what_each_knob_does(self): + # On the room's own impulse (a click at Mix 2, 48 kHz): Decay 127 + # rings longer than Decay 0; Damping at its 500 Hz bottom stop + # leaves under a fifth of the share of the tail's second half above + # 4 kHz that its top stop leaves, which + # hands the node no roll-off at all; Predelay 127 leaves 15 ms of + # silence after the latency before the room, Predelay 0 none; and + # Diffusion 127 starts the room at least 6 dB quieter over its first + # 5 ms than Diffusion 0, with the same energy overall. + def impulse(**options): + effect = build(mix=2.0, **options) + taps = effect.node.taps + out = run(effect, click(LATENCY + taps + 256)) + synthesis = effect._synthesis() + effect.deinit() + return out[LATENCY:LATENCY + taps].astype(np.float64), synthesis + + long_room, _ = impulse(decay=1.0, damping_hz=0.0) + short_room, _ = impulse(decay=0.0, damping_hz=0.0) + self.assertGreater(schroeder_t60(np.sum(long_room ** 2, axis=1), RATE), + 1.4 * schroeder_t60(np.sum(short_room ** 2, axis=1), + RATE)) + + def top(ir): + ir = ir[len(ir) // 2:] # the tail's second half + spectrum = np.abs(np.fft.rfft(ir[:, 0])) ** 2 + hz = np.fft.rfftfreq(len(ir), 1.0 / RATE) + return float(np.sum(spectrum[hz > 4000.0]) / np.sum(spectrum)) + + dark, synthesis = impulse(damping_hz=500.0) + self.assertAlmostEqual(synthesis[1], 500.0, places=6) + effect = build() + effect.set_macro(DAMPING_I, 0) + self.assertAlmostEqual(effect._synthesis()[1], 500.0, places=6) + effect.set_macro(DAMPING_I, 127) + self.assertEqual(effect._synthesis()[1], 0.0) + effect.deinit() + bright, synthesis = impulse(damping_hz=0.0) + self.assertEqual(synthesis[1], 0.0) + self.assertLess(top(dark), 0.2 * top(bright)) + + late, _ = impulse(predelay=1.0, diffusion=0.0) + early, _ = impulse(predelay=0.0, diffusion=0.0) + self.assertEqual(first_arrival(late), 720) + self.assertEqual(first_arrival(early), 0) + + soft, _ = impulse(diffusion=1.0) + hard, _ = impulse(diffusion=0.0) + head = int(0.005 * RATE) + self.assertLess(10 * np.log10(np.sum(soft[:head] ** 2) + / np.sum(hard[:head] ** 2)), -6.0) + + def test_room_picks_one_of_64_rooms(self): + # Walked by set_macro over every position: 64 seeds, 64 impulses no + # two alike, all on one allocation, and each on the Decay law with + # Damping out (D5 walks that within 3 %). + effect = build(damping_hz=0.0) + taps = effect.node.taps + pulse = click(LATENCY + taps + 256) + seeds, rooms = set(), set() + for midi in range(128): + effect.set_macro(ROOM_I, midi) + seeds.add(effect._synthesis()[4]) + rooms.add(digest(at_mix(effect, 127, pulse))) + self.assertEqual(effect.node.taps, taps) + effect.deinit() + self.assertEqual(seeds, set(range(1, 65))) + self.assertEqual(len(rooms), 64) + + def test_a_dark_room_lifts_low_material(self): + # A low chord at Mix 2, the level after the room has built, at + # Damping's 500 Hz stop: louder than the dry at three rates, stereo + # and mono, at 16 Rooms and three Decays (the least lift over that + # walk is about 2 dB). No number is claimed: the lift turns with the + # Room. + least = None + for rate in RATES: + for channels in (2, 1): + pcm = chord(rate, int(1.5 * rate), channels) + for decay in (0.0, 0.5, 1.0): + effect = build(rate=rate, channels=channels, decay=decay, + damping_hz=500.0) + for room in range(0, 128, 8): + effect.set_macro(ROOM_I, room) + level, _ = m6_cell(effect, pcm) + least = level if least is None else min(least, level) + self.assertGreater(level, 0.0, + (rate, channels, decay, room)) + effect.deinit() + self.assertGreater(least, 1.0) + + def test_what_measured_mode_refuses(self): + with tempfile.TemporaryDirectory() as folder: + path = ImpulseFiles().write(folder, 44100, 1, 900) + with self.assertRaises(ValueError): + build(impulse=path) + with self.assertRaises(ValueError): + build(impulse=np.zeros(512, dtype=np.int16).tobytes()) + with self.assertRaises(ValueError): + build(impulse=make_impulse(100).tobytes(), start_ms=2.1) + + def test_an_empty_impulse_is_a_wire_whatever_mix_and_reset(self): + # `impulse=b""`: at Mix 0, 0.6, 1.2 and 2, across a reset() before + # pull 20 (Mix put back after it) and a host `reset_buffer` on the + # output before pull 25, the output is the source frame for frame, + # with no latency and no frame silenced. ResetSilentOnEmpty (a + # reset that plays 256 frames of silence) is red. + for rate in (48000, 22050): + for channels in (2, 1): + pcm = counting(40 * 256, channels) + for cls in (None, ResetSilentOnEmpty): + for midi in (0, 38, 76, 127): + effect = build(cls, rate, channels, impulse=b"") + effect.set_macro(MIX_I, midi) + effect._source.swap(probes.ArraySource( + pcm, rate=rate, channels=channels)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for number in range(40): + if number == 20: + effect.reset() + effect.set_macro(MIX_I, midi) + if number == 25: + audiocore.reset_buffer(effect.output) + out += bytes(audiocore.get_buffer( + effect.output)[1]) + effect.deinit() + out = np.frombuffer(bytes(out), dtype=np.int16 + ).reshape(-1, channels) + label = (rate, channels, cls, midi) + if cls is None: + self.assertEqual(digest(out), digest(pcm), label) + else: + self.assertNotEqual(digest(out), digest(pcm), + label) + + def test_a_reset_keeps_the_whole_frames_the_node_holds(self): + # The re-audit's ask: a reset() while the node holds part of a + # source buffer. From sources in 512-, 100- and 256-frame calls, at + # Mix 0, the reset before pull 20 or 21 (with 512-frame calls pull + # 21 finds the node holding half a buffer; with 100-frame calls + # both do): the 256 frames after it are exact zero and every other + # frame is the source on time. ReplugOnReset (the source plugged + # back into the node on reset, which drops what the node held) is + # red wherever the node held frames, and clean where it held none. + for rate in (48000, 22050): + for channels in (2, 1): + pcm = counting(40 * 256, channels) + wire = np.vstack([silence(LATENCY, channels), pcm]) + for size in (512, 100, 256): + for at in (20, 21): + held = (at * 256) % size != 0 + for cls in (None, ReplugOnReset): + out, a = wire_render(cls, rate, channels, pcm, + size, at, + lambda e: e.reset()) + want = wire[:len(out)].copy() + want[a:a + 256] = 0 + label = (rate, channels, size, at, cls) + if cls is None or not held: + self.assertEqual(digest(out), digest(want), + label) + else: + self.assertNotEqual(digest(out), + digest(want), label) + + def test_a_host_reset_buffer_silences_the_block_and_drops_held_frames( + self): + # A host `audiocore.reset_buffer(effect.output)` before pull 21, at + # Mix 0: the 256 frames after it are exact zero, and from then on + # the output is the source moved on by the frames the node held of + # its current buffer: none from 256-frame calls, 256 from 512-frame + # calls, 24 from 100-frame calls (54 calls handed 5 400 frames and + # the node had taken 5 376). audiodsp: the node's reset drops them. + for rate in (48000, 22050): + for channels in (2, 1): + pcm = counting(44 * 256, channels) + for size, dropped in ((256, 0), (512, 256), (100, 24)): + out, a = wire_render( + None, rate, channels, pcm, size, 21, + lambda e: audiocore.reset_buffer(e.output)) + label = (rate, channels, size) + wire = np.vstack([silence(LATENCY, channels), pcm]) + self.assertEqual(digest(out[:a]), digest(wire[:a]), label) + self.assertEqual(int(np.max(np.abs(out[a:a + 256]))), 0, + label) + rest = out[a + 256:] + self.assertEqual( + digest(rest), + digest(pcm[a + dropped:a + dropped + len(rest)]), + label) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_deesser.py b/tests/test_cpython_effects_deesser.py index 917d224..d91f569 100644 --- a/tests/test_cpython_effects_deesser.py +++ b/tests/test_cpython_effects_deesser.py @@ -1209,16 +1209,18 @@ def test_wire_goes_red_on_a_one_lsb_dry_path(self): effect.deinit() effect2.deinit() - def test_wire_goes_red_on_a_dry_voice_at_unity(self): - """audiodsp#95's fault, and the probe the row above cannot use. - - A mixer voice at level 1.0 is not unity - upstream's Q15 level is - `1.0 * 32768` and the kernel divides by 32767 - so the dry voice at - unity came out one LSB high at every sample from 32736 up. The - committed `ramp_fs` probe never puts a sample strictly inside that - window, so the fault was invisible to it and to this whole file - (see `fine_ramp_fs`). On a monotone full-scale ramp it is 7 of - 16384, all in the right channel, and the class is exact. + def test_the_dry_voice_at_unity_is_a_wire_since_audiodsp_v0_6_1(self): + """audiodsp#95's plant, kept as a control now that it cannot fire. + + A mixer voice at level 1.0 was not unity - upstream's Q15 level was + `1.0 * 32768` and the kernel divided by 32767 - so the dry voice at + unity came out one LSB high at every sample from 32736 up, 7 of + 16384 on `fine_ramp_fs`, all in the right channel. audiodsp v0.6.1 + (#129) made the voice at 1.0 exact and the pin moved there on + 2026-09-27 (`AUDIODSP_PIN`). The row above keeps its fault, the + one-LSB voice; this is the floor's own control, on the probe the + old defect was visible to. If it goes red the floor has moved back + under the suite. """ rate = 48000 values = fine_ramp_fs(8192) @@ -1233,9 +1235,9 @@ def test_wire_goes_red_on_a_dry_voice_at_unity(self): cls=ThroughTheDryVoice, range_db=0.0) result = M.wire(render(effect2.output, 8192, rate), dry) effect2.deinit() - print("\n WIRE unity fault: %s" % result["values"]) - self.assertNotEqual(result["red"], []) - self.assertEqual(result["values"]["max_abs_difference_lsb"], 1) + print("\n WIRE unity control: %s" % result["values"]) + self.assertEqual(result["red"], [], result["values"]) + self.assertEqual(result["values"]["differing_samples"], 0) def test_tail_goes_red_on_a_held_dc_state(self): rate = 48000 diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py new file mode 100644 index 0000000..b2fda1b --- /dev/null +++ b/tests/test_cpython_effects_digitaldelay.py @@ -0,0 +1,3047 @@ +"""`DigitalDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/DigitalDelay.md` +(frozen at anchor 51207b8); its Tier 2 rows are T1-T5. Each row here is the +measurement at a few of the row's cells and the same measurement shown red +on a planted fault of the same kind, at the constructor defaults. Every +fault is shown unreachable from every macro position and shipped patch, and +every row's measurement is shown red on the class built as a wire. The full +spans, the three interpreters and the rates live in the evidence pack, not +in this file. + +The class is reached by `rebuilt.module_class("DigitalDelay")`, which is also what +`audioeffects.DigitalDelay` serves since its adoption on 2026-09-28. + +Fix round 1 (2026-09-27, after gate audit round 1): T2's pitch is read by a +local least-squares fit against the dossier's own law (787.5 / glide_ms, +written out here, never the class's `slew_of`), with a planted wrong Glide +law; T1 has a dry fault that shows below -6 dBFS; T5's compounding clause +has a measurement and an out-of-loop fault, and its out-stop fault is one +no position dials at any rate; every fault's reachability walk runs at +48, 44.1 and 22.05 kHz with a reading of what the node is handed (or what +the output does) at the position walked. The input ceiling, the Glide +round trip, a 0 bpm host and Repeat Tone's clamp at 22.05 kHz each have a +test beside a planted fault. + +Fix round 2 (2026-09-27, after gate audit round 2): the input ceiling is +rendered over 20 s at -3.1 dBFS and shown red at the old -3.0; the Glide +readback survives a 7-bit round trip through `_component.macro_of`, beside +the round-2 seeding; a NaN or infinite tempo leaves Time on the knob, +beside the round-2 guard; T5's Tone-compounding fault `PostToneDelay` is +here and in the reachability walks; and T2's part of the knob the row no +longer claims (rising, strictly between Glide grid 1 and 2) is tested by a +float32 model of the node's walk and by rendered fractional positions. + +Re-audit fix round 1 (2026-09-27, after the pin moved to audiodsp v0.6.2 +and gate audit round 3): `tail_samples` is finite at every Feedback with +the loop filters out, and graded on the old floor cell and on full scale +beside the old `None` and a bound one lap short; with Repeat Tone in, the +Feedback values where the node holds a value for ever are shown, where the +class says `None`, beside fix round 2's one-extra-lap rule. T2's rising +edge moves from Glide grid 2 to grid 3, placed by rendering every rounding +class of the node's walk, and the edge test goes red at the old edge. A +constructor Glide slower than 8 s reads back as the 8 s it plays, and a +constructor Time, Repeat Tone or Repeat Cut of 0 builds. + +Re-audit fix round 2 (2026-09-27, after gate re-audit 1): with Repeat Tone +in circuit the class hands the node a Feedback just outside its own stall +windows, and every stall cell and window centre reaches exact zero inside +a finite `tail_samples`, beside re-audit fix round 1's class, which said +`None` there and held 1-5 LSB. T2's pitch clause is read per binade of the +node's walk (`t2_segments`), which goes red at grid 3.0079 where the +whole-walk fit passed, and the rising edge moves to Glide grid 4. A NaN +`glide_ms` is the jump and a NaN `max_time_ms` is 800 ms. + +The pin's move to audiodsp v0.6.3rc1 (2026-09-28): the node keeps an out +loop filter's state live and lands a stalled damping state (audiodsp#158, +#159, #157), so the class's tracking out stop and its stall-window stepping +came out. The tests that pinned the old node or proved a workaround was +needed now assert the good behaviour directly: either filter back in after +silence is silent (planted: a filter left in at 0.001 Hz, frozen), both out +stops hand exactly 0 and render a fresh instance's bytes (planted: the +retired tracking stop), every stall cell reaches zero at the Feedback set +inside a finite bound (planted: the retired stepping, and the v0.6.2 +reckoning that says `None`). Two surface tests pin the Times the node lands +off the whole frame at 44.1 and 22.05 kHz, as `PingPongDelay`'s do. + +The trial of the second process (2026-09-29, at audiodsp v0.6.3rc3): the +module docstring is cut to the sentences `CLAIMS` ties to a test, and +`Claims` holds it there (every sentence present, every named test real, no +figure outside a claim, and the check shown able to fail). `TrialClaims` +asserts the claims no earlier test did: Glide 0's click beside the default +Glide's bend, a Mix jump that steps beside the same move in small steps, +the tail that waits for its source, Mix 2 as the repeats alone, `None` +with Repeat Cut in, patch 5's corners and the `ImportError` without +`audioecho`. The lifecycle matrix's red cells for the class are declared +as the two family limits (audiocomponents#117, audiodsp#180). +""" + +import math +import os +import re +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +import lifecycle # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import digitaldelay as dd # noqa: E402 +from tools import effect_measurements as kit # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +BLOCK = 256 +TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, SYNC_I, DIVISION_I, TONE_I, CUT_I = \ + range(8) + +DigitalDelay = rebuilt.module_class("DigitalDelay") + + +def midi_of_ms(time_ms): + """Time's MIDI position for `time_ms`, unquantised.""" + return 127.0 * math.log(time_ms / 12.5) / math.log(64.0) + + +def mapped_frames(position, rate): + """The dossier's T4 law, written out independently of the class: + 12.5 * 64^position ms, landed on the nearest whole frame.""" + time_ms = 12.5 * 64.0 ** position + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +# -------------------------------------------------------------------------- +# Planted faults, one per Tier 2 row, each of the row's own kind + + +class DryScaledDelay(DigitalDelay): + """T1: the dry path coloured - the output scaled by 32767/32768, one + LSB above -6 dBFS (`kit_faults.OneLsbScale`, WIRE's own fault).""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + DigitalDelay._build(self, *arguments, **options) + self._plant_dry_scale = True + self._output = kit_faults.OneLsbScale(self._delay) + + +class DryGainDelay(DigitalDelay): + """T1 at every level the row names: the output +0.1 dB + (`kit_faults.HiddenGain`, LEVEL's own fault). `OneLsbScale` is the + identity below 16 384 LSB, so on the -20 and -40 dBFS materials only + this one can go red.""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + DigitalDelay._build(self, *arguments, **options) + self._output = kit_faults.HiddenGain(self._delay, 0.1) + + +class _Stepper: + """Pulls the owner's node, stepping its delay from Python first: the + finest a Python-driven Time can move is once per block.""" + + def __init__(self, owner): + self._owner = owner + node = owner._delay + self.sample_rate = node.sample_rate + self.channel_count = node.channel_count + self.bits_per_sample = 16 + self.samples_signed = True + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + audiocore.reset_buffer(self._owner._delay) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self._owner._step() + return audiocore.get_buffer(self._owner._delay, + single_channel_output, audio_channel) + + +class StaircaseDelay(DigitalDelay): + """T2: route (b), A8.8 - `delay_ms` stepped from Python once per + 256-frame block with the node's slew off, at the rate the Glide asks + for. No macro position reaches it: Glide grid 0 is one jump, and every + other position is the node's own per-frame walk.""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + self._current_ms = None + DigitalDelay._build(self, *arguments, **options) + self._output = _Stepper(self) + + def _refresh(self): + DigitalDelay._refresh(self) + if self._current_ms is None: + self._current_ms = self._node_ms + slew = dd.slew_of(self._glide_ms()) + self._step_per_block = slew * BLOCK * 1000.0 / self._sample_rate + self._delay.set(delay_slew=0.0, delay_ms=self._current_ms) + + def _step(self): + target = self._node_ms + step = self._step_per_block + current = self._current_ms + if current == target: + return + if step <= 0.0 or abs(target - current) <= step: + current = target + elif target > current: + current += step + else: + current -= step + self._current_ms = current + self._delay.set(delay_ms=current) + + +class HalfGlideMsDelay(DigitalDelay): + """T2: the Glide law wrong - the knob's milliseconds halved, so the node + walks at twice the dossier's 787.5 / glide_ms. A measurement that took + its law from the class's own `slew_of` could not see it.""" + + NAME = 'DigitalDelay' + + def _glide_ms(self): + return 0.5 * DigitalDelay._glide_ms(self) + + +class HalfFrameDelay(DigitalDelay): + """T3: Time handed to the node half a frame off the whole frame, so the + read interpolator takes the top of the band down on every pass - the + per-pass loss the whole-frame law designs out.""" + + NAME = 'DigitalDelay' + + def _node_time_ms(self, frames): + return (frames + 0.5) * 1000.0 / self._sample_rate + + +class LinearMapDelay(DigitalDelay): + """T4: the Time map linear in the macro instead of log - the same knob + position means different milliseconds than the stated law.""" + + NAME = 'DigitalDelay' + + def _time_map(self, position): + return 12.5 + 787.5 * position + + +class OpenTopToneDelay(DigitalDelay): + """Superseded at fix round 1, kept so the round-1 probes still import: + Repeat Tone's top stop pre-warped from the clamped 16 kHz. At 22.05 kHz + that is 6 183.7, exactly what Tone positions 111-126 hand the node, so + the surface dials it there. `RawTopToneDelay` replaces it.""" + + NAME = 'DigitalDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return nominal_damping_hz(self._hz(16000.0), self._sample_rate) + return DigitalDelay._tone_damping(self, position) + + +class RawTopToneDelay(DigitalDelay): + """T5's out stop: Repeat Tone's top stop hands the node the span's top + corner, clamped below Nyquist, as a raw `damping_hz` (16 000 / 16 000 / + 10 804.5 at 48 / 44.1 / 22.05 kHz) instead of exactly 0. Every in-circuit + position hands a pre-warped value, which is lower at every rate, so no + position reaches it.""" + + NAME = 'DigitalDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return self._hz(16000.0) + return DigitalDelay._tone_damping(self, position) + + +class CornerShiftDelay(DigitalDelay): + """T5's corner clauses: both filters pre-warped for a corner 15 % above + the label. Both filters are out at the defaults, so it is inert there by + design; it is read at the corner cells.""" + + NAME = 'DigitalDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return 0.0 + corner = 1.15 * _component.macro_value(self._MACRO_RANGES[TONE_I], + position) + return nominal_damping_hz(self._hz(corner), self._sample_rate) + + def _cut_hz(self, position): + if position <= 0.0: + return 0.0 + corner = 1.15 * _component.macro_value(self._MACRO_RANGES[CUT_I], + position) + return dd.nominal_cut_hz(self._hz(corner), self._sample_rate) + + +class PostCutDelay(DigitalDelay): + """T5's compounding clause: Repeat Cut moved out of the loop onto the + output - the same one-pole, the same pre-warp, applied once. One pass + is identical to the class; the repeats do not compound.""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + self._post = 0.0 + self._hp = [0.0, 0.0] + DigitalDelay._build(self, *arguments, **options) + self._output = _PostHighPass(self) + + def _cut_hz(self, position): + self._post = DigitalDelay._cut_hz(self, position) + return 0.0 + + +class _PostHighPass: + def __init__(self, owner): + self._owner = owner + node = owner._delay + self.sample_rate = node.sample_rate + self.channel_count = node.channel_count + self.bits_per_sample = 16 + self.samples_signed = True + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + audiocore.reset_buffer(self._owner._delay) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + owner = self._owner + result, data = audiocore.get_buffer(owner._delay) + raw = bytes(data) + if not raw or owner._post <= 0.0: + return result, memoryview(raw) + a = 1.0 - math.exp(-2.0 * math.pi * owner._post / owner._sample_rate) + v = np.frombuffer(raw, dtype="= 0, np.trunc(out + 0.5), + np.trunc(out - 0.5)), -32768, 32767) + return result, memoryview(out.astype("= 0, np.trunc(out + 0.5), + np.trunc(out - 0.5)), -32768, 32767) + return result, memoryview(out.astype(" 0.0 + and self._glide_exact <= 800.0): + self._macros[GLIDE_I] = 1e-9 + + +class NanBpmDelay(DigitalDelay): + """The round-2 tempo guard, `bpm <= 0.0`, which a NaN passes and an + infinite tempo passes too: both drag Time to the bottom of the knob.""" + + NAME = 'DigitalDelay' + + def _synced_ms(self): + transport, state = self._transport_state() + if transport is _component.static_transport: + return None + bpm = float(state[2] or 0.0) + if bpm <= 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(dd.DIVISION_BEATS) - 1, max(0, index)) + return dd.DIVISION_BEATS[index] * 60000.0 / bpm + + +class PostToneDelay(DigitalDelay): + """T5's compounding clause, Tone half: Repeat Tone's one-pole low-pass + moved out of the loop onto the output - the same pre-warped + coefficient, applied once. One pass is identical to the class; the + repeats do not compound, so T3's control stops darkening. (The + re-refuter's fault, `digitaldelay_rerefute1_t5.py`, moved here at fix + round 2.)""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + self._post_lp = 0.0 + self._lp = [0.0, 0.0] + DigitalDelay._build(self, *arguments, **options) + self._output = _PostLowPass(self) + + def _tone_damping(self, position): + self._post_lp = DigitalDelay._tone_damping(self, position) + return 0.0 + + +# -------------------------------------------------------------------------- +# Planted faults for the Tier 1 checks the review round added + + +class SixtyDbTailDelay(DigitalDelay): + """The first build's tail: the -60 dB lap count plus one lap, which a + -6 dBFS burst outlives on its way down to exact zero.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + frames = self._frames + if self._feedback <= 0.0: + return int(frames) + laps = 1.0 + 3.0 * math.log(10.0) / -math.log(self._feedback) + return int(math.ceil(frames * laps)) + + +class FloorEraTailDelay(DigitalDelay): + """The declaration the class made up to audiodsp v0.6.1: `None` from + Feedback 0.5 up, where that node's rounding could hold 1 LSB in the + line for ever. From v0.6.2 a bound holds there, so `None` is a claim + the class no longer has any cause to make.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + if self._feedback >= 0.5: + return None + return DigitalDelay._tail_bound(self) + + +class LapShortTailDelay(DigitalDelay): + """The bound one lap short. Full-scale material lasts exactly the + lap count, so it outlives this.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + declared = DigitalDelay._tail_bound(self) + if declared is None: + return None + return declared - (self._reach + 1) + + +class OneLapToneDelay(DigitalDelay): + """Fix round 2's tail with Repeat Tone in: the lap count as if the + filter were out, plus one lap. The low-pass can hold a small value in + the line for ever at some Feedback values, and it makes other tails + longer than one lap more.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + if self._macros[CUT_I] > 0.0: + return None + laps = dd.laps_to_zero(self._feedback) + if self._macros[TONE_I] < 1.0: + laps += 1 + return int(laps * (self._reach + 1)) + + +class SteppedToneDelay(DigitalDelay): + """The workaround retired at audiodsp v0.6.3rc1: with Repeat Tone in, + the node handed the nearer edge of a stall window (`clear_of_stalls`) + instead of the Feedback asked for, up to 2.6 x 10^-5 away. On the + fixed node it plays a Feedback nobody set.""" + + NAME = 'DigitalDelay' + + def _refresh(self): + DigitalDelay._refresh(self) + if self._damping > 0.0: + self._feedback = dd.clear_of_stalls(self._feedback, + self._tone_excess()[1]) + self._delay.set(feedback=self._feedback) + + +class WindowNoneToneDelay(DigitalDelay): + """The v0.6.2 reckoning with the stepping gone: `None` wherever a lap + hands a peak back, the node's landing not counted. Declares no bound at + every stall cell.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + if self._macros[CUT_I] > 0.0: + return None + memory, excess = self._tone_excess() + if dd.stall_window(self._feedback, excess) is not None: + return None + return int(dd.laps_to_zero(self._feedback, excess) + * (self._reach + 1 + memory)) + + +class _ToneMemory: + """Remembers whether Repeat Tone has been in since the last clear, for + the out-stop faults below (the class itself no longer needs to).""" + + def _refresh(self): + DigitalDelay._refresh(self) + if self._damping > 0.0: + self._was_in = True + self._after_refresh() + + def _clear(self): + DigitalDelay._clear(self) + self._was_in = False + + def _out_after_in(self): + return getattr(self, "_was_in", False) and \ + self._macros[TONE_I] >= 1.0 + + +class FrozenToneDelay(DigitalDelay): + """Tier 1's silence clause after a Repeat Tone move: the out stop + leaves the low-pass in at 0.001 Hz, where its float32 coefficient is + one step above 0 and the state cannot move, so it holds what it held, + as the node's out stop did up to v0.6.2, and plays it out of silence + (26 112 LSB after the move at Mix 2, 48 kHz, and already before it).""" + + NAME = 'DigitalDelay' + + def _refresh(self): + DigitalDelay._refresh(self) + if self._macros[TONE_I] >= 1.0: + self._delay.set(damping_hz=0.001) + + +class FrozenCutDelay(DigitalDelay): + """The same for Repeat Cut: the out stop leaves the high-pass in at + 0.001 Hz, its state frozen (20 666 LSB after the move at 48 kHz).""" + + NAME = 'DigitalDelay' + + def _refresh(self): + DigitalDelay._refresh(self) + if self._macros[CUT_I] <= 0.0: + self._delay.set(cut_hz=0.001) + + +class TrackingToneDelay(_ToneMemory, DigitalDelay): + """The workaround retired at audiodsp v0.6.3rc1: once Repeat Tone has + been in, the out stop hands `damping_hz` at 32 x the rate (a + coefficient of exactly 1) instead of 0. On the fixed node it is the + filter out only up to float rounding.""" + + NAME = 'DigitalDelay' + + def _after_refresh(self): + if self._out_after_in(): + self._delay.set(damping_hz=32.0 * self._sample_rate) + + +class LeakyTrackDelay(_ToneMemory, DigitalDelay): + """The out stop after Repeat Tone has been in, tracking the tap at a + coefficient under 1: `damping_hz` at half the rate (a = 1 - e^-pi, + 0.957), a low-pass left in circuit where the knob says out.""" + + NAME = 'DigitalDelay' + + def _after_refresh(self): + if self._out_after_in(): + self._delay.set(damping_hz=0.5 * self._sample_rate) + + +class SlowSeedGlideDelay(DigitalDelay): + """The seeding up to re-audit fix round 1: a constructor Glide slower + than 8 s stayed on the audio path while the knob read 8 s, so handing + `get_macro(3)` back sped the walk up (16 s: +100 %).""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + DigitalDelay._build(self, *arguments, **options) + glide_ms = float(options.get("glide_ms", 4000.0)) + if glide_ms > dd.GLIDE_MAX_MS and self._glide_exact is not None: + self._glide_exact = glide_ms + + +class TargetOnlyTailDelay(DigitalDelay): + """The tail from the target Time alone, while the read head is still + walking down from the old one.""" + + NAME = 'DigitalDelay' + + def _refresh(self): + DigitalDelay._refresh(self) + self._reach = self._frames + + +class PerMacroPatchDelay(DigitalDelay): + """A patch applied one macro at a time with a refresh after each, so + Time is read against the outgoing patch's Sync.""" + + NAME = 'DigitalDelay' + program_change = _component.Component.program_change + + +# -------------------------------------------------------------------------- +# Sources and pulls + + +def array_src(values, channels=2, rate=RATE): + data = array("h") + for value in values: + for _ in range(channels): + data.append(int(value)) + return probes.ArraySource(data, rate=rate, channels=channels, block=BLOCK) + + +def silence_src(frames, channels=2, rate=RATE): + return probes.ArraySource(array("h", [0] * (frames * channels)), + rate=rate, channels=channels, block=BLOCK) + + +def sine_values(hz, frames, rate, level): + return [int(round(level * math.sin(2.0 * math.pi * hz * n / rate))) + for n in range(frames)] + + +def pull(effect, frames, channels=None, on_block=None): + """Interleaved int16 of `frames` frames; `on_block(frame)` runs before + each block is pulled.""" + channels = channels or effect.channel_count + out = array("h") + while len(out) < frames * channels: + if on_block is not None: + on_block(len(out) // channels) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + out.extend([0] * (frames * channels - len(out))) + break + out.extend(memoryview(data).cast("h")) + return np.array(out[:frames * channels], dtype=np.int16) + + +def left(interleaved, channels): + return interleaved[0::channels].astype(np.float64) + + +def inst_hz(x, rate): + x = np.asarray(x, dtype=np.float64) + n = len(x) + spec = np.fft.fft(x) + h = np.zeros(n) + h[0] = 1.0 + if n % 2 == 0: + h[n // 2] = 1.0 + h[1:n // 2] = 2.0 + else: + h[1:(n + 1) // 2] = 2.0 + phase = np.unwrap(np.angle(np.fft.ifft(spec * h))) + return np.diff(phase) * rate / (2.0 * math.pi) + + +def cents(ratio): + return 1200.0 * math.log(ratio) / math.log(2.0) + + +def laps_to_exact_zero(feedback, peak=32768.0): + """Laps of the line after which no sample can be non-zero, on audiodsp + v0.6.2 and later, at any Feedback below the node's 1.0. + + Rounding to nearest still bounds a lap's peak by x' <= f x + 0.5, so + x_k <= f^k (peak - c) + c with c = 0.5 / (1 - f); and since audiodsp#154 + no lap can hand back a sample as large as the one it sent, so once the + peak is at most floor(c) it takes at most floor(c) more laps to reach 0. + The 1e-6 keeps a c that is a whole number in exact arithmetic (10 at + 0.95) from flooring one short in float, where the loop would never end. + """ + c = 0.5 / (1.0 - feedback) + stall = math.floor(c + 1e-6) + laps = 0 + while peak >= stall + 1: + peak = feedback * (peak - c) + c + laps += 1 + return laps + int(stall) + + +# -------------------------------------------------------------------------- +# The measurements, each returning {"passed": ...} + + +def t1_measure(cls, rate=RATE, channels=2, **options): + """WIRE over the first T - 1 frames on the full-scale ramp, and the + first repeat arriving in the 64 frames after, where Mix is above 0.""" + options.setdefault("glide_ms", 4000.0) + effect_probe = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + frames_t = effect_probe._frames + mix = effect_probe.macro(MIX_I) + effect_probe.deinit() + total = max(8192, frames_t + 512) + ramp = probes.ramp_fs(frames=total, channels=channels) + source = probes.ArraySource(ramp, rate=rate, channels=channels, + block=BLOCK) + effect = cls(source, sample_rate=rate, **options) + out = pull(effect, total, channels) + src = np.array(ramp, dtype=np.int16) + window = (frames_t - 1) * channels + differing = int(np.count_nonzero(out[:window] != src[:window])) + after = slice(frames_t * channels, (frames_t + 64) * channels) + arrived = int(np.count_nonzero(out[after] != src[after])) + passed = differing == 0 and (mix <= 0.0 or arrived > 0) + return {"passed": passed, "differing": differing, "arrived": arrived, + "frames": frames_t} + + +def t1_quiet(cls, dbfs, rate=RATE, channels=2, **options): + """WIRE over the first T - 1 frames on `noise_det` at `dbfs` peak: the + row's quieter levels, where `OneLsbScale` cannot show.""" + options.setdefault("glide_ms", 4000.0) + effect_probe = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + frames_t = effect_probe._frames + effect_probe.deinit() + total = frames_t + 256 + data = probes.noise_det(frames=total, dbfs=dbfs, channels=channels) + effect = cls(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), sample_rate=rate, **options) + out = pull(effect, total, channels) + src = np.array(data, dtype=np.int16) + window = (frames_t - 1) * channels + differing = int(np.count_nonzero(out[:window] != src[:window])) + return {"passed": differing == 0, "differing": differing, + "peak": int(np.abs(src).max())} + + +def dossier_slew(glide_ms): + """The dossier's section 6 Glide law, written out here and never taken + from the class: 787.5 ms over `glide_ms`, pinned at 0.99, 0 the jump.""" + glide_ms = float(glide_ms) + if glide_ms <= 0.0: + return 0.0 + return min(0.99, 787.5 / glide_ms) + + +#: Where T2's rising 150 -> 200 ms move is claimed from on the Glide knob +#: (dossier section 8.13): grid 4, 860.17 ms, read per binade. Fix round 2 +#: said grid 2 and re-audit fix round 1 grid 3, both read by one fit over +#: the whole walk. +RISING_EDGE_GRID = 4.0 + + +def glide_of_grid(grid): + """The Glide knob's label at a grid position: log 800-8000 ms, grid 0 + the jump (dossier section 6).""" + if grid <= 0: + return 0.0 + return 800.0 * 10.0 ** (grid / 127.0) + + +def ls_hz(segment, rate): + """The frequency whose sine, with a phase and a DC term, fits `segment` + best in least squares. It reads only the samples it is given - the + analytic signal's FFT reads the whole render, which is what failed on + the near-stall rising cells - and it takes no law: the search starts + at the segment's own spectral peak, walks in 10-cent steps until the + minimum is inside the bracket, then narrows to 1 cent and a golden + section.""" + seg = np.asarray(segment, dtype=np.float64) + n = len(seg) + t = np.arange(n) / float(rate) + ones = np.ones(n) + + def residual(cents_off, base): + f = base * 2.0 ** (cents_off / 1200.0) + w = 2.0 * math.pi * f * t + basis = np.stack([np.sin(w), np.cos(w), ones], axis=1) + coef = np.linalg.lstsq(basis, seg, rcond=None)[0] + return float(np.sum((seg - basis.dot(coef)) ** 2)) + + pad = 1 << int(math.ceil(math.log(max(16 * n, 1 << 16), 2))) + spec = np.abs(np.fft.rfft((seg - seg.mean()) * np.hanning(n), pad)) + spec[0] = 0.0 + base = max(float(np.argmax(spec)) * rate / pad, 5.0) + centre = 0.0 + for _ in range(40): + offsets = centre + np.arange(-300.0, 301.0, 10.0) + errors = [residual(c, base) for c in offsets] + k = int(np.argmin(errors)) + centre = float(offsets[k]) + if 0 < k < len(offsets) - 1: + break + offsets = centre + np.arange(-10.0, 10.5, 1.0) + centre = float(offsets[int(np.argmin([residual(c, base) + for c in offsets]))]) + lo, hi = centre - 1.0, centre + 1.0 + g = (math.sqrt(5.0) - 1.0) / 2.0 + a, b = hi - g * (hi - lo), lo + g * (hi - lo) + fa, fb = residual(a, base), residual(b, base) + for _ in range(30): + if fa < fb: + hi, b, fb = b, a, fa + a = hi - g * (hi - lo) + fa = residual(a, base) + else: + lo, a, fa = a, b, fb + b = lo + g * (hi - lo) + fb = residual(b, base) + return base * 2.0 ** ((lo + hi) / 2400.0) + + +def t2_render(cls, rate, values, start_ms, target_ms, glide_ms, glide_grid, + move_at, frames): + source = array_src(values, 2, rate) + effect = cls(source, sample_rate=rate, time_ms=start_ms, feedback=0.0, + mix=2.0, glide_ms=glide_ms) + if glide_grid is not None: + effect.set_macro(GLIDE_I, glide_grid) + + def move(frame): + if target_ms is not None and frame == move_at: + effect.set_macro(TIME_I, midi_of_ms(target_ms)) + + return left(pull(effect, frames, 2, on_block=move), 2) + + +#: The shortest binade segment of a walk the pitch clause reads (frames, +#: after trimming); a shorter one is reported unread. +T2_MIN_SEGMENT = 128 + +#: The sine fit reads a segment only where it holds at least this many +#: periods of the shifted tone; under that (the top binade of a near-stall +#: rising walk at 22.05 kHz is 0.1-0.3 of a period, where the fit read +#: -18.6 c at grid 1 and +142 c at 800 ms against the ramp's -5.65 and +#: +0.08) it reads its own floor, not the walk, and the fine ramp alone is +#: graded there. +T2_MIN_PERIODS = 1.0 + + +def t2_fine_delay(cls, rate, start_ms, target_ms, glide_ms, glide_grid, + move_at, frames, end, coarse): + """The delay the node plays at each frame, read off a ramp as steep as + int16 allows over just the source frames the walk reads: n - D(n) runs + from move_at - a to end - b (a, b the whole-frame start and target), so + near a stall the walk reads only a few dozen source frames and the ramp + can climb hundreds of LSB per frame. Linear interpolation is exact on + a ramp, so the only error is the output's int16 rounding, half an LSB, + which is 0.5 / slope of a frame. A walk that reads more source frames + than int16 can ramp over one LSB at a time (the full-range move) keeps + `coarse`, the measurement's own slope-1/k ramp, whose k-frame steps + are nothing against segments tens of thousands of frames long.""" + a = dd.whole_frames(start_ms, rate) + b = dd.whole_frames(target_ms, rate) + j_lo = min(move_at - a, end - b) - 16 + j_hi = max(move_at - a, end - b) + 16 + if j_hi - j_lo > 60000: + return coarse, 0 + slope = min(1000, 60000 // (j_hi - j_lo)) + values = [max(-30000, min(30000, -30000 + slope * (j - j_lo))) + for j in range(frames)] + r = t2_render(cls, rate, values, start_ms, target_ms, glide_ms, + glide_grid, move_at, frames) + delay = np.arange(frames) - (j_lo + (r + 30000.0) / slope) + delay[(r <= -30000) | (r >= 30000)] = np.nan + return delay, slope + + +def t2_segments(y, delay, rate, start_ms, target_ms, slew, first, last, + falling, ratio): + """T2's pitch clause read per binade of the node's walk (re-audit fix + round 2). The node steps `current += slew` in float32 + (`audiodsp_feedback_delay.c:444` / `:449` at v0.6.2), so inside each + binade [2^e, 2^(e+1)) of the read head it plays the slew rounded to + 2^(e-23): a walk that crosses a power of two plays two rates, and one + fit over the whole walk reads their weighted mean, which is what hid + gate re-audit 1's grid 3.0079. The walk window [first, last) is cut + where the fine delay (`t2_fine_delay`) crosses each power of two, less + a pad either side, and each piece of at least `T2_MIN_SEGMENT` frames + is read two ways against the law: the fine ramp's delay slope, and + `ls_hz` on the 997 Hz render where the piece holds `T2_MIN_PERIODS` of + the shifted tone. Returns [(start, stop, sine cents or None, ramp cents + or None)]; both None where a piece is too short to read.""" + a = dd.whole_frames(start_ms, rate) + b = dd.whole_frames(target_ms, rate) + lo, hi = min(a, b), max(a, b) + cuts = [] + e = int(math.floor(math.log(lo, 2))) + 1 + span = delay[first:last] + while 2 ** e < hi: + bound = float(2 ** e) + with np.errstate(invalid="ignore"): + crossed = np.where(span < bound if falling else span >= bound)[0] + if len(crossed): + cuts.append(first + int(crossed[0])) + e += 1 + pad = int(math.ceil(2.0 / slew)) + 8 + edges = [first] + sorted(cuts) + [last] + shifted = 997.0 * ratio + out = [] + for i in range(len(edges) - 1): + s0 = edges[i] + (pad if i > 0 else 0) + s1 = edges[i + 1] - (pad if i + 1 < len(edges) - 1 else 0) + piece = delay[s0:s1] + if s1 - s0 < T2_MIN_SEGMENT or np.isnan(piece).any(): + out.append((s0, s1, None, None)) + continue + played = abs(float(np.polyfit(np.arange(s0, s1), piece, 1)[0])) + ramp = cents(((1.0 + played) if falling else (1.0 - played)) + / ratio) + sine = None + if (s1 - s0) * shifted / rate >= T2_MIN_PERIODS: + sine = cents(ls_hz(y[s0:s1], rate) / shifted) + out.append((s0, s1, sine, ramp)) + return out + + +def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, + target_ms=150.0, move_at=20480, glide_grid=None, level=12000): + """T2's three clauses at one cell, against the dossier's own law. + + The law is w_s (1 - dD) with dD = 787.5 / (the Glide asked) - the + constructor's `glide_ms`, or the knob's label at `glide_grid` - never + the class's own `slew_of`. The walk's end is read off a ramp render of + the same move (the node's float32 walk ends early, A8.1). Pitch: the + walk less a margin of min(400, walk/10) frames at each end, read per + binade of the node's walk (`t2_segments`), and the worst piece is + `pitch_cents`; `whole_pitch_cents` is the least-squares fit over the + whole window, the reading up to re-audit fix round 1, which averaged + two rates where the walk crosses a power of two. Residual: the same + fit 50-250 ms after the walk. + No-step: the largest first difference in the walk against + 1.05 |1 - dD| x the unramped maximum, a gap past the ramp's end against + the looser legitimate slope, and 2000 frames after against the unramped + maximum. `body` is the walk's largest step outside its last block, so + a staircase's one remainder jump at the end is reported apart.""" + asked = glide_ms if glide_grid is None else glide_of_grid(glide_grid) + slew = dossier_slew(asked) + falling = target_ms < start_ms + ratio = (1.0 + slew) if falling else (1.0 - slew) + nominal = (int(abs(target_ms - start_ms) / slew * rate / 1000.0) + if slew > 0.0 else 0) + frames = move_at + nominal + int(0.35 * rate) + 4096 + sine = sine_values(997.0, frames, rate, level) + y = t2_render(cls, rate, sine, start_ms, target_ms, glide_ms, glide_grid, + move_at, frames) + base = t2_render(cls, rate, sine, start_ms, None, glide_ms, glide_grid, + move_at, frames) + d_base = float(np.abs(np.diff(base[move_at:])).max()) + k = max(1, -(-frames // 60000)) + ramp = [i // k - 30000 for i in range(frames)] + r = t2_render(cls, rate, ramp, start_ms, target_ms, glide_ms, glide_grid, + move_at, frames) + delay = np.arange(frames) - k * (r + 30000.0) + target_frames = int(math.floor(target_ms * rate / 1000.0 + 0.5)) + short = np.where(np.abs(delay[move_at:] - target_frames) > 2 * k + 2)[0] + end = move_at + (int(short.max()) + 1 if len(short) else 0) + walked = end - move_at + result = {"slew": slew, "ratio": ratio, "unramped": d_base, + "walk_frames": walked, "nominal": nominal, "law": "dossier", + "bar": 1.05 * ratio * d_base} + if slew <= 0.0 or walked < 64 or end + 4096 > len(y): + step = float(np.abs(np.diff(y[move_at - 1:move_at + 256])).max()) + result.update({"passed": False, "walk": step, "gap": step, + "after": step, "body": step, "end": None, + "bar_gap": result["bar"], "bar_after": result["bar"], + "pitch_cents": None, "residual_cents": None, + "why": "no walk read off the ramp"}) + return result + a = move_at + walked // 10 + b = end - walked // 10 + result["slew_measured"] = ( + abs(float(np.polyfit(np.arange(a, b), delay[a:b], 1)[0])) + if b - a > 10 else float("nan")) + slack = 2 * k + 2 + margin = int(math.ceil((slack + (slack - 2) // 2) / slew)) + 2 + walk = float(np.abs(np.diff(y[move_at - 1:end + 1])).max()) + body = float(np.abs(np.diff(y[move_at - 1:max(move_at, end - BLOCK) + + 1])).max()) + end_jump = float(np.abs(np.diff(y[max(move_at, end - BLOCK) - 1: + end + 1])).max()) + gap = float(np.abs(np.diff(y[end:end + margin + 1])).max()) + after = float(np.abs(np.diff(y[end + margin:end + margin + 2000])).max()) + edge = min(400, walked // 10) + window = walked - 2 * edge + whole = cents(ls_hz(y[move_at + edge:end - edge], rate) + / (997.0 * ratio)) + fine, _ = t2_fine_delay(cls, rate, start_ms, target_ms, glide_ms, + glide_grid, move_at, frames, end, delay) + segments = t2_segments(y, fine, rate, start_ms, target_ms, slew, + move_at + edge, end - edge, falling, ratio) + read = [c for s in segments for c in s[2:] if c is not None] + pitch = max(read, key=abs) if read else whole + settled = end + margin + residual = cents(ls_hz(y[settled + int(0.05 * rate): + settled + int(0.25 * rate)], rate) / 997.0) + bar_gap = 1.05 * max(ratio, 1.0) * d_base + bar_after = 1.05 * d_base + passed = (abs(pitch) <= 10.0 and abs(residual) <= 1.0 + and walk <= result["bar"] and gap <= bar_gap + and after <= bar_after) + result.update({"passed": passed, "pitch_cents": pitch, + "whole_pitch_cents": whole, "segments": segments, + "residual_cents": residual, "walk": walk, "body": body, + "end": end_jump, "gap": gap, "after": after, + "bar_gap": bar_gap, "bar_after": bar_after, + "window": window}) + return result + + +def f32_walk_cents(rate, grids, start_ms=150.0, target_ms=200.0): + """A float32 model of the node's slew walk (`current += slew`, + `audiodsp_feedback_delay.c:444` / `:449` at v0.6.2), from `start_ms` to + `target_ms` in whole frames, at the dossier's law for each Glide grid + position in `grids`. Returns, per position, the cents the walk plays + off the law, read over the walk's middle 80 % (gate audit round 2's + model, `digitaldelay_audit2_f32scan.py`).""" + grids = np.asarray(grids, dtype=np.float64) + a = dd.whole_frames(start_ms, rate) + b = dd.whole_frames(target_ms, rate) + law = np.array([dossier_slew(glide_of_grid(g)) for g in grids]) + step = law.astype(np.float32) + rising = b > a + tgt = np.float32(b) + start = np.full(len(grids), a, np.float32) + + def advance(c): + if rising: + return np.minimum((c + step).astype(np.float32), tgt) + return np.maximum((c - step).astype(np.float32), tgt) + + n = np.zeros(len(grids), np.int64) + c = start.copy() + done = np.zeros(len(grids), bool) + k = 0 + while not done.all(): + k += 1 + c = np.where(done, c, advance(c)) + newly = (~done) & (c == tgt) + n[newly] = k + done |= newly + ia, ib = n // 10, n - n // 10 + c = start.copy() + pa = np.zeros(len(grids)) + pb = np.zeros(len(grids)) + for k in range(1, int(n.max()) + 1): + c = advance(c) + pa[ia == k] = c[ia == k] + pb[ib == k] = c[ib == k] + eff = np.abs(pb - pa) / (ib - ia) + ratio = (1.0 - eff) / (1.0 - law) if rising else (1.0 + eff) / (1.0 + law) + return 1200.0 * np.log2(ratio) + + +def binade_model_cents(rate, grids, start_ms=150.0, target_ms=200.0): + """The exact per-binade model of the node's walk (re-audit fix round + 2): in each binade the move crosses, the slew as float32 rounded to + that binade's ulp, 2^(e-23), against the dossier's law. Returns, per + Glide grid position, the worst binade's cents off the law.""" + grids = np.asarray(grids, dtype=np.float64) + # dossier_slew(glide_of_grid(g)) for every g > 0, vectorised + law = np.minimum(0.99, 787.5 / (800.0 * 10.0 ** (grids / 127.0))) + s32 = law.astype(np.float32).astype(np.float64) + a = dd.whole_frames(start_ms, rate) + b = dd.whole_frames(target_ms, rate) + worst = np.zeros(len(grids)) + for e in range(int(math.floor(math.log(min(a, b), 2))), + int(math.floor(math.log(max(a, b), 2))) + 1): + ulp = 2.0 ** (e - 23) + played = np.round(s32 / ulp) * ulp + if b > a: + c = 1200.0 * np.log2((1.0 - played) / (1.0 - law)) + else: + c = 1200.0 * np.log2((1.0 + played) / (1.0 + law)) + worst = np.where(np.abs(c) > np.abs(worst), c, worst) + return worst + + +def t3_burst(rate): + n = int(rate * 50.0 / 1000.0) + rng = np.random.RandomState(12345) + return np.round(rng.uniform(-1.0, 1.0, n) * 8192).astype(np.int16) + + +def t3_shares(segment, rate): + spec = np.abs(np.fft.rfft(segment)) ** 2 + freqs = np.fft.rfftfreq(len(segment), 1.0 / rate) + total = spec[freqs >= 20.0].sum() + bands = ((100.0, 1000.0), (1000.0, 4000.0), (4000.0, rate / 2.0)) + if total <= 0.0: + return None + return [spec[(freqs >= lo) & (freqs < hi)].sum() / total + for lo, hi in bands] + + +def t3_measure(cls, rate=RATE, tone_hz=16000.0, **options): + """Burst then silence at Feedback 0.8, Mix 2: each repeat's band shares + against repeat 1's, through repeat 8. Returns the worst deviation per + band and repeat 8's 4 kHz-Nyquist share against repeat 1's.""" + options.setdefault("time_ms", 350.0) + burst = t3_burst(rate) + probe = cls(silence_src(64, 2, rate), sample_rate=rate, **options) + frames_t = probe._frames + probe.deinit() + total = 9 * frames_t + len(burst) + rate // 10 + values = np.zeros(total, dtype=np.int16) + values[:len(burst)] = burst + effect = cls(array_src(values.tolist(), 2, rate), sample_rate=rate, + feedback=0.8, mix=2.0, tone_hz=tone_hz, cut_hz=20.0, + glide_ms=4000.0, **options) + y = left(pull(effect, total, 2), 2) + rows = [] + for k in range(1, 9): + segment = y[k * frames_t:k * frames_t + len(burst)] + if float(np.sqrt(np.mean(segment ** 2))) < 1.0: + return {"passed": False, "why": "repeat %d is silent" % k} + shares = t3_shares(segment, rate) + if shares is None or min(shares) <= 0.0: + return {"passed": False, "why": "repeat %d has no band" % k} + rows.append(shares) + worst = [max(abs(10.0 * math.log10(row[i] / rows[0][i])) for row in rows) + for i in range(3)] + high8 = 10.0 * math.log10(rows[7][2] / rows[0][2]) + return {"passed": max(worst) <= 1.0, "worst": worst, "high8": high8, + "frames": frames_t} + + +def t4_delay(cls, rate=RATE, transport=None, midi=None, division=None, + sync=None, **options): + """The wet click's landing in frames: one full-scale sample at frame + 1000, Mix 2, Feedback 0, Glide 0 (an instant knob).""" + at = 1000 + frames = at + int(0.81 * rate) + 512 + values = [0] * frames + values[at] = 32767 + source = array_src(values, 2, rate) + if transport is None: + effect = cls(source, sample_rate=rate, feedback=0.0, mix=2.0, + glide_ms=0.0, **options) + else: + effect = cls.create(source, rate, transport=transport, feedback=0.0, + mix=2.0, glide_ms=0.0, **options) + if sync is not None: + effect.set_macro(SYNC_I, sync) + if division is not None: + effect.set_macro(DIVISION_I, division) + if midi is not None: + effect.set_macro(TIME_I, midi) + y = np.abs(left(pull(effect, frames, 2), 2)) + peak = int(np.argmax(y)) + lo, hi = max(0, peak - 2), min(len(y), peak + 3) + centroid = float(np.dot(np.arange(lo, hi), y[lo:hi]) / y[lo:hi].sum()) + return centroid - at, effect.get_macro(TIME_I) + + +def t4_measure(cls, rate=RATE, positions=None): + """Delay tracking against the stated law's whole frame, within one + sample; with no positions, at the constructor's own 350 ms.""" + if positions is None: + measured, _ = t4_delay(cls, rate, time_ms=350.0) + expected = int(math.floor(350.0 * rate / 1000.0 + 0.5)) + errors = [abs(measured - expected)] + else: + errors = [] + for k in positions: + measured, _ = t4_delay(cls, rate, midi=127.0 * k / 16.0) + errors.append(abs(measured - mapped_frames(k / 16.0, rate))) + return {"passed": max(errors) <= 1.0, "worst": max(errors)} + + +def t5_gain_db(cls, hz, rate=RATE, window_s=0.25, **options): + """One pass, wet only, Time 350.0 ms: the tone's magnitude against the + class's own filters-out render at the same Time and rate.""" + frames_t = int(math.floor(350.0 * rate / 1000.0 + 0.5)) + total = frames_t + int(window_s * rate) + int(0.05 * rate) + values = sine_values(hz, total, rate, 8192) + + def magnitude(**opts): + effect = cls(array_src(values, 2, rate), sample_rate=rate, + time_ms=350.0, feedback=0.0, mix=2.0, glide_ms=4000.0, + **opts) + y = left(pull(effect, total, 2), 2) + n = int(window_s * rate) + segment = y[-n:] + t = np.arange(n) / float(rate) + return abs(np.sum(segment * np.exp(-2j * math.pi * hz * t))) * 2.0 / n + + wet = magnitude(**options) + ref = magnitude() + return 20.0 * math.log10(max(wet, 1e-9) / max(ref, 1e-9)) + + +def t5_out_identical(cls, rate=RATE, channels=2): + """The class at both filter stops (its defaults) against a node given + neither filter option, on deterministic noise: byte for byte.""" + frames = 8192 + noise = probes.noise_det(frames=frames, channels=channels) + effect = cls(probes.ArraySource(noise, rate=rate, channels=channels, + block=BLOCK), + sample_rate=rate, time_ms=12.5, feedback=0.6, mix=2.0) + node_ms = effect._node_ms + out = pull(effect, frames, channels) + # The reference takes the class's whole-frame Time (12.5 ms is 551.25 + # frames at 44.1 kHz; the class lands it on 551, dossier section 6). + reference = dd.audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=801.0, + delay_ms=node_ms, feedback=0.6, mix=2.0) + reference.play(probes.ArraySource(noise, rate=rate, channels=channels, + block=BLOCK)) + ref = array("h") + while len(ref) < frames * channels: + data = bytes(audiocore.get_buffer(reference)[1]) + if not data: + break + ref.extend(memoryview(data).cast("h")) + ref = np.array(ref[:frames * channels], dtype=np.int16) + return int(np.count_nonzero(out != ref)) + + +def t5_measure(cls, rate=RATE): + """Repeat Tone 7 kHz: -3 dB inside 6.3-7.7 kHz, no more than 3 dB down + at 5 kHz, more than 3 dB down at 10 kHz; Repeat Cut 40 Hz: -3 dB inside + 36-44 Hz; both out-of-circuit stops byte-identical to filters-out.""" + tone = {hz: t5_gain_db(cls, hz, rate, tone_hz=7000.0) + for hz in (5000, 6300, 7700, 10000)} + cut = {hz: t5_gain_db(cls, hz, rate, cut_hz=40.0) for hz in (36, 44)} + differing = t5_out_identical(cls, rate) + passed = (tone[6300] > -3.0 > tone[7700] and tone[5000] >= -3.0 + and tone[10000] < -3.0 and cut[36] < -3.0 < cut[44] + and differing == 0) + return {"passed": passed, "tone": tone, "cut": cut, + "differing": differing} + + +def t5_compound(cls, rate=RATE, level=8192, settings=None, **options): + """T5's compounding clause: the T3 burst at Feedback 0.8, Mix 2, Time + 350.0 ms; repeat 8's share of each band against repeat 1's, in dB, for + 20-100 Hz (the Cut clause's band), 100 Hz-1 kHz, 1-4 kHz and + 4 kHz-Nyquist.""" + n = int(rate * 50.0 / 1000.0) + burst = np.round(np.random.RandomState(12345).uniform(-1.0, 1.0, n) + * level).astype(np.int16) + opts = {"time_ms": 350.0, "feedback": 0.8, "mix": 2.0, + "glide_ms": 4000.0} + opts.update(options) + frames_t = dd.whole_frames(350.0, rate) + total = 9 * frames_t + n + rate // 10 + values = np.zeros(total, dtype=np.int16) + values[:n] = burst + effect = cls(array_src(values.tolist(), 2, rate), sample_rate=rate, + **opts) + for index in sorted(settings or {}): + effect.set_macro(index, settings[index]) + y = left(pull(effect, total, 2), 2) + bands = ((20.0, 100.0), (100.0, 1000.0), (1000.0, 4000.0), + (4000.0, rate / 2.0 + 1.0)) + + def shares(segment): + spec = np.abs(np.fft.rfft(segment)) ** 2 + freqs = np.fft.rfftfreq(len(segment), 1.0 / rate) + total_energy = spec[freqs >= 20.0].sum() + return [spec[(freqs >= lo) & (freqs < hi)].sum() / total_energy + for lo, hi in bands] + + first = shares(y[frames_t:frames_t + n]) + eighth = shares(y[8 * frames_t:8 * frames_t + n]) + return {"last": [10.0 * math.log10(e / f) for e, f in zip(eighth, first)]} + + +def tail_measure(cls, feedback, material, rate=RATE, channels=2, + time_ms=12.5, budget=None, **options): + """The frames from the input's end to the output's last non-zero frame, + against the `tail_samples` the class declares once the input has + stopped. `floor` is gate audit round 1's cell (997 Hz at 12 000 LSB for + 2 048 frames, the material that held 1 LSB for ever at v0.6.1); + `fullscale` is four laps of +32 767, which leaves every line sample at + the rail and so meets the lap count exactly. Mix 2, wet only.""" + probe = DigitalDelay(silence_src(64, channels, rate), sample_rate=rate, + time_ms=time_ms, feedback=feedback, mix=2.0, + **options) + lap = probe._frames + budget = budget or probe.tail_samples or 4 * rate + probe.deinit() + if material == "floor": + values = [0] * 256 + sine_values(997.0, 2048, rate, 12000) + elif material == "dc2": + values = [2] * (rate // 2) + else: + values = [32767] * (4 * lap) + input_end = len(values) + values += [0] * (budget + 4 * lap) + effect = cls(array_src(values, channels, rate), sample_rate=rate, + time_ms=time_ms, feedback=feedback, mix=2.0, **options) + declared = effect.tail_samples + out = pull(effect, len(values), channels) + nonzero = np.nonzero(out[input_end * channels:])[0] + tail = int(nonzero[-1]) // channels + 1 if len(nonzero) else 0 + passed = declared is not None and 0 < tail <= declared + held = int(np.abs(out[-4 * lap * channels:]).max()) + return {"passed": passed, "declared": declared, "tail": tail, + "lap": lap, "held": held} + + +def railed_samples(cls, dbfs, seconds=1.0, rate=RATE, **options): + """The input ceiling's measurement: `noise_det` at `dbfs` peak, 48 kHz + stereo; output samples on the int16 rail that are not on it in the + source.""" + frames = int(seconds * rate) + data = probes.noise_det(frames=frames, dbfs=dbfs, channels=2) + effect = cls(probes.ArraySource(data, rate=rate, channels=2, block=BLOCK), + sample_rate=rate, **options) + out = pull(effect, frames, 2).astype(np.int64) + src = np.array(data, dtype=np.int64) + rail = (out >= 32767) | (out <= -32768) + return int(np.count_nonzero(rail & ~((src >= 32767) | (src <= -32768)))) + + +# -------------------------------------------------------------------------- +# Reachability: what the node is handed, or what the output does, at the +# position walked. The twin's `FeedbackDelay.set` is watched while a walk +# runs, so a reading sees the options the class actually handed over. + + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node: `_handed` (the latest value of each option) and + `_writes` (each call's options, in order).""" + + def __enter__(self): + node_class = dd.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._writes = [] + node._handed.update(options) + node._writes.append(dict(options)) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def copy_of(effect, source, **ctor): + """A fresh instance of the same class on `source`, at `effect`'s macro + positions.""" + other = type(effect)(source, sample_rate=effect._sample_rate, **ctor) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + return other + + +def read_dry_gain(effect): + """The dry path's gain before the first repeat: a copy at these + positions on a 256-frame full-scale ramp, least-squares out / in.""" + ramp = probes.ramp_fs(frames=256, channels=2) + other = copy_of(effect, probes.ArraySource( + ramp, rate=effect._sample_rate, channels=2, block=BLOCK)) + out = pull(other, 256, 2).astype(np.float64) + src = np.array(ramp, dtype=np.float64) + other.deinit() + return round(float(np.dot(out, src) / np.dot(src, src)), 5) + + +def read_python_steps(effect): + """How many times the node's `delay_ms` is written while eight blocks + are pulled after one Time move, on a copy at these positions: the class + writes it once, on the move; a Python-stepped Time writes it every + block.""" + rate = effect._sample_rate + other = copy_of(effect, silence_src(16 * BLOCK, 2, rate)) + pull(other, BLOCK, 2) + time_now = other.get_macro(TIME_I) + mark = len(other._delay._writes) + other.set_macro(TIME_I, time_now + 32.0 if time_now < 64 else + time_now - 32.0) + pull(other, 8 * BLOCK, 2) + writes = sum(1 for w in other._delay._writes[mark:] if "delay_ms" in w) + other.deinit() + return writes + + +def read_landing_fraction(effect): + """The handed delay's distance from a whole frame, landed the node's way + (float32 `value * rate / 1000`, `audiodsp_feedback_delay.c:148`).""" + ms = np.float32(effect._delay._handed["delay_ms"]) + frames = float(ms * np.float32(effect._sample_rate) / np.float32(1000.0)) + return round(abs(frames - round(frames)), 2) + + +def read_map_error(effect): + """The handed delay's whole frames against the dossier's T4 law at the + knob's own position.""" + rate = effect._sample_rate + frames = int(math.floor(effect._delay._handed["delay_ms"] * rate / 1000.0 + + 0.5)) + return frames - mapped_frames(effect._macros[TIME_I], rate) + + +def read_damping(effect): + """The `damping_hz` handed to the node now.""" + return round(float(effect._delay._handed["damping_hz"]), 1) + + +def read_corner_ratios(effect): + """Each filter's handed coefficient against the pre-warp of its label + at the knob's position (1.0 at an out stop and wherever they agree).""" + rate = effect._sample_rate + handed = effect._delay._handed + position = effect._macros[TONE_I] + if position >= 1.0: + tone = 1.0 if handed["damping_hz"] == 0.0 else 0.0 + else: + label = 800.0 * 20.0 ** position + tone = handed["damping_hz"] / nominal_damping_hz(effect._hz(label), + rate) + position = effect._macros[CUT_I] + if position <= 0.0: + cut = 1.0 if handed["cut_hz"] == 0.0 else 0.0 + else: + label = 20.0 * 20.0 ** position + cut = handed["cut_hz"] / dd.nominal_cut_hz(effect._hz(label), rate) + return (round(tone, 3), round(cut, 3)) + + +def read_cut_placement(effect): + """(the `cut_hz` handed to the loop, the high-pass applied outside it).""" + return (round(float(effect._delay._handed["cut_hz"]), 3), + round(float(getattr(effect, "_post", 0.0)), 3)) + + +def read_tone_placement(effect): + """(the `damping_hz` handed to the loop, the low-pass applied outside + it).""" + return (round(float(effect._delay._handed["damping_hz"]), 3), + round(float(getattr(effect, "_post_lp", 0.0)), 3)) + + +def read_slew_against_label(effect): + """The `delay_slew` handed to the node against the dossier's law at the + Glide the knob's label says (the constructor's exact value while the + knob has not moved).""" + handed = effect._delay._handed["delay_slew"] + if effect._glide_exact is not None: + asked = effect._glide_exact + else: + asked = glide_of_grid(127.0 * effect._macros[GLIDE_I]) + law = dossier_slew(asked) + if law == 0.0: + return 1.0 if handed == 0.0 else 0.0 + return round(handed / law, 4) + + +#: (name, fault, reading, constructor options for both builds). The corner +#: and compounding faults are built at their corner cells (Tone 7 kHz, Cut +#: 40 Hz), because both filters are out at the defaults. +REACH_WALKS = ( + ("DryScaledDelay", DryScaledDelay, read_dry_gain, {}), + ("DryGainDelay", DryGainDelay, read_dry_gain, {}), + ("StaircaseDelay", StaircaseDelay, read_python_steps, {}), + ("HalfGlideMsDelay", HalfGlideMsDelay, read_slew_against_label, {}), + ("HalfFrameDelay", HalfFrameDelay, read_landing_fraction, {}), + ("LinearMapDelay", LinearMapDelay, read_map_error, {}), + ("RawTopToneDelay", RawTopToneDelay, read_damping, {}), + ("CornerShiftDelay", CornerShiftDelay, read_corner_ratios, + {"tone_hz": 7000.0, "cut_hz": 40.0}), + ("PostCutDelay", PostCutDelay, read_cut_placement, {"cut_hz": 40.0}), + ("PostToneDelay", PostToneDelay, read_tone_placement, + {"tone_hz": 7000.0}), +) + + +def reach(faulted, reading, rate, ctor): + """`kit_faults.fault_reachability` at `rate`, the node watched.""" + def build(cls): + return cls(silence_src(512, 2, rate), sample_rate=rate, **ctor) + + with NodeSpy(): + return kit_faults.fault_reachability(DigitalDelay, faulted, reading, + build) + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + cls = DigitalDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Feedback", "Mix", "Glide", "Sync", + "Division", "Repeat Tone", "Repeat Cut")) + self.assertEqual(len(cls.PATCHES), 6) + self.assertEqual(cls.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho",)) + effect = cls(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ("tempo_sync",)) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-28, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects + self.assertIn("DigitalDelay", rebuilt.ADOPTED) + self.assertNotIn("DigitalDelay", rebuilt.parked()) + self.assertIs(audioeffects.DigitalDelay, DigitalDelay) + served = audioeffects.create("DigitalDelay", silence_src(64), RATE) + self.assertIsInstance(served, DigitalDelay) + served.deinit() + + def test_patch_0_is_the_constructor_grid(self): + effect = DigitalDelay(silence_src(512), sample_rate=RATE) + grid = DigitalDelay.PATCHES[0][1] + for index, expected in enumerate(grid): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_tail_samples_follows_time_and_feedback(self): + # laps_to_zero(f) laps of (the head's longest delay + 1) frames, + # finite at every Feedback since audiodsp v0.6.2 (#154). + self.assertEqual(dd.laps_to_zero(0.0), 1) + self.assertEqual(dd.laps_to_zero(0.35), 11) + self.assertEqual(dd.laps_to_zero(64 / 127.0 * 0.99), 15) + self.assertEqual(dd.laps_to_zero(0.5), 16) + self.assertEqual(dd.laps_to_zero(0.99), 685) + self.assertEqual(dd.laps_to_zero(1.5), 685) + effect = DigitalDelay(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.tail_samples, 11 * 16801) + effect.program_change(0) + self.assertEqual(effect.tail_samples, 11 * 16936) + effect.set_macro(FEEDBACK_I, 0) + self.assertEqual(effect.tail_samples, 16936) + # A falling move walks from the old Time, so the old Time stays the + # bound until a jump lands the head. + effect.set_macro(TIME_I, 0) + self.assertEqual(effect.tail_samples, 16936) + effect.set_macro(GLIDE_I, 0) + self.assertEqual(effect.tail_samples, 601) + effect.set_macro(FEEDBACK_I, 64) + self.assertEqual(effect.tail_samples, 15 * 601) + # Repeat Tone in: each lap is the low-pass's forgetting time longer + # (22 frames at grid 100, 48 kHz). Feedback 0.99 sits in a stall + # window; since audiodsp v0.6.3rc1 the node lands a stalled state + # (#157), so the node is handed 0.99 itself and the count takes its + # one landing lap there. + effect.set_macro(TONE_I, 100) + self.assertEqual(effect._tone_excess()[0], 22) + self.assertEqual(effect.tail_samples, 15 * (601 + 22)) + effect.set_macro(FEEDBACK_I, 65) + self.assertEqual(effect.tail_samples, 17 * (601 + 22)) + effect.set_macro(FEEDBACK_I, 127) + self.assertEqual(effect.get_macro(FEEDBACK_I), 127.0) + self.assertEqual(effect._feedback, 0.99) + laps = dd.laps_to_zero(effect._feedback, effect._tone_excess()[1]) + self.assertEqual(effect.tail_samples, laps * (601 + 22)) + # Tone back out after it has been in is the filter out: exactly 0 + # handed, no memory, and the same bound as an instance that never + # had Tone in, 685 laps of the line at 0.99. + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._damping, 0.0) + self.assertEqual(effect._feedback, 0.99) + self.assertEqual(effect.tail_samples, 685 * 601) + fresh = copy_of(effect, silence_src(512)) + self.assertEqual(fresh.tail_samples, 685 * 601) + fresh.deinit() + # A window centre counts its landing lap: one more than the filter + # out at 0.5 and 0.99 (16 and 685); outside a window, 0.7, the + # excess alone decides. + self.assertEqual(dd.laps_to_zero(0.5, 1e-5), 17) + self.assertEqual(dd.laps_to_zero(0.99, 1e-5), 686) + self.assertEqual(dd.laps_to_zero(0.7, 1e-5), 29) + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(CUT_I, 1) + self.assertIsNone(effect.tail_samples) + effect.reset() + self.assertEqual(effect.tail_samples, 11 * 16936) + + def test_glide_law(self): + self.assertEqual(dd.slew_of(0.0), 0.0) + self.assertAlmostEqual(dd.slew_of(3937.5), 0.2) + self.assertAlmostEqual(dd.slew_of(4000.0), 0.196875) + self.assertEqual(dd.slew_of(500.0), 0.99) + effect = DigitalDelay(silence_src(512), sample_rate=RATE, + glide_ms=800.0) + self.assertAlmostEqual(dd.slew_of(effect._glide_ms()), 0.984375) + effect.set_macro(GLIDE_I, 1) + self.assertAlmostEqual(dd.slew_of(effect._glide_ms()), 0.96669, + places=5) + effect.set_macro(GLIDE_I, 0) + self.assertEqual(effect._glide_ms(), 0.0) + effect.set_macro(GLIDE_I, 127) + self.assertAlmostEqual(effect._glide_ms(), 8000.0) + + def test_filter_stops_are_exactly_zero(self): + effect = DigitalDelay(silence_src(512), sample_rate=RATE) + self.assertEqual(effect._tone_damping(1.0), 0.0) + self.assertEqual(effect._cut_hz(0.0), 0.0) + self.assertAlmostEqual(dd.nominal_cut_hz(40.0, 48000), 39.7916, + places=3) + self.assertAlmostEqual(dd.nominal_cut_hz(400.0, 44100), 378.3309, + places=3) + + def test_where_the_node_lands_the_handed_frame(self): + # The node turns the handed ms back into frames in float32 + # (`audiodsp_feedback_delay.c:148`). At 48 kHz every knob position + # lands exactly; at 44.1 and 22.05 kHz these land one float32 step + # off, which the class cannot avoid (the node ask is drafted: + # `audiodsp-feedback-delay-whole-frame-landing.md`). Goes red when + # the node lands every whole frame. + off_frame = { + 48000: [], + 44100: [8, 9, 13, 19, 29, 34, 52, 57, 61, 74, 75, 78, 80, 94, + 101, 104, 118, 125], + 22050: [8, 9, 13, 16, 34, 39, 51, 52, 57, 74, 75, 78, 80, 94, + 101, 104, 118, 123], + } + f32 = np.float32 + with NodeSpy(): + for rate, worst in ((48000, 0.0), (44100, 2.0 ** -8), + (22050, 2.0 ** -10)): + effect = DigitalDelay(silence_src(64, 2, rate), + sample_rate=rate) + missed = [] + for midi in range(128): + effect.set_macro(TIME_I, midi) + ms = f32(effect._delay._handed["delay_ms"]) + frames = float((ms * f32(rate)) / f32(1000.0)) + law = mapped_frames(midi / 127.0, rate) + self.assertEqual(effect._frames, law, (rate, midi)) + if frames != law: + self.assertLessEqual(abs(frames - law), worst, + (rate, midi)) + missed.append(midi) + effect.deinit() + self.assertEqual(missed, off_frame[rate], rate) + + def test_an_off_frame_time_leaks_into_the_next_frame(self): + # Patch 4 (MIDI 125) at 44.1 kHz is 33 043 frames, which the node + # lands 1/256 of a frame short: a 20 000 click's repeat reads 78 + # one frame early. The default 350 ms and 48 kHz are exact; a Time + # handed half a frame off, planted, leaks at 48 kHz. + def window(cls, rate, patch=None): + probe = cls(silence_src(64, 2, rate), sample_rate=rate, + patch=patch) + frames = probe._frames + probe.deinit() + values = [0] * (frames + 64) + values[0] = 20000 + effect = cls(array_src(values, 2, rate), sample_rate=rate, + patch=patch, feedback=0.0, mix=2.0) + if patch is not None: + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 127) + y = left(pull(effect, frames + 64, 2), 2) + effect.deinit() + return [int(v) for v in y[frames - 1:frames + 2]] + + self.assertEqual(window(DigitalDelay, 44100, 4), [78, 19922, 0]) + self.assertEqual(window(DigitalDelay, 48000, 4), [0, 20000, 0]) + self.assertEqual(window(DigitalDelay, 44100), [0, 20000, 0]) + self.assertNotEqual(window(HalfFrameDelay, 48000, 4), [0, 20000, 0]) + + +# -------------------------------------------------------------------------- +# Tier 2 rows + + +class T1DryIsAWire(unittest.TestCase): + def test_defaults_stereo_and_mono(self): + for channels in (2, 1): + result = t1_measure(DigitalDelay, channels=channels) + self.assertTrue(result["passed"], (channels, result)) + self.assertEqual(result["frames"], 16800) + + def test_the_hardest_cells(self): + for rate in (48000, 22050): + for mix in (0.9921, 1.0): + result = t1_measure(DigitalDelay, rate=rate, time_ms=12.5, + feedback=0.99, mix=mix, tone_hz=800.0, + cut_hz=400.0) + self.assertTrue(result["passed"], (rate, mix, result)) + + def test_a_coloured_dry_is_red(self): + result = t1_measure(DryScaledDelay) + self.assertFalse(result["passed"]) + self.assertGreater(result["differing"], 0) + result = t1_measure(DryGainDelay) + self.assertFalse(result["passed"]) + + def test_the_quieter_levels_have_a_fault_leg(self): + # -20 and -40 dBFS: the clean class is a wire, OneLsbScale cannot + # show there (it is the identity below 16 384 LSB), and the +0.1 dB + # fault does. + for dbfs in (-20.0, -40.0): + self.assertTrue(t1_quiet(DigitalDelay, dbfs)["passed"], dbfs) + self.assertEqual(t1_quiet(DryScaledDelay, dbfs)["differing"], 0) + self.assertGreater(t1_quiet(DryGainDelay, dbfs)["differing"], 0) + + +class T2TimeResamples(unittest.TestCase): + def test_the_row_cell_falls_on_the_law(self): + result = t2_measure(DigitalDelay) + self.assertTrue(result["passed"], result) + self.assertLess(abs(result["pitch_cents"]), 10.0) + + def test_a_rising_move_at_the_default_glide(self): + result = t2_measure(DigitalDelay, glide_ms=4000.0, start_ms=150.0, + target_ms=200.0) + self.assertTrue(result["passed"], result) + + def test_a_near_stall_rising_cell(self): + # Glide grid 1, 150 -> 200 ms: the law's ratio is 0.033 (a 33 Hz + # tone), and at 22.05 kHz the walk's top binade is under a third of + # a period, where the sine fit reads its own floor (-18.6 c) and + # the fine ramp reads the walk (-5.65 c). Claimed at three rates. + for rate in (48000, 44100, 22050): + result = t2_measure(DigitalDelay, rate=rate, glide_grid=1, + start_ms=150.0, target_ms=200.0) + self.assertTrue(result["passed"], (rate, result)) + + def test_the_constructor_800_ms_cell(self): + result = t2_measure(DigitalDelay, glide_ms=800.0) + self.assertTrue(result["passed"], result) + + def test_a_wrong_glide_law_is_red(self): + result = t2_measure(HalfGlideMsDelay) + self.assertFalse(result["passed"], result) + self.assertGreater(result["pitch_cents"], 200.0) + + def test_the_block_staircase_is_red(self): + result = t2_measure(StaircaseDelay, glide_ms=4000.0) + self.assertFalse(result["passed"], result) + self.assertGreater(result["body"], result["bar"]) + + def test_the_unclaimed_band_is_where_the_binade_model_says(self): + # A cross-check: the exact per-binade model of the node's walk, + # over the whole knob in steps of 0.0001, puts every position more + # than 10 c off the law inside the unclaimed band (1, 4) at every + # rate, and does find some there at 48 and 44.1 kHz, so the scan + # can fail. Falling moves stay under 1 c everywhere. + grids = np.arange(10000, 1270001) / 10000.0 + for rate in (48000, 44100, 22050): + model = binade_model_cents(rate, grids) + off = grids[np.abs(model) > 10.0] + self.assertTrue(np.all((off > 1.0) & (off < RISING_EDGE_GRID)), + (rate, off[(off <= 1.0) + | (off >= RISING_EDGE_GRID)])) + if rate != 22050: + self.assertGreater(len(off), 0, rate) + falling = binade_model_cents(rate, grids, 200.0, 150.0) + self.assertLess(float(np.abs(falling).max()), 1.0, rate) + + def test_the_pitch_is_read_per_binade(self): + # Gate re-audit 1's cell: grid 3.0079, rising 150 -> 200 ms. The + # walk plays the law below 8192 frames and 12.5 c off it above; one + # fit over the whole walk reads their mean (-8.66 c at 48 kHz, + # -1.57 c at 44.1 kHz) and passed. Read per binade it is red, the + # sine fit and the fine ramp agreeing on the top piece. + for rate in (48000, 44100): + result = t2_measure(DigitalDelay, rate=rate, glide_grid=3.0079, + start_ms=150.0, target_ms=200.0) + self.assertLess(abs(result["whole_pitch_cents"]), 10.0, + (rate, result)) + self.assertLess(result["pitch_cents"], -12.0, (rate, result)) + self.assertFalse(result["passed"], (rate, result)) + top = result["segments"][-1] + self.assertLess(top[3], -12.0, (rate, top)) + # At 22.05 kHz the same position holds (+0.02 c on the ramp). + result = t2_measure(DigitalDelay, rate=22050, glide_grid=3.0079, + start_ms=150.0, target_ms=200.0) + self.assertTrue(result["passed"], result) + + def test_inside_the_band_is_red_and_outside_it_holds(self): + # Re-audit fix round 2 (dossier section 8.13): the rising 150 -> + # 200 ms move is claimed at Glide grid 1 and from grid 4 up, read + # per binade. Inside: gate audit round 3's 2.0342 and 2.49031, + # round 2's 1.245 and 1.0076, and gate re-audit 1's 3.0079 at 48 + # and 44.1 kHz, 3.24 and 3.30, which re-audit fix round 1's edge + # (grid 3) claimed. Outside: the edge at three rates and each + # rate's worst position above it in the per-binade model (9.90 c + # at 4.05795 at 48 and 44.1 kHz, 4.98 c at 4.0138 at 22.05). A + # cell passes exactly when the edge claims it, so an edge moved + # back to 3 claims 3.0079, 3.24 and 3.30 and goes red on them. + for rate, grid in ((48000, 2.0342), (48000, 2.49031), + (48000, 1.245), (44100, 1.0076), + (48000, 3.0079), (44100, 3.0079), (48000, 3.24), + (48000, 3.3), (48000, 4.0), (44100, 4.0), + (22050, 4.0), (48000, 4.05795), (44100, 4.05795), + (22050, 4.0138)): + result = t2_measure(DigitalDelay, rate=rate, glide_grid=grid, + start_ms=150.0, target_ms=200.0) + self.assertEqual(result["passed"], grid >= RISING_EDGE_GRID, + (rate, grid, result)) + # The constructor, the same way: 830.056 ms and 844.84 ms (grid + # 3.0079, which re-audit fix round 1 claimed from its 844.72 ms) + # are red, the new edge's own 860.17 ms holds. + for glide_ms in (830.056, glide_of_grid(3.0079), glide_of_grid(4.0)): + result = t2_measure(DigitalDelay, glide_ms=glide_ms, + start_ms=150.0, target_ms=200.0) + self.assertEqual(result["passed"], + glide_ms >= glide_of_grid(RISING_EDGE_GRID), + (glide_ms, result)) + + +class T3NoDarkening(unittest.TestCase): + def test_the_constructor_time_at_48k_and_22k(self): + for rate, frames in ((48000, 16800), (22050, 7718)): + result = t3_measure(DigitalDelay, rate=rate) + self.assertTrue(result["passed"], (rate, result)) + self.assertEqual(result["frames"], frames) + + def test_the_control_darkens(self): + result = t3_measure(DigitalDelay, tone_hz=3000.0) + self.assertLessEqual(result["high8"], -20.0, result) + + def test_a_half_frame_read_is_red(self): + result = t3_measure(HalfFrameDelay) + self.assertFalse(result["passed"], result) + + +class T4TimeLaw(unittest.TestCase): + def test_the_map_at_the_stops_and_between(self): + result = t4_measure(DigitalDelay, positions=(0, 4, 8, 12, 16)) + self.assertTrue(result["passed"], result) + result = t4_measure(DigitalDelay, rate=22050, positions=(0, 16)) + self.assertTrue(result["passed"], result) + + def test_a_lowered_ceiling_clamps_visibly(self): + measured, reported = t4_delay(DigitalDelay, midi=127.0, + max_time_ms=300.0) + self.assertLessEqual(abs(measured - 14400), 1.0) + self.assertAlmostEqual(reported, 97.048, places=3) + measured, reported = t4_delay(DigitalDelay, midi=64.0, + max_time_ms=300.0) + self.assertLessEqual(abs(measured - mapped_frames(64 / 127.0, RATE)), + 1.0) + self.assertAlmostEqual(reported, 64.0, places=6) + + def test_sync_quantises_time_into_the_map(self): + def transport(): + return (True, 0.0, 120.0, 4, 4) + for division, frames, reported in ((0, 3000, None), + (51, 12000, None), + (127, 38400, 127.0)): + measured, got = t4_delay(DigitalDelay, transport=transport, + sync=127, division=division) + self.assertLessEqual(abs(measured - frames), 1.0, division) + if reported is not None: + self.assertAlmostEqual(got, reported, places=6) + + def test_no_host_leaves_time_on_the_knob(self): + measured, _ = t4_delay(DigitalDelay, sync=127, division=0, + time_ms=350.0) + self.assertLessEqual(abs(measured - 16800), 1.0) + + def test_a_linear_map_is_red(self): + self.assertTrue(t4_measure(DigitalDelay)["passed"]) + self.assertFalse(t4_measure(LinearMapDelay)["passed"]) + + +class T5BandLimit(unittest.TestCase): + def test_the_corners_at_48k(self): + result = t5_measure(DigitalDelay) + self.assertTrue(result["passed"], result) + + def test_the_in_circuit_stops(self): + self.assertAlmostEqual( + t5_gain_db(DigitalDelay, 800, tone_hz=800.0), -3.01, delta=0.2) + self.assertAlmostEqual( + t5_gain_db(DigitalDelay, 400, cut_hz=400.0), -3.01, delta=0.2) + + def test_an_open_top_stop_is_red(self): + for rate in (48000, 44100, 22050): + self.assertEqual(t5_out_identical(DigitalDelay, rate), 0, rate) + self.assertGreater(t5_out_identical(RawTopToneDelay, rate), 0, + rate) + self.assertFalse(t5_measure(RawTopToneDelay)["passed"]) + + def test_the_corners_shifted_are_red(self): + # 15 % high puts the 7 kHz corner at 8 050 Hz, so 7 700 Hz is still + # less than 3 dB down, and the 40 Hz corner at 46 Hz. + result = t5_measure(CornerShiftDelay) + self.assertFalse(result["passed"], result) + self.assertGreater(result["tone"][7700], -3.0) + + def test_the_cut_compounds_in_the_loop(self): + # Repeat Cut at its 400 Hz stop, Feedback 0.8: repeat 8's 20-100 Hz + # share at least 6 dB under repeat 1's (the fix round's bar, dated + # in the dossier); moved out of the loop it does not compound. + for rate in (48000, 22050): + clean = t5_compound(DigitalDelay, rate, settings={CUT_I: 127}) + self.assertLessEqual(clean["last"][0], -6.0, (rate, clean)) + post = t5_compound(PostCutDelay, rate, settings={CUT_I: 127}) + self.assertGreater(post["last"][0], -1.0, (rate, post)) + + def test_the_tone_compounds_in_the_loop(self): + # T3's control is the Tone half of the compounding clause: Repeat + # Tone 3 kHz takes repeat 8's 4 kHz-Nyquist share at least 20 dB + # under repeat 1's. Moved out of the loop it does not compound. + for rate in (48000, 44100, 22050): + clean = t3_measure(DigitalDelay, rate=rate, tone_hz=3000.0) + self.assertLessEqual(clean["high8"], -20.0, (rate, clean)) + post = t3_measure(PostToneDelay, rate=rate, tone_hz=3000.0) + self.assertGreater(post["high8"], -1.0, (rate, post)) + + +# -------------------------------------------------------------------------- +# Tier 1, the fast half + + +class Tier1Fast(unittest.TestCase): + def test_mix_zero_is_a_wire_on_the_full_scale_ramp(self): + for rate in (48000, 44100, 22050): + for channels in (2, 1): + ramp = probes.ramp_fs(frames=8192, channels=channels) + source = probes.ArraySource(ramp, rate=rate, + channels=channels, block=BLOCK) + effect = DigitalDelay(source, sample_rate=rate, mix=0.0, + time_ms=12.5, feedback=0.99) + out = pull(effect, 8192, channels) + self.assertTrue(np.array_equal( + out, np.array(ramp, dtype=np.int16)), (rate, channels)) + + def test_silence_stays_silence(self): + effect = DigitalDelay(silence_src(RATE), sample_rate=RATE, + feedback=0.99, mix=2.0, tone_hz=800.0, + cut_hz=400.0) + self.assertEqual(int(np.max(np.abs(pull(effect, RATE)))), 0) + + def _tone_back_in(self, cls, rate=RATE, channels=2, mix=2.0): + """300 Hz at 30 000 LSB for 0.5 s with Repeat Tone 2 kHz and + Feedback 0, Tone to its out stop as the input stops, 2 s of silence, + then Tone to MIDI 0: the output's peak after that move.""" + loud = (rate // 2) // BLOCK * BLOCK + back = (loud + 2 * rate) // BLOCK * BLOCK + values = [0] * (back + rate // 4) + values[:loud] = sine_values(300.0, loud, rate, 30000) + effect = cls(array_src(values, channels, rate), sample_rate=rate, + tone_hz=2000.0, feedback=0.0, mix=mix) + + def move(frame): + if frame == loud: + effect.set_macro(TONE_I, 127) + elif frame == back: + effect.set_macro(TONE_I, 0) + + y = pull(effect, len(values), channels, on_block=move) + effect.deinit() + return int(np.abs(y[back * channels:]).max()) + + def test_tone_back_in_after_silence_stays_silent(self): + # Since audiodsp v0.6.3rc1 the node keeps an out low-pass's state + # on the tap (#158), so the out stop is exactly 0 and Tone back in + # after silence plays nothing. + for rate in (48000, 44100, 22050): + for channels in (2, 1): + self.assertEqual(self._tone_back_in(DigitalDelay, rate, + channels), 0, + (rate, channels)) + self.assertEqual(self._tone_back_in(DigitalDelay, mix=0.3), 0) + # Planted: a low-pass left in with a frozen state (0.001 Hz) plays + # what it held (26 112 LSB at Mix 2, 7 834 at Mix 0.3, 48 kHz). + self.assertGreater(self._tone_back_in(FrozenToneDelay), 20000) + self.assertGreater(self._tone_back_in(FrozenToneDelay, mix=0.3), + 3000) + + def _cut_back_in(self, cls, rate=RATE, channels=2): + """The same move on Repeat Cut at 400 Hz: out as the tone stops, + back in after 2 s of silence; the peak after the move.""" + loud = (rate // 2) // BLOCK * BLOCK + back = (loud + 2 * rate) // BLOCK * BLOCK + values = [0] * (back + rate // 4) + values[:loud] = sine_values(300.0, loud, rate, 30000) + effect = cls(array_src(values, channels, rate), sample_rate=rate, + cut_hz=400.0, feedback=0.0, mix=2.0) + + def move(frame): + if frame == loud: + effect.set_macro(CUT_I, 0) + elif frame == back: + effect.set_macro(CUT_I, 127) + + y = pull(effect, len(values), channels, on_block=move) + effect.deinit() + return int(np.abs(y[back * channels:]).max()) + + def test_cut_back_in_after_silence_stays_silent(self): + # Up to audiodsp v0.6.2 the node froze the high-pass state while + # Cut was out and this played 20 858 / 18 699 / 16 352 LSB, which + # the class could only disclose; since v0.6.3rc1 the node holds it + # at zero (#159). + for rate in (48000, 44100, 22050): + for channels in (2, 1): + self.assertEqual(self._cut_back_in(DigitalDelay, rate, + channels), 0, + (rate, channels)) + # Planted: a high-pass left in with a frozen state (0.001 Hz). + for rate, floor in ((48000, 20000), (44100, 18000), (22050, 15000)): + self.assertGreater(self._cut_back_in(FrozenCutDelay, rate), + floor, rate) + + def test_the_out_stops_hand_exactly_zero(self): + # After Tone has been in, after a patch with Tone in, and fresh: + # the out stop is 0, the filter out. The retired tracking cure, + # planted, hands 32 x the rate there. + with NodeSpy(): + for rate in (48000, 44100, 22050): + for cls, expected in ((DigitalDelay, 0.0), + (TrackingToneDelay, 32.0 * rate)): + effect = cls(silence_src(64, 2, rate), sample_rate=rate) + self.assertEqual(effect._delay._handed["damping_hz"], + 0.0) + effect.set_macro(TONE_I, 0) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._delay._handed["damping_hz"], + expected, (cls, rate)) + effect.deinit() + effect = cls(silence_src(64, 2, rate), sample_rate=rate, + patch=5) + effect.program_change(0) + self.assertEqual(effect._delay._handed["damping_hz"], + expected, (cls, rate)) + self.assertEqual(effect._delay._handed["cut_hz"], 0.0) + effect.deinit() + + def _out_after_tone(self, cls, rate=RATE, channels=2, **options): + """Samples and worst LSB by which the out stop after Tone has been + in differs from a fresh instance's out stop, on 2 s of noise.""" + frames = 2 * rate + values = [int(v) for v in + np.random.default_rng(5).integers(-32768, 32768, frames)] + tracked = cls(array_src(values, channels, rate), sample_rate=rate, + **options) + tracked.set_macro(TONE_I, 0) + tracked.set_macro(TONE_I, 127) + fresh = DigitalDelay(array_src(values, channels, rate), + sample_rate=rate, **options) + diff = np.abs(pull(tracked, frames, channels).astype(int) + - pull(fresh, frames, channels).astype(int)) + tracked.deinit() + fresh.deinit() + return int((diff > 0).sum()), int(diff.max()) + + def test_tone_out_after_tone_is_the_filter_out(self): + # Byte for byte a fresh instance's out stop, where the retired + # tracking cure was 1 LSB off: at a fractional read (Time 136.054 + # at 44.1 kHz, which the node lands off the frame) and at a stall + # centre (Feedback 0.99). + cells = ({}, {"patch": 4}, + {"rate": 44100, "time_ms": 136.054, "feedback": 0.6}, + {"time_ms": 12.5, "feedback": 0.99}) + for options in cells: + self.assertEqual(self._out_after_tone(DigitalDelay, **options), + (0, 0), options) + # Planted: the retired cure is red at the fractional read, and a + # coefficient of 0.957 is a low-pass left in circuit. + count, _worst = self._out_after_tone(TrackingToneDelay, **cells[2]) + self.assertGreater(count, 0) + _count, worst = self._out_after_tone(LeakyTrackDelay) + self.assertGreater(worst, 1000) + + def _kit_tail(self, cls, seconds, **options): + data, on = probes.burst_silence(total_s=seconds, rate=RATE) + effect = cls(probes.ArraySource(data, rate=RATE, channels=2, + block=BLOCK), + sample_rate=RATE, **options) + declared = effect.tail_samples + render = probes.render(effect, int(seconds * RATE), rate=RATE, + channels=2) + result = kit.tail(render, burst_end_frame=on, + declared_tail_samples=declared, + settle_frames=effect._frames) + return declared, result["values"], result["red"] + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + # The kit's TAIL on its own -6 dBFS burst, at the defaults, at the + # Sync-on patch with the highest Feedback under 0.5, and at patch 4 + # (Feedback 0.70), which held 1 LSB for ever at audiodsp v0.6.1. + for options, seconds in (({}, 5.0), ({"patch": 2}, 6.0), + ({"patch": 4}, 24.0)): + declared, values, red = self._kit_tail(DigitalDelay, seconds, + **options) + self.assertEqual(red, [], (options, values)) + self.assertLessEqual(values["tail_samples"], declared) + declared, values, red = self._kit_tail(SixtyDbTailDelay, 5.0) + self.assertNotEqual(red, [], values) + self.assertGreater(values["tail_samples"], declared) + + def test_the_tail_is_bounded_at_every_feedback(self): + # Up to audiodsp v0.6.1 the node rounded the feedback write to + # nearest, so from Feedback 0.5 a 1 LSB repeat wrote itself back + # for ever, and the class declared None there. v0.6.2 (#154) steps + # the fed-back term toward zero where rounding would stall, so the + # class declares `laps_to_zero(f)` laps at every Feedback, and the + # tail ends inside it: on the old floor cell, and on full scale, + # which meets the lap count exactly. + for feedback in (0.5, 0.7, 0.99): + for material in ("floor", "fullscale"): + result = tail_measure(DigitalDelay, feedback, material) + self.assertTrue(result["passed"], + (feedback, material, result)) + # Repeat Tone in, where a bound holds. + for feedback, tone_hz in ((0.35, 800.0), (0.7, 800.0), + (0.7, 3000.0)): + result = tail_measure(DigitalDelay, feedback, "fullscale", + tone_hz=tone_hz) + self.assertTrue(result["passed"], (feedback, tone_hz, result)) + # The pin mover's own geometric bound agrees at 0.5. + self.assertLessEqual(tail_measure(DigitalDelay, 0.5, "floor")["tail"], + laps_to_exact_zero(0.5) * (600 + 1)) + # The old declaration is red, and so is a bound one lap short on + # full scale (at 0.99 the margin makes the bound a lap loose, so + # one lap short still holds there). + self.assertFalse(tail_measure(FloorEraTailDelay, 0.5, + "floor")["passed"]) + for feedback in (0.35, 0.5, 0.7, 0.9): + result = tail_measure(LapShortTailDelay, feedback, "fullscale") + self.assertFalse(result["passed"], (feedback, result)) + result = tail_measure(SixtyDbTailDelay, 0.7, "fullscale") + self.assertFalse(result["passed"], result) + + def test_repeat_tone_stall_cells_reach_zero_at_the_feedback_set(self): + # With Repeat Tone in, the node's single-precision damping state + # can rest a few ulps above a small value v, and where + # 0.5 / (1 - f) is v, `recirculated` rounds f v back up to v. Up to + # audiodsp v0.6.2 the line held v for ever (1-5 LSB on these + # cells) and the class handed the node a Feedback clear of the + # window. Since v0.6.3rc1 (#157) the node lands a stalled state: + # the Feedback set is the one handed, the class declares a finite + # bound, and the tail reaches exact zero inside it. The stall + # cells on a 2 LSB DC. + for feedback, tone_hz in ((0.5, 800.0), (0.75, 800.0), + (1.0 - 0.5 / 3.0, 800.0), (0.875, 800.0), + (0.9, 800.0), (0.5, 3000.0)): + result = tail_measure(DigitalDelay, feedback, "dc2", + tone_hz=tone_hz) + self.assertTrue(result["passed"], (feedback, tone_hz, result)) + self.assertEqual(result["held"], 0, (feedback, tone_hz, result)) + effect = DigitalDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, tone_hz=tone_hz) + self.assertEqual(effect._feedback, feedback) + effect.deinit() + # Planted: the retired stepping plays a Feedback nobody set, + # and the v0.6.2 reckoning declares no bound at all. + stepped = SteppedToneDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, tone_hz=tone_hz) + self.assertNotEqual(stepped._feedback, feedback) + stepped.deinit() + old = tail_measure(WindowNoneToneDelay, feedback, "dc2", + tone_hz=tone_hz) + self.assertIsNone(old["declared"], (feedback, tone_hz, old)) + self.assertFalse(old["passed"], (feedback, tone_hz, old)) + # A Feedback on a window's edge: the re-refuter's k = 1 edge at + # Tone grid 0 came back through the Feedback macro 10^-17 inside + # `laps_to_zero`'s window and read None. Every value within a few + # float steps of the lower edge now declares a bound. + probe = DigitalDelay(silence_src(64), sample_rate=RATE) + probe.set_macro(TONE_I, 0) + excess = probe._tone_excess()[1] + edge = 0.5 / ((1.0 + dd.FEEDBACK_MARGIN) * (1.0 + excess)) + for steps in range(-4, 5): + feedback = edge * (1.0 + steps * 2.0 ** -52) + effect = DigitalDelay(silence_src(64), sample_rate=RATE, + feedback=feedback) + effect.set_macro(TONE_I, 0) + self.assertIsNotNone(effect.tail_samples, (steps, feedback)) + # Fix round 2's rule for Repeat Tone, one extra lap, is still red + # where a bound holds: Feedback grid 113, Tone at 800 Hz. + feedback = 113 * 0.99 / 127.0 + for cls, passed in ((DigitalDelay, True), (OneLapToneDelay, False)): + result = tail_measure(cls, feedback, "fullscale", tone_hz=800.0) + self.assertEqual(result["passed"], passed, (cls, result)) + + def test_every_window_centre_reaches_zero(self): + # The k = 1 ... 50 window centres, f = 1 - 0.5 / k, at Repeat Tone + # grid 0, 64 and 126, on the 2 LSB DC (the line fills to 4k LSB and + # decays through k): each reaches exact zero inside a finite + # declared tail, and the handed Feedback is the one asked. The + # v0.6.2 reckoning without the stepping says None at every one. + # A host's block decides when the node's landing runs, so the k = 1, + # 10 and 50 centres at grid 0 are also rendered in 4096-frame + # blocks, seven laps of the 12.5 ms line. + for k in (1, 10, 50): + feedback = 1.0 - 0.5 / k + data = array("h", [2] * (RATE // 2 * 2) + + [0] * ((RATE * 10) * 2)) + effect = DigitalDelay(probes.ArraySource(data, rate=RATE, + channels=2, block=4096), + sample_rate=RATE, time_ms=12.5, + feedback=feedback, mix=2.0, tone_hz=800.0) + declared = effect.tail_samples + out = pull(effect, len(data) // 2, 2) + effect.deinit() + nonzero = np.nonzero(out[RATE // 2 * 2:])[0] + last = int(nonzero[-1]) // 2 + 1 + self.assertLessEqual(last, declared, k) + self.assertEqual(int(np.abs(out[-RATE // 4 * 2:]).max()), 0, k) + for grid in (0, 64, 126): + tone_hz = 800.0 * 20.0 ** (grid / 127.0) + for k in range(1, 51): + feedback = 1.0 - 0.5 / k + result = tail_measure(DigitalDelay, feedback, "dc2", + tone_hz=tone_hz) + self.assertTrue(result["passed"], (grid, k, result)) + self.assertEqual(result["held"], 0, (grid, k, result)) + probe = DigitalDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, tone_hz=tone_hz) + self.assertEqual(probe._feedback, feedback) + probe.deinit() + old = WindowNoneToneDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, + tone_hz=tone_hz) + self.assertIsNone(old.tail_samples, (grid, k)) + old.deinit() + + def _walk_tail(self, cls): + """800 ms of 997 Hz, then Time 800 -> 12.5 ms at the default Glide + on the tone's last block, Feedback 0, Mix 2.""" + tone = int(0.8 * RATE) // BLOCK * BLOCK + values = sine_values(997.0, tone, RATE, 12000) + [0] * RATE + effect = cls(array_src(values), sample_rate=RATE, time_ms=800.0, + feedback=0.0, mix=2.0) + seen = {} + + def move(frame): + if frame == tone: + effect.set_macro(TIME_I, 0) + seen["declared"] = effect.tail_samples + + out = pull(effect, len(values), on_block=move) + after = out[tone * 2:] + last = int(np.nonzero(after)[0][-1]) // 2 + 1 + return seen["declared"], last + + def test_a_falling_walk_keeps_the_old_time_in_the_tail(self): + declared, last = self._walk_tail(DigitalDelay) + self.assertGreater(last, 30000) + self.assertLessEqual(last, declared) + declared, last = self._walk_tail(TargetOnlyTailDelay) + self.assertGreater(last, declared) + + def _patch_times(self, cls): + def transport(): + return (True, 0.0, 120.0, 4, 4) + effect = cls.create(silence_src(512), RATE, transport=transport) + seen = [] + effect.program_change(1) + effect.program_change(0) + seen.append((effect._frames, round(effect.get_macro(TIME_I), 3))) + effect.program_change(1) + effect.program_change(4) + seen.append((effect._frames, round(effect.get_macro(TIME_I), 3))) + effect.program_change(2) + effect.reset() + seen.append((effect._frames, round(effect.get_macro(TIME_I), 3), + effect.patch_index)) + return seen + + def test_a_sync_off_patch_loads_its_own_time(self): + # 120 bpm: patch 1's synced Time is 12000 frames (1/8), patch 2's + # 18000 (1/8 dotted). + self.assertEqual(self._patch_times(DigitalDelay), + [(16935, 102.0), (35966, 125.0), + (16935, 102.0, 0)]) + faulted = self._patch_times(PerMacroPatchDelay) + self.assertEqual([row[0] for row in faulted], [12000, 12000, 18000]) + + def test_reset_empties_the_line(self): + values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) + values += [0] * RATE + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=100.0, feedback=0.9, mix=2.0) + pull(effect, 2304) + effect.reset() + self.assertEqual(effect.patch_index, 0) + self.assertEqual(int(np.max(np.abs(pull(effect, RATE // 2)))), 0) + + def test_deinit_leaves_the_source(self): + source = array_src(sine_values(440.0, 1024, RATE, 8000)) + effect = DigitalDelay(source, sample_rate=RATE) + pull(effect, 256) + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + + def test_click_delay_is_zero(self): + values = [0] * 2048 + values[10] = 30000 + effect = DigitalDelay(array_src(values), sample_rate=RATE) + out = left(pull(effect, 2048), 2) + self.assertEqual(int(np.argmax(np.abs(out))), 10) + self.assertEqual(effect.latency_samples, 0) + + def test_the_transport_is_read_only_with_sync_on(self): + reads = [] + + def transport(): + reads.append(1) + return (True, 0.0, 120.0, 4, 4) + effect = DigitalDelay.create(silence_src(512), RATE, + transport=transport) + self.assertEqual(reads, []) + effect.set_macro(SYNC_I, 127) + self.assertGreater(len(reads), 0) + + +# -------------------------------------------------------------------------- +# Fix round 1: the audit's (o) items and the 0 bpm host + + +class InputCeiling(unittest.TestCase): + """The docstring's ceiling on `noise_det`, 48 kHz stereo, over 20 s: + the defaults clean at -3.1 dBFS peak, patch 3 (the first shipped patch + to rail) at -4. Fix round 2: 1 s could not see the defaults rail at the + old -3.0 dBFS (4 samples over 20 s, the first at frame 169 739), so the + render is 20 s and -3.0 is the red leg.""" + + SECONDS = 20.0 + + def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): + for options, ceiling, over in (({}, -3.1, -3.0), + ({"patch": 3}, -4.0, -3.0)): + self.assertEqual(railed_samples(DigitalDelay, ceiling, + self.SECONDS, **options), 0, + options) + self.assertGreater(railed_samples(DigitalDelay, over, + self.SECONDS, **options), 0, + options) + + def test_the_any_material_bound(self): + # A DC one LSB under floor(32767 (1 - Mix)) never reaches the rail + # at Feedback 0.99; at floor(32767 (1 - Mix)) itself the sum rounds + # onto 32767 (the docstring says so: nothing clips). + for mix in (0.5, 0.3): + edge = int(math.floor(32767 * (1.0 - mix))) + for level, railed in ((edge - 1, False), (edge, mix == 0.5)): + values = [level] * (RATE // 2) + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=12.5, feedback=0.99, mix=mix) + out = pull(effect, len(values)) + self.assertEqual(bool(np.any(out >= 32767)), railed, + (mix, level)) + + +class GlideRoundTrip(unittest.TestCase): + def _round_trip(self, cls, glide_ms): + effect = cls(silence_src(512), sample_rate=RATE, glide_ms=glide_ms) + before = dd.slew_of(effect._glide_ms()) + effect.set_macro(GLIDE_I, effect.get_macro(GLIDE_I)) + return before, dd.slew_of(effect._glide_ms()) + + def test_get_macro_hands_back_the_glide(self): + # A constructor Glide faster than grid 1 reads back as grid 1, so a + # float trip lands the knob's fastest walk (0.966689; from 500 ms's + # pinned 0.99 that is -2.36 %, the most the knob can hold). + for glide_ms in (500.0, 800.0, 4000.0): + before, after = self._round_trip(DigitalDelay, glide_ms) + self.assertGreater(before, 0.0) + self.assertLessEqual(abs(after / before - 1.0), 0.025, glide_ms) + effect = DigitalDelay(silence_src(512), sample_rate=RATE, + glide_ms=0.0) + self.assertEqual(effect.get_macro(GLIDE_I), 0.0) + + def test_the_jump_seed_is_red(self): + before, after = self._round_trip(JumpSeedGlideDelay, 800.0) + self.assertGreater(before, 0.9) + self.assertEqual(after, 0.0) + + def _seven_bit_trip(self, cls, glide_ms): + """The Glide knob stored as a 7-bit value, the way a patch author + or a MIDI host stores it (`_component.macro_of` of `macro(3)`), and + handed back.""" + effect = cls(silence_src(512), sample_rate=RATE, glide_ms=glide_ms) + before = dd.slew_of(effect._glide_ms()) + midi = _component.macro_of(cls._MACRO_RANGES[GLIDE_I], + effect.macro(GLIDE_I)) + effect.set_macro(GLIDE_I, midi) + return before, midi, dd.slew_of(effect._glide_ms()) + + def test_a_seven_bit_trip_keeps_the_glide(self): + grid_1 = dossier_slew(glide_of_grid(1)) + for glide_ms in (500.0, 800.0, 810.0, 4000.0): + before, midi, after = self._seven_bit_trip(DigitalDelay, + glide_ms) + self.assertGreater(after, 0.0, glide_ms) + if before > grid_1: + # Faster than the grid can hold: grid 1, the fastest walk. + self.assertEqual(midi, 1, glide_ms) + self.assertAlmostEqual(after, 0.966689, places=6) + else: + self.assertLessEqual(abs(after / before - 1.0), 0.02, + glide_ms) + for glide_ms in (500.0, 800.0): + _, midi, after = self._seven_bit_trip(NearZeroSeedGlideDelay, + glide_ms) + self.assertEqual((midi, after), (0, 0.0), glide_ms) + + def test_a_slow_constructor_glide_reads_back_as_itself(self): + # Re-audit fix round 1: a constructor Glide slower than the span's + # 8 s plays 8 s, so a float or a 7-bit trip through get_macro(3) + # lands within one grid step of it (the top step is 10^(1/127) - 1, + # 1.83 %). Up to fix round 2 it played the slower Glide while the + # knob read 8 s: 16 s came back +100 %, 80 s +900 %. + step = 10.0 ** (1.0 / 127.0) - 1.0 + for glide_ms in (8000.0, 12000.0, 16000.0, 80000.0): + before, after = self._round_trip(DigitalDelay, glide_ms) + self.assertAlmostEqual(before, 787.5 / 8000.0, places=12) + self.assertLessEqual(abs(after / before - 1.0), step, glide_ms) + before, midi, after = self._seven_bit_trip(DigitalDelay, + glide_ms) + self.assertEqual(midi, 127, glide_ms) + self.assertLessEqual(abs(after / before - 1.0), step, glide_ms) + for glide_ms, gain in ((16000.0, 1.0), (80000.0, 9.0)): + for trip in (self._round_trip, self._seven_bit_trip): + got = trip(SlowSeedGlideDelay, glide_ms) + before, after = got[0], got[-1] + self.assertAlmostEqual(after / before - 1.0, gain, + places=6, msg=(glide_ms, trip)) + + +class ConstructorZeros(unittest.TestCase): + """Re-audit fix round 1: `time_ms`, `tone_hz` and `cut_hz` at 0 built + nothing (`math domain error` from the log knob's seed). 0 is how the + node spells a filter out of circuit, so Repeat Tone and Repeat Cut at 0 + are their out stops, and Time at 0 is the bottom of its span, beside + the out-of-span values that already clamped.""" + + def _built(self, **options): + effect = DigitalDelay(silence_src(512), sample_rate=RATE, **options) + return (effect._frames, effect.get_macro(TIME_I), + effect.get_macro(TONE_I), effect.get_macro(CUT_I), + effect._tone_damping(effect._macros[TONE_I]), + effect._cut_hz(effect._macros[CUT_I])) + + def test_zero_builds_and_means_the_stop(self): + bottom_and_out = (600, 0.0, 127.0, 0.0, 0.0, 0.0) + for options in ({"time_ms": 0.0, "tone_hz": 0.0, "cut_hz": 0.0}, + {"time_ms": -5.0, "tone_hz": -1.0, "cut_hz": -1.0}, + {"time_ms": 5.0, "tone_hz": 30000.0, "cut_hz": 5.0}): + self.assertEqual(self._built(**options), bottom_and_out, + options) + + def test_zero_filters_render_as_filters_out(self): + values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) + values += [0] * 4096 + renders = [] + for options in ({}, {"tone_hz": 0.0, "cut_hz": 0.0}): + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=12.5, feedback=0.7, **options) + renders.append(pull(effect, len(values))) + self.assertTrue(np.array_equal(renders[0], renders[1])) + self.assertGreater(int(np.abs(renders[0]).max()), 0) + + def _clicks(self, **options): + """Impulses at frames 1 000 and 30 000 through the class (Feedback + 0, Mix 2), Time moved to 100 ms on the block at frame 29 952: the + frames each impulse comes back at, and the knobs read after.""" + values = [0] * 50000 + values[1000] = 30000 + values[30000] = 30000 + effect = DigitalDelay(array_src(values), sample_rate=RATE, + feedback=0.0, mix=2.0, **options) + frames = effect._frames + + def move(frame): + if frame == 29952: + effect.set_macro(TIME_I, midi_of_ms(100.0)) + + out = left(pull(effect, len(values), on_block=move), 2) + first = int(np.argmax(np.abs(out[:29952]))) - 1000 + second = int(np.argmax(np.abs(out[30001:]))) + 1 + return frames, first, second, effect.get_macro(GLIDE_I) + + def test_nan_glide_and_nan_line_build_on_their_stated_values(self): + # Gate re-audit 1: a NaN `glide_ms` passed the 8 s clamp and handed + # the node a NaN slew, which neither walks nor jumps, so a Time + # move did nothing (the click stayed at 16 800 frames) while + # get_macro(3) read the jump; a NaN `max_time_ms` passed both + # clamps, and the class reported 16 800 frames while the line + # played a 1-frame delay. NaN Glide is now the jump, as 0 and a + # negative are, and NaN `max_time_ms` is 800 ms. + nan = float("nan") + self.assertEqual(self._clicks(glide_ms=nan), (16800, 16800, 4800, + 0.0)) + self.assertEqual(self._clicks(glide_ms=0.0), (16800, 16800, 4800, + 0.0)) + frames, first, second, _ = self._clicks(max_time_ms=nan, + glide_ms=0.0) + self.assertEqual((frames, first, second), (16800, 16800, 4800)) + effect = DigitalDelay(silence_src(512), sample_rate=RATE, + max_time_ms=nan) + self.assertEqual(effect._max_time_ms, 800.0) + effect.set_macro(TIME_I, 127) + self.assertEqual(effect._frames, 38400) + + +class ZeroBpmHost(unittest.TestCase): + def test_a_tempo_that_is_not_finite_leaves_time_on_the_knob(self): + # Fix round 2: NaN and infinity passed the round-2 guard + # (`bpm <= 0.0`) and landed Time at 12.5 ms (600 frames). + for bpm in (float("nan"), float("inf"), float("-inf")): + def transport(_bpm=bpm): + return (True, 0.0, _bpm, 4, 4) + measured, _ = t4_delay(DigitalDelay, transport=transport, + sync=127, division=51, time_ms=350.0) + self.assertLessEqual(abs(measured - 16800), 1.0, bpm) + measured, _ = t4_delay(NanBpmDelay, transport=transport, + sync=127, division=51, time_ms=350.0) + if bpm > 0.0 or bpm != bpm: + self.assertLessEqual(abs(measured - 600), 1.0, bpm) + + def test_no_tempo_leaves_time_on_the_knob(self): + for bpm in (0.0, None): + def transport(_bpm=bpm): + return (True, 0.0, _bpm, 4, 4) + measured, _ = t4_delay(DigitalDelay, transport=transport, + sync=127, division=51, time_ms=350.0) + self.assertLessEqual(abs(measured - 16800), 1.0, bpm) + measured, _ = t4_delay(ZeroBpmDelay, transport=transport, + sync=127, division=51, time_ms=350.0) + self.assertLessEqual(abs(measured - 12000), 1.0, bpm) + + +class RepeatToneKnee(unittest.TestCase): + """Repeat Tone's clamp: at 22.05 kHz positions 111-126 sit on the + 10 804.5 Hz ceiling and 127 is out; at 44.1 and 48 kHz every position + moves.""" + + def _dampings(self, rate): + effect = DigitalDelay(silence_src(64, 2, rate), sample_rate=rate) + return [effect._tone_damping(p / 127.0) for p in range(128)] + + def test_the_knee(self): + values = self._dampings(22050) + self.assertLess(values[110], values[111]) + self.assertEqual(len(set(values[111:127])), 1) + self.assertEqual(values[127], 0.0) + for rate in (48000, 44100): + values = self._dampings(rate) + self.assertTrue(all(b > a for a, b in zip(values[:126], + values[1:127])), rate) + self.assertEqual(values[127], 0.0) + + +# -------------------------------------------------------------------------- +# The two checks every planted fault and every row is held to + + +class FaultsAreUnreachable(unittest.TestCase): + """Every fault's reachability walk, at 48, 44.1 and 22.05 kHz, reading + what the node is handed (or what the output does) at each position.""" + + CHECKED = 8 * 17 + 6 + + def test_every_fault_is_off_the_surface_at_three_rates(self): + for rate in (48000, 44100, 22050): + for name, faulted, reading, ctor in REACH_WALKS: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor) + self.assertEqual(result["checked"], self.CHECKED) + + def test_the_old_out_stop_fault_is_dialled_at_22k(self): + with self.assertRaises(kit_faults.FaultReachable): + reach(OpenTopToneDelay, read_damping, 22050, {}) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire, beside + a control on the real class that must pass.""" + + def test_every_row_is_red_on_a_wire(self): + for name, measure in ( + ("T1", t1_measure), + ("T2", t2_measure), + ("T3", t3_measure), + ("T4", lambda cls: t4_measure(cls, positions=(0, 8, 16))), + ("T5", t5_measure)): + with self.subTest(row=name): + result = kit_faults.null_build_red( + DigitalDelay, measure, label="DigitalDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + +# -------------------------------------------------------------------------- +# The trial of the second process (2026-09-29): the docstring's claims that +# no earlier test asserted, each beside the reading that would refute it. + + +def max_step_after(y, frame, frames=BLOCK): + """The largest first difference of `y` within `frames` of `frame`.""" + seg = y[frame - 1:frame + frames] + return float(np.max(np.abs(np.diff(seg)))) + + +class TrialClaims(unittest.TestCase): + LEVEL = 12000 + HZ = 440.0 + + #: The Time move's tone: 50 ms is 23.5 of its cycles, so the jump lands + #: the read head half a cycle away (at 440 Hz, 22 whole cycles, a jump + #: would be seamless and prove nothing). + MOVE_HZ = 470.0 + + def _bar(self, hz=None): + """1.5 x the tone's own steepest step, the lifecycle matrix's P5.""" + return 1.5 * 2.0 * math.pi * (hz or self.HZ) / RATE * self.LEVEL + + def _time_move(self, glide_ms): + """A 470 Hz tone at Mix 2, Feedback 0, Time 200 -> 150 ms on the + block at frame 20 480; the left channel.""" + frames = 20480 + RATE // 2 + values = sine_values(self.MOVE_HZ, frames, RATE, self.LEVEL) + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=200.0, feedback=0.0, mix=2.0, + glide_ms=glide_ms) + + def move(frame): + if frame == 20480: + effect.set_macro(TIME_I, midi_of_ms(150.0)) + y = left(pull(effect, frames, on_block=move), 2) + effect.deinit() + return y + + def test_glide_0_jumps_with_a_click(self): + # Glide 0: the read head jumps, and the output steps far past the + # tone's own slope. At the default Glide the same move does not. + bar = self._bar(self.MOVE_HZ) + self.assertGreater(max_step_after(self._time_move(0.0), 20480), + 2.0 * bar) + self.assertLess(max_step_after(self._time_move(4000.0), 20480, + RATE // 4), bar) + + def _mix_moves(self, steps): + """The tone at Time 12.5 ms, Feedback 0, Mix from MIDI 0 to 127 in + `steps` equal moves, one per block from frame 20 480.""" + frames = 20480 + (steps + 8) * BLOCK + values = sine_values(self.HZ, frames, RATE, self.LEVEL) + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=12.5, feedback=0.0, mix=0.0) + + def move(frame): + k = (frame - 20480) // BLOCK + 1 + if frame >= 20480 and k <= steps: + effect.set_macro(MIX_I, 127.0 * k / steps) + y = left(pull(effect, frames, on_block=move), 2) + effect.deinit() + return float(np.max(np.abs(np.diff(y[20479:])))) + + def test_a_jump_steps_and_small_steps_do_not(self): + # The family limit (audiocomponents#117): Mix 0 -> 2 in one move + # steps the output; the same move in 127 steps from the host, one + # a block, stays under the bar. + self.assertGreater(self._mix_moves(1), 4.0 * self._bar()) + self.assertLess(self._mix_moves(127), self._bar()) + + def _tail_across_a_stop(self, stop): + """A 50 ms burst into Time 100 ms, Feedback 0.5, Mix 2, then + silence; after 24 pulls the source hands empty buffers for `stop` + pulls, then silence again. Returns (the bytes handed while it was + stopped, the 40 blocks pulled after it came back).""" + burst = array("h") + for v in sine_values(self.HZ, 2400, RATE, self.LEVEL): + burst.extend((v, v)) + feed = lifecycle.Feed(burst, RATE, 2, "256", False) + effect = DigitalDelay(feed.port, sample_rate=RATE, time_ms=100.0, + feedback=0.5, mix=2.0) + for _ in range(24): + audiocore.get_buffer(effect.output) + feed.point(feed.empty) + stopped = bytearray() + for _ in range(stop): + stopped.extend(bytes(audiocore.get_buffer(effect.output)[1])) + feed.point(feed.sil) + after = bytearray() + while len(after) < 40 * BLOCK * 4: + after.extend(bytes(audiocore.get_buffer(effect.output)[1])) + effect.deinit() + return bytes(stopped), bytes(after[:40 * BLOCK * 4]) + + def test_the_tail_waits_for_the_source(self): + # The family limit (audiodsp#180): a source that hands empty + # buffers stops the tail; when it feeds again the tail carries on + # where it was, as if the stop had not happened. + stopped, after = self._tail_across_a_stop(30) + self.assertEqual(stopped.strip(b"\x00"), b"") + self.assertGreater(max(abs(v) for v in array("h", after)), 1000) + self.assertEqual(after, self._tail_across_a_stop(0)[1]) + + def test_mix_2_is_the_repeats_alone(self): + # A click at Mix 2: nothing until the repeat, 100 ms later. + values = [0] * 8192 + values[10] = 30000 + effect = DigitalDelay(array_src(values), sample_rate=RATE, mix=2.0, + time_ms=100.0, feedback=0.0) + out = left(pull(effect, 8192), 2) + self.assertEqual(float(np.max(np.abs(out[:4810]))), 0.0) + self.assertEqual(float(out[4810]), 30000.0) + + def test_repeat_cut_in_has_no_tail_bound(self): + # Repeat Cut's high-pass can take a lap's peak above the last one, + # so the per-lap count does not hold: `None` at patch 5 and at any + # Cut position above the out stop; an int with Cut out. + effect = DigitalDelay(silence_src(512), sample_rate=RATE, patch=5) + self.assertGreater(effect.get_macro(CUT_I), 0.0) + self.assertIsNone(effect.tail_samples) + for position in (1, 64, 127): + effect.set_macro(CUT_I, position) + self.assertIsNone(effect.tail_samples, position) + effect.set_macro(CUT_I, 0) + self.assertIsInstance(effect.tail_samples, int) + for patch in (0, 1, 2, 3, 4): + effect.program_change(patch) + self.assertIsInstance(effect.tail_samples, int, patch) + + def test_patch_5_is_the_pedal_corners(self): + # Patch 5's Repeat Tone and Repeat Cut positions are the 7 kHz and + # 40 Hz corners T5 measures, within one step of the 7-bit knob. + grid = DigitalDelay.PATCHES[5][1] + ranges = DigitalDelay._MACRO_RANGES + tone = _component.macro_value(ranges[TONE_I], grid[TONE_I] / 127.0) + cut = _component.macro_value(ranges[CUT_I], grid[CUT_I] / 127.0) + step = 20.0 ** (1.0 / 127.0) - 1.0 + self.assertLessEqual(abs(tone / 7000.0 - 1.0), step, tone) + self.assertLessEqual(abs(cut / 40.0 - 1.0), step, cut) + + def test_construction_needs_audioecho(self): + saved = sys.modules.get("audioecho", False) + sys.modules["audioecho"] = None + try: + with self.assertRaises(ImportError): + DigitalDelay(silence_src(512), sample_rate=RATE) + finally: + if saved is False: + del sys.modules["audioecho"] + else: + sys.modules["audioecho"] = saved + DigitalDelay(silence_src(512), sample_rate=RATE).deinit() + + +class SyncLimits(unittest.TestCase): + """Second fixer (2026-09-29): Sync's two limits, at the attacker's + settings. A Division longer than 800 ms stops at 800 ms, and the tempo + is read only when a control moves or a patch loads.""" + + #: The sixteen Divisions in beats, written out here: 1/32, 1/16T, + #: 1/32D, 1/16, 1/8T, 1/16D, 1/8, 1/4T, 1/8D, 1/4, 1/2T, 1/4D, 1/2, + #: 1/1T, 1/2D, 1/1. + BEATS = (1 / 8, 1 / 6, 3 / 16, 1 / 4, 1 / 3, 3 / 8, 1 / 2, 2 / 3, + 3 / 4, 1, 4 / 3, 3 / 2, 2, 8 / 3, 3, 4) + + def test_divisions_stop_at_800_ms(self): + for bpm in (120.0, 60.0): + def transport(_bpm=bpm): + return (True, 0.0, _bpm, 4, 4) + clamped = 0 + for index, beats in enumerate(self.BEATS): + ideal = beats * 60.0 / bpm * RATE + expected = min(ideal, 0.8 * RATE) + measured, _ = t4_delay(DigitalDelay, transport=transport, + sync=127, + division=index * 127.0 / 15.0) + self.assertLessEqual(abs(measured - expected), 1.0, + (bpm, index)) + if expected < ideal: + clamped += 1 + self.assertGreater(abs(measured - ideal), 1.0) + self.assertEqual(clamped, 4 if bpm == 120.0 else 7, bpm) + + def _tempo_change(self, action=None, channels=2): + """Sync on, a quarter note, Glide 0, Feedback 0, Mix 2. The host + goes from 120 to 60 bpm at frame 24 064, `action(effect)` runs, and + an impulse follows at frame 48 000: the echo's distance.""" + at = 48000 + frames = at + 50000 + values = [0] * frames + values[at] = 32767 + tempo = [120.0] + + def transport(): + return (True, 0.0, tempo[0], 4, 4) + effect = DigitalDelay.create(array_src(values, channels), RATE, + transport=transport, feedback=0.0, + mix=2.0, glide_ms=0.0) + effect.set_macro(SYNC_I, 127) + effect.set_macro(DIVISION_I, 9 * 127.0 / 15.0) + + def change(frame): + if frame == 24064: + tempo[0] = 60.0 + if action is not None: + action(effect) + y = np.abs(left(pull(effect, frames, channels, on_block=change), + channels)) + effect.deinit() + y[at] = 0.0 + return int(np.argmax(y)) - at + + def test_a_tempo_change_waits_for_a_control(self): + self.assertEqual(self._tempo_change(), 24000) + self.assertEqual(self._tempo_change(channels=1), 24000) + # Any control moved, even to where it already is, reads the new + # tempo: a 60 bpm quarter, clamped to 800 ms. + for index in range(8): + def touch(effect, _index=index): + effect.set_macro(_index, effect.get_macro(_index)) + self.assertEqual(self._tempo_change(touch), 38400, index) + self.assertEqual(self._tempo_change(touch, channels=1), 38400) + # A patch load reads it too. Patch 2 syncs on a dotted eighth and + # glides, so the Time it hands the node is read, not rendered: + # 750 ms at 60 bpm, 375 ms at 120. + for bpm, frames in ((120.0, 18000), (60.0, 36000)): + tempo = [120.0] + effect = DigitalDelay.create( + silence_src(512), RATE, + transport=lambda: (True, 0.0, tempo[0], 4, 4)) + tempo[0] = bpm + effect.program_change(2) + self.assertEqual(effect._frames, frames, bpm) + effect.deinit() + + +# -------------------------------------------------------------------------- +# The docstring's claims + +#: Every claim the module docstring makes, word for word, and the tests that +#: assert it ("Class.test_name", in this file). +CLAIMS = ( + ("Time is the delay, from 12.5 to 800 ms, and Feedback is how much of " + "each repeat goes round again, up to 0.99.", + ("T4TimeLaw.test_the_map_at_the_stops_and_between", + "Tier1Fast.test_the_tail_is_bounded_at_every_feedback")), + ("Mix is the echo level: the dry stays at unity up to Mix 1, Mix 2 is " + "the repeats alone, and at Mix 0 the output is the input.", + ("T1DryIsAWire.test_the_hardest_cells", + "T1DryIsAWire.test_defaults_stereo_and_mono", + "TrialClaims.test_mix_2_is_the_repeats_alone", + "Tier1Fast.test_mix_zero_is_a_wire_on_the_full_scale_ramp")), + ("Turn Time while it plays and the repeats bend in pitch and settle, " + "instead of clicking.", + ("T2TimeResamples.test_the_row_cell_falls_on_the_law", + "T2TimeResamples.test_a_rising_move_at_the_default_glide", + "T2TimeResamples.test_the_block_staircase_is_red")), + ("Glide is how long a full-range Time move takes, from 800 ms to 8 s.", + ("TheSurface.test_glide_law", + "T2TimeResamples.test_a_wrong_glide_law_is_red")), + ("Glide 0 is an instant knob, and its price is a click.", + ("TrialClaims.test_glide_0_jumps_with_a_click",)), + ("Repeat Tone is a low-pass and Repeat Cut a high-pass inside the loop, " + "so each repeat is a little darker or thinner than the last.", + ("T5BandLimit.test_the_tone_compounds_in_the_loop", + "T5BandLimit.test_the_cut_compounds_in_the_loop")), + ("Repeat Tone's top stop and Repeat Cut's bottom stop take them out.", + ("TheSurface.test_filter_stops_are_exactly_zero", + "T5BandLimit.test_an_open_top_stop_is_red")), + ("With Sync on, Time is Division of the host's beat, up to 800 ms; " + "with no host tempo, Time stays where the knob is.", + ("T4TimeLaw.test_sync_quantises_time_into_the_map", + "SyncLimits.test_divisions_stop_at_800_ms", + "T4TimeLaw.test_no_host_leaves_time_on_the_knob", + "ZeroBpmHost.test_no_tempo_leaves_time_on_the_knob", + "ZeroBpmHost.test_a_tempo_that_is_not_finite_leaves_time_on_the_knob")), + ("The class reads the tempo only when a control moves or a patch " + "loads, so after a tempo change Time keeps the old beat until you " + "move a control.", + ("SyncLimits.test_a_tempo_change_waits_for_a_control",)), + ("Patch 5 puts the DD-2's 7 kHz and 40 Hz corners in the loop.", + ("TrialClaims.test_patch_5_is_the_pedal_corners", + "T5BandLimit.test_the_corners_at_48k")), + ("The DD-2's compander and its HOLD are not here.", + ("TheSurface.test_macros_patches_tier_latency",)), + ("At 48 kHz the repeats of a Time you have stopped turning do not " + "darken.", + ("T3NoDarkening.test_the_constructor_time_at_48k_and_22k", + "TheSurface.test_where_the_node_lands_the_handed_frame")), + ("At 44.1 and 22.05 kHz a few Times land a hair off the whole frame, " + "and at those each repeat spills a little onto the frame beside it.", + ("TheSurface.test_where_the_node_lands_the_handed_frame", + "TheSurface.test_an_off_frame_time_leaks_into_the_next_frame")), + ("A rising Time move at the fastest Glides can read more than 10 cents " + "off the ideal bend, because the node walks its read head in single " + "precision.", + ("T2TimeResamples.test_the_pitch_is_read_per_binade", + "T2TimeResamples.test_inside_the_band_is_red_and_outside_it_holds", + "T2TimeResamples.test_the_unclaimed_band_is_where_the_binade_model_says")), + ("At 22.05 kHz the top positions of Repeat Tone sit on one clamp below " + "Nyquist and sound the same.", + ("RepeatToneKnee.test_the_knee",)), + ("The dry sits at unity and the repeats add to it, so a hot input can " + "reach the int16 rail.", + ("InputCeiling.test_the_stated_ceiling_is_clean_and_just_over_is_not",)), + ("With Repeat Cut out and Mix below 1, an input that peaks at or below " + "floor(32767 (1 - Mix)) - 1 cannot reach the rail, at any Time or " + "Feedback.", + ("InputCeiling.test_the_any_material_bound",)), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + ("TrialClaims.test_a_jump_steps_and_small_steps_do_not",)), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", + ("TrialClaims.test_the_tail_waits_for_the_source",)), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", + ("TrialClaims.test_the_tail_waits_for_the_source",)), + ("Latency is zero samples: nothing looks ahead.", + ("Tier1Fast.test_click_delay_is_zero", + "TheSurface.test_macros_patches_tier_latency")), + ("`tail_samples` is an upper bound on how long the output takes to " + "reach exact zero once your input stops, at every Feedback, with " + "Repeat Tone in or out.", + ("Tier1Fast.test_the_tail_reaches_exact_zero_inside_tail_samples", + "Tier1Fast.test_the_tail_is_bounded_at_every_feedback", + "Tier1Fast.test_repeat_tone_stall_cells_reach_zero_at_the_feedback_set", + "Tier1Fast.test_every_window_centre_reaches_zero", + "Tier1Fast.test_a_falling_walk_keeps_the_old_time_in_the_tail")), + ("With Repeat Cut in circuit, as at patch 5, `tail_samples` is `None`: " + "the class gives no bound there.", + ("TrialClaims.test_repeat_cut_in_has_no_tail_bound",)), + ("Pass a lower `max_time_ms` for a shorter line: Time then stops at " + "that ceiling, and `get_macro(0)` shows where it stopped.", + ("T4TimeLaw.test_a_lowered_ceiling_clamps_visibly",)), + ("A constructor Time of 0 is the bottom of its span, and a Repeat Tone " + "or Repeat Cut of 0 is that filter out.", + ("ConstructorZeros.test_zero_builds_and_means_the_stop", + "ConstructorZeros.test_zero_filters_render_as_filters_out")), + ("`reset()` empties the line and returns to patch 0.", + ("Tier1Fast.test_reset_empties_the_line",)), + ("The class reads the host's transport only while Sync is on.", + ("Tier1Fast.test_the_transport_is_read_only_with_sync_on",)), + ("The class needs audiodsp's `audioecho`, and on a board without it " + "construction raises `ImportError`.", + ("TrialClaims.test_construction_needs_audioecho",)), +) + +#: Model names that carry digits and are not figures. +NAMES = ("DD-2",) + +FAMILY_HEADING = "**Limits shared by the family.**" + + +def _flat(text): + return " ".join(text.split()) + + +def claim_problems(doc, claims=CLAIMS): + """What is wrong between a docstring and `claims`: a sentence missing, a + named test that does not exist, a digit outside every claim.""" + doc = _flat(doc) + problems = [] + rest = doc + for sentence, tests in claims: + if sentence not in doc: + problems.append("missing: %s" % sentence) + rest = rest.replace(sentence, " ") + for name in tests: + owner, _, test = name.partition(".") + if not hasattr(globals().get(owner), test): + problems.append("no test %s" % name) + for name in NAMES: + rest = rest.replace(name, " ") + for match in re.finditer(r"\S*\d\S*", rest): + problems.append("figure outside a claim: %s" % match.group()) + return problems + + +class Claims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + self.assertEqual(claim_problems(dd.__doc__), []) + self.assertEqual(claim_problems(DigitalDelay.__doc__, ()), []) + self.assertIn(FAMILY_HEADING, _flat(dd.__doc__)) + # The checker can fail: a figure outside a claim, a claim the + # docstring does not carry, a test that does not exist. + self.assertTrue(claim_problems(dd.__doc__ + " It reads 12 ms.")) + self.assertTrue(claim_problems(_flat(dd.__doc__).replace( + "nothing looks ahead", "nothing looks back"))) + self.assertTrue(claim_problems(dd.__doc__, CLAIMS + ( + ("Latency is zero samples: nothing looks ahead.", + ("Tier1Fast.test_nothing_here",)),))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_distortion.py b/tests/test_cpython_effects_distortion.py index 4dfbcc9..f229be2 100644 --- a/tests/test_cpython_effects_distortion.py +++ b/tests/test_cpython_effects_distortion.py @@ -1541,5 +1541,72 @@ def test_program_change_onto_digital_silence_stays_silent(self): "LSB" % (patch, peak)) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + At the scoop patches the output capacitor is charged on the bias again + before the port is pointed back, as `_build` charges it. + """ + + CLS = rebuilt.Distortion + MIX = 4 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_dynamiceq.py b/tests/test_cpython_effects_dynamiceq.py index e9e3105..2a93202 100644 --- a/tests/test_cpython_effects_dynamiceq.py +++ b/tests/test_cpython_effects_dynamiceq.py @@ -800,28 +800,27 @@ def test_wire_goes_red_on_a_one_lsb_dry_path(self): print("\n WIRE fault: first differing sample %r" % (first,)) self.assertIsNotNone(first) - def test_wire_goes_red_on_a_dry_voice_at_unity(self): - """audiodsp#95's fault, which is the wiring this class shipped with. - - A mixer voice at level 1.0 is not unity - upstream's Q15 level is - `1.0 * 32768` and the kernel divides by 32767 - so the dry tap at - unity came out one LSB high at every sample from 32736 up. Three of - 16384 on a full-scale ramp, all in the right channel, because a - stereo voice at pan 0 gets 32767 on the left and 32768 on the right. - Nothing below -6.02 dBFS can reach it, which is why the row is a - ramp and this fault was invisible to a -4.4 dBFS tone for a year. + def test_the_dry_voice_at_unity_is_a_wire_since_audiodsp_v0_6_1(self): + """audiodsp#95's plant, kept as a control now that it cannot fire. + + A mixer voice at level 1.0 was not unity - upstream's Q15 level was + `1.0 * 32768` and the kernel divided by 32767 - so the wiring this + class shipped with came out one LSB high at every sample from + 32736 up: three of 16384 on a full-scale ramp, all in the right + channel. audiodsp v0.6.1 (#129) made the voice at 1.0 exact and the + pin moved there on 2026-09-27 (`AUDIODSP_PIN`). The row above still + has its fault, the one-LSB voice; this is the floor's own control, + on the ramp because nothing below -6.02 dBFS could reach the old + defect. If it goes red the floor has moved back under the suite. """ data = ramp_fs(8192) effect, _ = build(data, ThroughTheDryVoice, mix=0.0) out = render(effect.output, 8192) differ = [index for index in range(min(len(out), len(data))) if out[index] != data[index]] - print("\n WIRE unity fault: %d differing, first %r" + print("\n WIRE unity control: %d differing, first %r" % (len(differ), differ[0] if differ else None)) - self.assertTrue(differ) - self.assertTrue(all(abs(out[index] - data[index]) == 1 - for index in differ)) - self.assertTrue(all(abs(data[index]) >= 32736 for index in differ)) + self.assertEqual(differ, []) def test_tail_goes_red_on_a_held_dc_state(self): """TAIL's fault: the audiodsp#23 shape, a state that never arrives. @@ -961,5 +960,93 @@ def test_state_goes_red_when_a_node_is_left_out_of_the_walk(self): self.assertGreater(faulted, 1000) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + """ + + CLS = module.DynamicEQ + MIX = 6 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + patch=1) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + def test_mono_renders_one_block_at_a_time(self): + # The mixer used to render 512 mono frames at a time, and the tail + # kept the second half queued: a Mix move to 0 between the halves + # skipped it, and the move back played it. Rendered a block at a + # time, nothing is queued, and the class comes back in step at + # 44.1 kHz mono, where the move lands between the halves. + self.assertLessEqual(stale.in_step(module.DynamicEQ, 6, 127, 0, + 44100, 1), 3) + # Planted: the fixed 2048-byte mixer, which is two blocks mono. + # With the tail cleared on the way back the class comes back 256 + # frames ahead of an instance that never moved. + real = module.audiomixer.Mixer + + def wide(*args, **kwargs): + kwargs["buffer_size"] = 2048 + return real(*args, **kwargs) + + module.audiomixer.Mixer = wide + try: + self.assertGreater(stale.in_step(module.DynamicEQ, 6, 127, 0, + 44100, 1), 1000) + finally: + module.audiomixer.Mixer = real + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_cpython_effects_exciter.py b/tests/test_cpython_effects_exciter.py index e75f2ae..8515259 100644 --- a/tests/test_cpython_effects_exciter.py +++ b/tests/test_cpython_effects_exciter.py @@ -1418,5 +1418,69 @@ def test_the_block_length_moves_no_sample(self): rebuilt.MIXER_BUFFER_BYTES = kept +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + """ + + CLS = rebuilt.Exciter + MIX = 2 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_flanger.py b/tests/test_cpython_effects_flanger.py index babafbf..dc0155f 100644 --- a/tests/test_cpython_effects_flanger.py +++ b/tests/test_cpython_effects_flanger.py @@ -103,6 +103,34 @@ class SoftColorFlanger(Flanger): COLOR_CEILING = 0.5 +class _CutAtColorMaxFlanger(Flanger): + """F8's ring, planted: the loop high-pass at Color max moved from + 0.4 Hz to `COLOR_MAX_CUT_HZ`, the same travel from 20 Hz at Color 0. + The ring's length is what moves; Color and the comb do not.""" + NAME = 'Flanger' + COLOR_MAX_CUT_HZ = rebuilt.CUT_HZ_COLOR_MAX + + def _refresh(self): + Flanger._refresh(self) + color = rebuilt._color_to_feedback( + self._value(2), type(self).COLOR_CEILING, type(self).COLOR_LAW) + travel = min(1.0, max(0.0, float(color) / 0.99)) + self._wet.set(cut_hz=rebuilt.CUT_HZ + travel * ( + type(self).COLOR_MAX_CUT_HZ - rebuilt.CUT_HZ)) + + +class ShortRingFlanger(_CutAtColorMaxFlanger): + """F8: the Color-max cut at 0.8 Hz, a ring about 0.1-0.3 s shorter.""" + NAME = 'Flanger' + COLOR_MAX_CUT_HZ = 0.8 + + +class LongRingFlanger(_CutAtColorMaxFlanger): + """F8: the Color-max cut at 0.2 Hz, a ring about 0.05-0.1 s longer.""" + NAME = 'Flanger' + COLOR_MAX_CUT_HZ = 0.2 + + class ShortLatencyFlanger(Flanger): """CLICK: report 256 samples short.""" NAME = 'Flanger' @@ -522,6 +550,17 @@ def test_ceiling_is_not_a_macro(self): lambda subject: build(subject, probe=probes.silence(2048)), label="Flanger F8") + def test_ring_faults_are_not_a_macro(self): + # The node's cut is write-only, so the reading is the Color-max + # corner the class keeps, as for the ceiling above. + for fault in (ShortRingFlanger, LongRingFlanger): + kit_faults.fault_reachability( + Flanger, fault, + lambda e: getattr(type(e), "COLOR_MAX_CUT_HZ", + rebuilt.CUT_HZ_COLOR_MAX), + lambda subject: build(subject, probe=probes.silence(2048)), + label="Flanger F8 ring") + def test_soft_ceiling_fires_at_defaults(self): probe = sine(1000, frames=8192) clean = build(probe=probe) @@ -559,16 +598,37 @@ def _audio_t60(self, cls=Flanger, color=0.99, listen_s=3.0, hz=440.0): t60 = (start - burst) / float(rate) return t60 - def test_audio_t60_at_color_max_is_at_least_two_seconds(self): - """F8 audio: Color-max cut is 0.4 Hz so the ring clears 2 s.""" - t60 = self._audio_t60() - self.assertIsNotNone(t60, "never reached −60 dB") - self.assertGreaterEqual(t60, 2.0, t60) - - def test_audio_t60_at_200hz_is_at_least_two_seconds(self): - t60 = self._audio_t60(hz=200.0) - self.assertIsNotNone(t60, "never reached −60 dB") - self.assertGreaterEqual(t60, 2.0, t60) + #: F8 restated on 2026-09-28 (Brad's ruling of that date): what the + #: node does at audiodsp v0.6.2, measured, not the 2.0 s bar. The ring + #: at Color max read 2.13 s at 440 Hz and 2.35 s at 200 Hz at v0.6.1; + #: audiodsp#154 empties the line where rounding used to hold a few LSB + #: going round for ever, and part of the old ring was that floor. The + #: reading steps in 10 ms hops, and the tolerance is three of them: + #: the Color-max loop cut at 0.8 Hz or at 0.2 Hz instead of 0.4 moves + #: the ring out of it at both frequencies. + F8_RING_S = {440.0: 1.91, 200.0: 2.13} + F8_TOLERANCE_S = 0.03 + + def _f8_ring_holds(self, cls, hz, listen_s=3.0): + t60 = self._audio_t60(cls, hz=hz, listen_s=listen_s) + return (t60 is not None + and abs(t60 - self.F8_RING_S[hz]) <= self.F8_TOLERANCE_S), t60 + + def _f8_row(self, hz): + held, t60 = self._f8_ring_holds(Flanger, hz) + self.assertTrue(held, (hz, t60)) + # Planted: a ring 0.1-0.3 s shorter, and one 0.05-0.1 s longer. + for cls in (ShortRingFlanger, LongRingFlanger): + held, t60 = self._f8_ring_holds(cls, hz) + self.assertFalse(held, (cls.__name__, hz, t60)) + + def test_audio_t60_at_color_max_is_the_measured_ring(self): + """F8 audio at 440 Hz: 1.91 s to -60 dB at Color max.""" + self._f8_row(440.0) + + def test_audio_t60_at_200hz_is_the_measured_ring(self): + """F8 audio at 200 Hz: 2.13 s to -60 dB at Color max.""" + self._f8_row(200.0) def test_soft_ceiling_audio_t60_is_under_two_seconds(self): t60 = self._audio_t60(SoftColorFlanger) @@ -577,13 +637,12 @@ def test_soft_ceiling_audio_t60_is_under_two_seconds(self): def test_f8_audio_null_build_is_red(self): def measure(cls): - t60 = self._audio_t60(cls, listen_s=2.5) - passed = t60 is not None and t60 >= 2.0 - return {"passed": passed, "red": [{"t60": t60}]} + held, t60 = self._f8_ring_holds(cls, 440.0, listen_s=2.5) + return {"passed": held, "red": [{"t60": t60}]} kit_faults.null_build_red(Flanger, measure, label="Flanger F8 audio") def test_default_audio_tail_is_not_two_seconds(self): - """Headline: constructor Color 0.55 is ~0.1 s, not F8's Color-max 2 s.""" + """Headline: constructor Color 0.55 is ~0.1 s, not F8's Color-max ring.""" effect = build(probe=probes.silence(2048)) self.assertLess(effect.tail_samples / float(RATE), 0.2) effect.deinit() diff --git a/tests/test_cpython_effects_fuzz.py b/tests/test_cpython_effects_fuzz.py index c7521d1..3c181fb 100644 --- a/tests/test_cpython_effects_fuzz.py +++ b/tests/test_cpython_effects_fuzz.py @@ -2241,5 +2241,82 @@ def test_program_change_onto_digital_silence_stays_silent(self): "output pole was charged" % (patch, peak, self.RESIDUAL_LSB)) + +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + `germanium` primes nothing at construction, so the construction variant + reads 0 before and after; off-centre Bias the output pole is charged + again, and a clear without that charge is red at shipped patch 2. + """ + + CLS = rebuilt.Fuzz + MIX = 5 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + # `germanium` primes no voice at construction, so the construction + # variant cannot tell a clear-only cure apart: the next test does. + + def test_a_clear_without_the_charge_is_red_off_centre(self): + # Shipped patch 2 is Bias -0.844: cleared and not re-charged, the + # output pole starts cold and the offset bangs out of silence. + _before, after = stale.blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX, 127, + 0, patch=2) + self.assertGreater(after, 10000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_library.py b/tests/test_cpython_effects_library.py index 55807d7..f1aec81 100644 --- a/tests/test_cpython_effects_library.py +++ b/tests/test_cpython_effects_library.py @@ -36,6 +36,12 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) from effects_measure import SAMPLE_RATE, build, peak, source # noqa: E402 +# `reverb.Reverb` is the old `_core.Effect` class, the one with `preset` and +# no macro surface, which `audioeffects.Reverb` served until the Phase 5 +# rebuild's adoption on 2026-09-29. The two tests that name it are about +# those two properties of that class. +from audioeffects import reverb # noqa: E402 + #: Every name the package exports as an effect. `ALL` is the catalogue the #: contract freezes - the provider names, computed by the package itself - #: so these tests read it rather than deriving the set from `dir()` again. @@ -82,7 +88,7 @@ def test_effects_chain_into_each_other(self): drive = audioeffects.Exciter(source(), tune=2500.0, harmonics=0.4) comp = audioeffects.Compressor(drive.output, threshold_db=-30.0, ratio=6.0, character="optical") - verb = audioeffects.Reverb(comp.output, preset="hall", mix=0.35) + verb = reverb.Reverb(comp.output, preset="hall", mix=0.35) self.assertGreater(peak(verb.output, 12), 0.001) def test_an_instrument_feeds_an_effect(self): @@ -156,7 +162,7 @@ def test_a_program_change_to_a_patch_that_is_not_there_is_ignored(self): self.assertEqual(effect._macros, before) def test_a_class_without_macros_declares_an_empty_macro_surface(self): - plain = audioeffects.Reverb(source()) + plain = reverb.Reverb(source()) self.assertEqual(plain.MACRO_LABELS, ()) self.assertEqual(plain.MACRO_MODES, {}) self.assertEqual(plain.PATCHES, {0: ("Default", ())}) diff --git a/tests/test_cpython_effects_limiter.py b/tests/test_cpython_effects_limiter.py index 0fbbee8..9e54388 100644 --- a/tests/test_cpython_effects_limiter.py +++ b/tests/test_cpython_effects_limiter.py @@ -1030,5 +1030,75 @@ def test_true_peak_off_is_byte_for_byte_what_it_always_was(self): self.assertEqual(digests[0], digests[1]) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class NoStageClear(rebuilt.Limiter): + """Planted: the class before the fix. A stage's delay grows and nothing + is cleared, so the line plays what it held when it was last that + long.""" + + def _clear_nodes(self, keep=(), only=None): + if only is None: + rebuilt.Limiter._clear_nodes(self, keep, only) + + +class ShapeStageOnlyClear(rebuilt.Limiter): + """Planted, a wrong cure: only the shape stage is cleared when its delay + grows. True Peak's twelve-sample reserve lives on the catch stage, and + that line keeps its old contents.""" + + def _clear_nodes(self, keep=(), only=None): + if only is not None and self._catch in only: + return + rebuilt.Limiter._clear_nodes(self, keep, only) + + +class ALookaheadThatGrowsPlaysNothingOld(unittest.TestCase): + """Lookahead back up after a pause plays nothing from before the pause + (audiocomponents#113; Brad, 2026-09-28: "fix the stale blocks"). The + node writes its lookahead line only as far as its delay reaches, so the + rest keeps what it held the last time the delay was that long, and + Lookahead off and back on replayed the last note at full level (31 373 + LSB). A stage whose delay grows is now cleared first.""" + + CLS = rebuilt.Limiter + + def test_lookahead_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + for low in (0, 40): + self.assertEqual( + stale.blip(self.CLS, 2, 127, low, rate, channels), + (0, 0), (rate, channels, low)) + for patch in sorted(self.CLS.PATCHES): + self.assertEqual(stale.blip(self.CLS, 2, 127, 0, patch=patch), + (0, 0), patch) + + def test_true_peak_moving_its_reserve_plays_nothing(self): + # Patches 1, 4 and 5 turn True Peak on with lookahead. + for patch in (1, 4, 5): + for a, b in ((0, 127), (127, 0)): + self.assertEqual( + stale.blip(self.CLS, 4, a, b, patch=patch), (0, 0), + (patch, a, b)) + + def test_the_old_class_and_a_shape_only_clear_are_red(self): + before, after = stale.blip(NoStageClear, 2, 127, 0) + self.assertEqual(before, 0) + self.assertGreater(after, 15000) + # True Peak on during the tone fills the catch stage's reserve; off + # hands the twelve samples back to the shape stage and the catch + # line stops being written; on again after the silence reads them. + before, after = stale.blip(ShapeStageOnlyClear, 4, 127, 0, patch=1) + self.assertEqual(before, 0) + self.assertGreater(after, 2000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_multiband.py b/tests/test_cpython_effects_multiband.py index 1837358..da91535 100644 --- a/tests/test_cpython_effects_multiband.py +++ b/tests/test_cpython_effects_multiband.py @@ -442,13 +442,14 @@ def test_mix_zero_is_a_wire_at_every_rate_and_channel_count(self): self.assertEqual( result["values"]["differing_samples"], 0) - def test_the_dry_voice_at_unity_is_the_fault_the_wire_catches(self): - """The planted fault, and it is the wiring this class used to have: - Mix 0 routed through the mixer's dry voice at level 1.0. - - Without this the row above is a measurement nobody has shown able to - fail - and the difference is one LSB on 15 samples of 32768, which - no summarising statistic would have found either. + def test_the_dry_voice_at_unity_is_a_wire_since_audiodsp_v0_6_1(self): + """audiodsp#95's plant, kept as a control now that it cannot fire. + + The wiring this class used to have - Mix 0 through the mixer's dry + voice at level 1.0 - came out one LSB high on 15 samples of 32768 + until audiodsp v0.6.1 (#129) made the voice at 1.0 exact. The pin + moved there on 2026-09-27 (`AUDIODSP_PIN`); if this goes red the + floor has moved back under the suite. """ values = probes.ramp_fs(16384) effect = build(ThroughTheDryVoice, probe=values, mix=0.0) @@ -458,6 +459,26 @@ def test_the_dry_voice_at_unity_is_the_fault_the_wire_catches(self): effect.deinit() dry = kit.Render(bytes(memoryview(values).cast("B")), RATE, 2) result = kit.wire(wet, dry, latency_samples=0) + self.assertTrue(result["passed"], result["red"]) + self.assertEqual(result["values"]["differing_samples"], 0) + + def test_the_wire_goes_red_on_a_one_lsb_dry_path(self): + """The fault the WIRE row is shown catching: the dry voice at + 32767/32768, the kit spec's own plant. Without this the row above + is a measurement nobody has shown able to fail - and the + difference is one LSB, which no summarising statistic would find. + It has to put the mixer back in the path to be planted at all, + because Mix 0 no longer runs through one. + """ + values = probes.ramp_fs(16384) + effect = build(ThroughTheDryVoice, probe=values, mix=0.0) + try: + effect._mixer.voice[0].level = 32767.0 / 32768.0 + wet = render(effect, 16384) + finally: + effect.deinit() + dry = kit.Render(bytes(memoryview(values).cast("B")), RATE, 2) + result = kit.wire(wet, dry, latency_samples=0) self.assertFalse(result["passed"]) self.assertEqual(result["values"]["max_abs_difference_lsb"], 1) self.assertGreater(result["values"]["differing_samples"], 0) @@ -849,5 +870,69 @@ def nonzero(cls, block, old_ring=False): self.assertEqual(nonzero(CLASS, 16384, old_ring=True), 0) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + """ + + CLS = multibandcompressor.MultibandCompressor + MIX = 13 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py new file mode 100644 index 0000000..49fbd6e --- /dev/null +++ b/tests/test_cpython_effects_multitapdelay.py @@ -0,0 +1,3272 @@ +"""`MultiTapDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/MultiTapDelay.md` +(frozen at anchor 02e7e0c, the Station A revision); its Tier 2 rows are +T1-T5. Each row here is the measurement at a few of the row's cells and the +same measurement shown red on a planted fault of the same kind, at the +constructor defaults. Every fault is shown unreachable from every macro +position and shipped patch, and every row's measurement is shown red on the +class built as a wire. The full spans, the three interpreters and the rates +live in the evidence pack, not in this file. + +Every law a measurement checks against is written out here from the +dossier, never taken from the class: the whole-frame landing, the lap +clamp, S1's head sets, Tilt's line and the lap node's float32 hand-off. + +The class is reached by `rebuilt.module_class("MultiTapDelay")`, which is also what +`audioeffects.MultiTapDelay` serves since its adoption on 2026-09-29. + +Three plants are built for real here that Station A emulated: T4's +per-head 6 kHz low-pass (a second tap node for head 2 behind an +`audiofilters.Filter`), T5 clause 2's compose-first build (a front Filter +into the tap node's own decay) and, since fix round 2, T5 clause 1's +(`Head2OwnLoop`: head 2 read through its own, darker lap node, Station C's +plant ported to the fix round 1 graph). The dossier's emulation of clause +1's plant on the rendered windows stays beside it. + +Re-audit fix round 1 (2026-09-28) moved the class to audiodsp v0.6.3rc1, +whose lap node lands a stalled loop low-pass (audiodsp#157): the Feedback is +handed as set, and `ReauditRoundOne` holds that beside the retired stepping. +Its route tests run one numpy-free script (`ROUTES_MODULE`) under CPython +and under the workspace's desktop MicroPython and CircuitPython where they +are present and current for `AUDIODSP_PIN`: a mono dry block lost after a +reset shows only on a native interpreter, whose mixer voice points into the +tap's own buffer where the CPython shim copies. + +Re-audit fix round 2 (2026-09-28) adds `ReauditRoundTwo`: the same script +with `audiocore.get_buffer` withheld from the class (a stand-in module, so +it runs natively too), every route held to what the docstring says such a +build plays, beside a plant that moves the first block; and `NoTapLap`, a +tail bound one lap short, red on the tail reading at a cell where the tail +comes within a lap. +""" + +import math +import os +import subprocess +import sys +import tempfile +import types +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import audiodelays # noqa: E402 +import audioecho # noqa: E402 +import audiofilters # noqa: E402 +import audiomixer # noqa: E402 +import audioroute # noqa: E402 +import synthio # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +import audioeffects # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import multitapdelay as mtd # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls, laps_to_zero) +from tools import effect_measurements as kit # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +RATES = (48000, 44100, 22050) +BLOCK = 256 +(TIME_I, PATTERN_I, HEADS_I, FEEDBACK_I, MIX_I, TILT_I, TONE_I, SYNC_I, + DIVISION_I) = range(9) + +MultiTapDelay = rebuilt.module_class("MultiTapDelay") + + +# -------------------------------------------------------------------------- +# The dossier's laws, written out independently of the class + +#: S1's table, modes 1-11 (section 6 row 1); mode 12 is every head 1 ... K. +S1 = ((1,), (2,), (3,), (1, 2), (2, 3), (1, 3), (1, 2, 3), (1, 4), (3, 4), + (1, 3, 4), (1, 2, 4)) + + +def law_heads(mode, heads): + if mode == 12: + return tuple(range(1, heads + 1)) + return tuple(k for k in S1[mode - 1] if k <= heads) + + +def law_frames(time_ms, rate): + """Section 6 row 0: the nearest whole frame.""" + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +def law_landed(time_ms, heads, rate, max_lap_ms=1600.0): + """(n1, P): the base landed, clamped so K n1 fits max_lap_ms.""" + n1 = max(1, law_frames(time_ms, rate)) + n1 = min(n1, int(math.floor(max_lap_ms * rate / 1000.0)) // heads) + return n1, heads * n1 + + +#: Section 4 (revised in fix round 1): the tap node reads its input one +#: block after the dry has played it, so each head is handed k n1 - LAG +#: frames and sounds at k n1 against the dry. +LAW_LAG = 256 + + +def law_time_ms(midi): + """Section 6 row 0: 20-400 ms, log, on the 0-127 grid.""" + return 20.0 * 20.0 ** (midi / 127.0) + + +def law_pattern_midi(mode): + """The MIDI position whose index floor(11 p + 0.5) is mode - 1.""" + return int(round((mode - 1) * 127.0 / 11.0)) + + +def law_heads_midi(heads): + return int(round((heads - 3) * 127.0 / 5.0)) + + +def law_tilt_levels(selected, heads, tilt): + """Section 6 row 5: 12 dB x Tilt from head 1 to head K, loudest + sounding head at 1.0.""" + db = [12.0 * tilt * (k - 1) / (heads - 1) for k in selected] + top = max(db) + return [10.0 ** ((d - top) / 20.0) for d in db] + + +def f32(x): + return array("f", (float(x),))[0] + + +def law_node_frames(value, rate): + """The lap node's frames, `value * rate / 1000.0f`, in float32.""" + return f32(f32(f32(value) * f32(rate)) / f32(1000.0)) + + +def law_lap_node_ms(lap, rate): + """Section 4, the lap node's hand-off: the least float32 value, by + one-unit steps from float32(P 1000 / fs), whose frames are >= P.""" + def step(v, up): + raw = array("I", array("f", (v,)).tobytes()) + raw[0] += 1 if up else -1 + return array("f", raw.tobytes())[0] + v = f32(lap * 1000.0 / rate) + while law_node_frames(v, rate) < lap: + v = step(v, True) + while law_node_frames(v, rate) > lap: + lower = step(v, False) + if law_node_frames(lower, rate) < lap: + break + v = lower + return v + + +#: Section 6's patch table in engineering units, which `macro_of` puts on +#: the grid: (Time ms, mode, Heads, Feedback, Mix, Tilt, Tone Hz, Sync, +#: Division index). Patch 1's Tone is its out stop, the span's top. +DOSSIER_PATCHES = ( + ("Three Heads, Even", (150.0, 7, 3, 0.45, 0.35, 0.0, 4000.0, 0, 3)), + ("Three Heads, Even - lean", + (150.0, 7, 3, 0.45, 0.35, 0.0, 16000.0, 0, 3)), + ("Two Heads, Near Loudest", (180.0, 4, 4, 0.5, 0.35, -0.5, 4000.0, 0, 3)), + ("Four Heads, Far Loudest", (74.0, 12, 4, 0.5, 0.35, 0.5, 5000.0, 0, 3)), + ("Eight Heads, Dense", (40.0, 12, 8, 0.55, 0.3, 0.0, 3000.0, 0, 3)), + ("One Head, Long Repeats", (120.0, 3, 3, 0.7, 0.35, 0.0, 2500.0, 0, 3)), + ("Triplet Grid, Synced", (150.0, 7, 3, 0.45, 0.35, 0.0, 4000.0, 1, 4)), +) +SPANS = ((20.0, 400.0, "log"), (0.0, 11.0), (3.0, 8.0), (0.0, 0.95), + (0.0, 2.0), (-1.0, 1.0), (800.0, 16000.0, "log"), (0.0, 1.0), + (0.0, 15.0)) +MODES = ("UNIPOLAR", "UNIPOLAR", "UNIPOLAR", "UNIPOLAR", "UNIPOLAR", + "BIPOLAR", "UNIPOLAR", "TOGGLE", "UNIPOLAR") + + +# -------------------------------------------------------------------------- +# Material and rendering + + +class Endless: + """int16 frames, BLOCK per pull, then silence for ever: a finite probe + would stop the lap node's line, which only advances on frames that + arrive.""" + + def __init__(self, data, rate=RATE, channels=2): + self.sample_rate = int(rate) + self.channel_count = int(channels) + self.bits_per_sample = 16 + self.samples_signed = True + self._pcm = bytes(data) + self._stride = BLOCK * channels * 2 + self._position = 0 + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + self._position = 0 + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + chunk = self._pcm[self._position:self._position + self._stride] + self._position += self._stride + if len(chunk) < self._stride: + chunk += bytes(self._stride - len(chunk)) + return 1, memoryview(chunk) + + +def click(level=20000, channels=2, at=0): + data = array("h", [0] * (BLOCK * 4 * channels)) + for ch in range(channels): + data[at * channels + ch] = level + return data + + +def silence(frames=BLOCK, channels=2): + return array("h", [0] * (frames * channels)) + + +def pull(effect, frames): + channels = effect.channel_count + want = frames * channels * 2 + pcm = bytearray() + while len(pcm) < want: + chunk = bytes(audiocore.get_buffer(effect.output)[1]) + if not chunk: + pcm += bytes(want - len(pcm)) + break + pcm += chunk + return np.frombuffer(bytes(pcm[:want]), dtype=" 0, + "arrivals": len(arrivals), "late": off} + late = [a for lane in lanes for a in arrivals + if out[a, lane] == 0 or out[a - 1, lane] != 0] + return {"passed": not late and len(arrivals) > 0, "late": late, + "arrivals": len(arrivals)} + + +def spectra(out, n1, lap, heads, laps=4, size=4096, lane=0): + win = np.hanning(size) + res = {} + for n in range(1, laps + 1): + for k in heads: + a = (n - 1) * lap + k * n1 + seg = out[a - size // 2:a + size // 2, lane].astype(float) + peak = float(np.abs(seg).max()) + mag = np.abs(np.fft.rfft(seg * win)) + res[(n, k)] = (20.0 * np.log10(np.maximum(mag, 1e-9)), peak, seg) + return res + + +def t45_reading(cls, rate=RATE, feedback=0.45, tone=4000.0, heads=3, + mode=7, midi=None, emulate=None, data=None, time_ms=200.0): + """T4 and T5 at one cell: click 24 000, Mix 2, Tilt 0, t1 200 ms, + 4096-point Hann windows at every arrival of laps 1-4, on every lane. + Returns lane 0's worst pairwise T4 spread, worst clause-1 increment + spread, corner and 8/5 kHz D2/D4 of head 1, whether every head sounded + (every window non-zero) and the lap-1 peaks, with the same for every + lane under "lanes"; `t4_green` and `t5_green` judge every lane.""" + midi = dict(midi or {}) + midi.setdefault(MIX_I, 127) + size = 4096 + effect = build(cls, data=click(24000) if data is None else data, + rate=rate, midi=midi, time_ms=time_ms, heads=heads, + pattern=mode, feedback=feedback, tone_hz=tone) + selected = law_heads(mode, heads) + n1, lap = law_landed(time_ms, heads, rate) + out = pull(effect, 4 * lap + size) + effect.deinit() + freqs = np.fft.rfftfreq(size, 1.0 / rate) + band = (freqs >= 100.0) & (freqs <= 10000.0) + i100 = int(np.argmin(np.abs(freqs - 100.0))) + corner = min(tone, rate * 0.5 * _component.NYQUIST_MARGIN) + top = 8000.0 if rate > 30000 else 5000.0 + lanes = [] + for lane in range(out.shape[1]): + res = spectra(out, n1, lap, selected, size=size, lane=lane) + if emulate is not None: + res = emulate(res, rate) + present = all(res[(n, k)][1] > 0 for n in range(1, 5) + for k in selected) + t4 = 0.0 + t5 = 0.0 + for n in range(1, 5): + norm = np.array([res[(n, k)][0] - 20.0 * math.log10( + max(res[(n, k)][1], 1.0)) for k in selected]) + if len(selected) > 1: + t4 = max(t4, float(np.max(np.ptp(norm[:, band], axis=0)))) + if n > 1 and len(selected) > 1: + inc = np.array([res[(n, k)][0] - res[(1, k)][0] + for k in selected]) + t5 = max(t5, float(np.max(np.ptp(inc[:, band], axis=0)))) + h = selected[0] + + def dark(n, f, res=res, h=h): + iq = int(np.argmin(np.abs(freqs - f))) + inc = res[(n, h)][0] - res[(1, h)][0] + return -float(inc[iq] - inc[i100]) + lanes.append({"t4": t4, "t5": t5, "present": present, + "lap1": [res[(1, k)][1] for k in selected], + "c2": dark(2, corner), "c4": dark(4, corner), + "q2": dark(2, top), "q4": dark(4, top)}) + result = dict(lanes[0]) + result["lanes"] = lanes + return result + + +def one_pole(seg, hz, rate, passes=1): + """A one-pole low-pass over a rendered window: the dossier's emulation + of a per-head filter (T4's B4, T5 clause 1's B5).""" + a = 1.0 - math.exp(-2.0 * math.pi * hz / rate) + y = np.asarray(seg, dtype=float) + for _ in range(passes): + z = np.empty_like(y) + s = 0.0 + for i, v in enumerate(y): + s += a * (v - s) + z[i] = s + y = z + return y + + +def darken_head2_per_lap(res, rate, size=4096): + """T5 clause 1's plant, emulated as the dossier states it: head 2's + window through n - 1 extra passes of a 6 kHz one-pole at lap n.""" + win = np.hanning(size) + out = dict(res) + for (n, k), (_db, _peak, seg) in res.items(): + if k != 2 or n == 1: + continue + y = one_pole(seg, 6000.0, rate, passes=n - 1) + mag = np.abs(np.fft.rfft(y * win)) + out[(n, k)] = (20.0 * np.log10(np.maximum(mag, 1e-9)), + float(np.abs(y).max()), y) + return out + + +# -------------------------------------------------------------------------- +# Planted faults + + +class LateHeads(MultiTapDelay): + """T1 (1): every head below K handed one frame late, + (k n1 - LAG + 1.5) / P.""" + + NAME = 'MultiTapDelay' + + def _tap_positions(self, selected, n1, lap): + heads = lap // n1 + return tuple((k * n1 - LAW_LAG + (0.5 if k == heads else 1.5)) / lap + for k in selected) + + +class LongLap(MultiTapDelay): + """T1 (2), the lap clause's own: the lap node handed P + 1 frames.""" + + NAME = 'MultiTapDelay' + + def _lap_node_ms(self, lap): + return law_lap_node_ms(lap + 1, self._sample_rate) + + +class ShortTapLap(MultiTapDelay): + """T2 (a): the tap node's lap one frame short, heads still at + (k n1 + 0.5) / P.""" + + NAME = 'MultiTapDelay' + + def _tap_node_ms(self, lap): + return (lap - 1 + 0.5) * 1000.0 / self._sample_rate + + +class NarrowTime(MultiTapDelay): + """T2 (b): Time's span handed as 120-200 ms, log. The constructor's + 150 ms still lands on the same frame.""" + + NAME = 'MultiTapDelay' + _MACRO_RANGES = ((120.0, 200.0, "log"),) + \ + MultiTapDelay._MACRO_RANGES[1:] + + +class RotatedTable(MultiTapDelay): + """T3: S1's table rotated by one position: position m sounds mode + m + 1's set, position 12 mode 1's.""" + + NAME = 'MultiTapDelay' + + def _head_set(self, mode, heads=None): + return MultiTapDelay._head_set(self, mode % 12 + 1, heads) + + +class FilteredHead(MultiTapDelay): + """T4: head 2 read by a second tap node behind a 6 kHz low-pass, built + for real: the lap node's output split into two tap nodes, head 2's + behind the filter, summed as a third voice of the output Mixer (a + nested Mixer would reset the tap nodes' lines when it is played). Both + tap nodes are wired the class's way, a block of zeros first and one + block behind the dry. Full graph only; the plant is read at Tone in.""" + + NAME = 'MultiTapDelay' + CORNER_HZ = 6000.0 + + def _wire(self, quiet=False): + rate, channels = self._sample_rate, self._channel_count + self._plant_filtered_head = True + self._fd.play(self._tap1) + split = audioroute.Splitter(self._fd, taps=2) + self._filter = audiofilters.Filter( + filter=synthio.Biquad(synthio.FilterMode.LOW_PASS, + type(self).CORNER_HZ), + mix=1.0, buffer_size=BLOCK * channels * 2, sample_rate=rate, + channel_count=channels) + self._filter.play(split.tap(1)) + self._head2 = audiodelays.MultiTapDelay( + max_delay_ms=1601, delay_ms=self._tap_ms, decay=0.0, mix=1.0, + taps=self._head2_taps(), buffer_size=BLOCK * channels * 2, + sample_rate=rate, channel_count=channels) + self._feed2 = audioroute.Port(self._hush) + self._head2.play(self._feed2) + self._tapnode.taps = self._other_taps() + self._feed.play(self._hush) + self._tapnode.play(self._feed) + mix = self._value(MIX_I) + self._mixer = audiomixer.Mixer( + voice_count=3, buffer_size=BLOCK * channels * 4, + channel_count=channels, sample_rate=rate) + self._mixer.voice[0].level = min(1.0, 2.0 - mix) + self._mixer.voice[1].level = min(1.0, mix) + self._mixer.voice[2].level = min(1.0, mix) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry) + self._mixer.voice[1].play(self._tapnode) + self._mixer.voice[2].play(self._head2) + self._feed.play(split.tap(0)) + self._feed2.play(self._filter) + self._tail.play(self._mixer) + self._plugged = False + + def _other_taps(self): + return tuple(t for k, t in zip(self._selected, self._taps) if k != 2) + + def _head2_taps(self): + return tuple(t for k, t in zip(self._selected, self._taps) if k == 2) + + def _refresh(self): + MultiTapDelay._refresh(self) + if getattr(self, "_head2", None) is not None: + self._tapnode.taps = self._other_taps() + self._mixer.voice[2].level = self._mixer.voice[1].level + + +class FrontFilter(MultiTapDelay): + """T5 clause 2: the seed's compose-first build, built for real - a + front `audiofilters.Filter` (low-pass at the Tone value) into the tap + node, whose own decay makes the laps. It decays and never darkens. The + filter holds the source's first block from its own prime, which is the + one block the tap node runs behind the dry.""" + + NAME = 'MultiTapDelay' + + def _wire(self, quiet=False): + rate, channels = self._sample_rate, self._channel_count + self._plant_front_filter = True + self._filter = audiofilters.Filter( + filter=synthio.Biquad(synthio.FilterMode.LOW_PASS, + self._hz(self._value(TONE_I))), + mix=1.0, buffer_size=BLOCK * channels * 2, sample_rate=rate, + channel_count=channels) + self._filter.play(self._tap1) + self._tapnode.decay = self._value(FEEDBACK_I) + self._feed.play(self._hush) + self._tapnode.play(self._feed) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry) + self._mixer.voice[1].play(self._tapnode) + self._feed.play(self._filter) + self._plugged = False + + def _refresh(self): + MultiTapDelay._refresh(self) + self._tapnode.decay = self._value(FEEDBACK_I) + + +class PrimingReset(MultiTapDelay): + """Station B review's finding, planted: the base's `reset()`, which + wires a never-wired graph by priming a block of the borrowed source + and restores patch 0 without dropping the block tap 1 holds.""" + + NAME = 'MultiTapDelay' + + def reset(self): + _component.Component.reset(self) + + def _quiet_wiring(self): + # The base's reset never sets `_resetting`, and before re-audit fix + # round 1 a wiring outside `reset()` primed the source. + return self._resetting + + +class StaleReset(MultiTapDelay): + """Tier 1 STATE, planted: `reset()` empties both lines but leaves the + one block tap 1 holds for the tap node, audio from before the reset.""" + + NAME = 'MultiTapDelay' + + def _resync(self): + self._fd.clear() + audiocore.reset_buffer(self._tapnode) + self._feed.play(self._target(self._lean)) + self._plugged = self._lean + + +class PrimedWire(MultiTapDelay): + """Gate audit round 1, item 1, planted: Station B's wiring. The tap + node primes the source's first block and renders it into its planar + line for the wet voice's prime, level with the dry, heads handed at + (k n1 + 0.5) / P with head K at 1.0, so a Time or Heads move before + the first pull re-bases that block.""" + + NAME = 'MultiTapDelay' + + def _tap_positions(self, selected, n1, lap): + heads = lap // n1 + return tuple(1.0 if k == heads else (k * n1 + 0.5) / lap + for k in selected) + + def _wire(self, quiet=False): + self._plant_primed_wire = True + self._fd.play(self._tap1) + self._feed.play(self._target(self._lean)) + self._tapnode.play(self._feed, loop=False) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry, loop=False) + self._mixer.voice[1].play(self._tapnode, loop=False) + self._plugged = self._lean + + +class LateReset(MultiTapDelay): + """Item 2, planted: a reset that puts a silent block in front of the + source (fix round c80ca59's quiet prime did), so every later frame is + a block late against a reported 0.""" + + NAME = 'MultiTapDelay' + _plant_late_reset = True + + def _resync(self): + MultiTapDelay._resync(self) + if self._resetting: + self._adapter.play(self._hush) + self._mixer.voice[0].play(self._dry, loop=False) + self._adapter.play(self._source) + + +class MixerTail(MultiTapDelay): + """Item 3, planted: the output port on the Mixer, as before the tail. + A host's reset reaches the Mixer, whose voices re-prime from the + Splitter's taps and drop the block they hold.""" + + NAME = 'MultiTapDelay' + + def _route(self): + MultiTapDelay._route(self) + if self._ready and not self._at_source: + self._plant_mixer_tail = True + self._output = self._mixer + + +class PrimingPlug(MultiTapDelay): + """Item 4, planted: a Repeat Tone crossing that re-plays the tap node + (Station B's `_plug`), which primes a block from its new source; two + crossings between pulls take two.""" + + NAME = 'MultiTapDelay' + _plant_priming_plug = True + + def _plug(self, lean): + if not lean: + self._fd.clear() + self._feed.play(self._target(lean)) + self._tapnode.play(self._feed, loop=False) + self._plugged = lean + + +class PannedLaps(MultiTapDelay): + """Item 5, the lane-1 plant (the material refuter's): the lap node's + input steered to the left line (`input_pan` -1), so the right lane + keeps lap 1 (the lap node's dry pass) and loses every later lap.""" + + NAME = 'MultiTapDelay' + + def _refresh(self): + MultiTapDelay._refresh(self) + self._plant_panned_laps = True + self._fd.set(input_pan=-1.0) + + +class CrossedLaps(MultiTapDelay): + """Item 5 (the material refuter's): the lap node's cross-feed at 1, + so every lap swaps lanes.""" + + NAME = 'MultiTapDelay' + + def _refresh(self): + MultiTapDelay._refresh(self) + self._plant_crossed_laps = True + self._fd.set(cross_feed=1.0) + + +class LapMixOne(MultiTapDelay): + """Item 6 (the material refuter's): the lap node's `mix` at 1.0, not + the Feedback (dossier section 4's correction undone), so lap 2 is at + unity against lap 1.""" + + NAME = 'MultiTapDelay' + + def _refresh(self): + MultiTapDelay._refresh(self) + self._lap_mix = 1.0 + self._fd.set(mix=1.0) + + +class ToneHalf(MultiTapDelay): + """Item 6 (the material refuter's): Repeat Tone's corner at half the + knob, one pass about -7 dB at the knob's frequency, not -3.""" + + NAME = 'MultiTapDelay' + + def _tone_damping(self): + if self._macros[TONE_I] >= 1.0: + return 0.0 + return nominal_damping_hz(0.5 * self._hz(self._value(TONE_I)), + self._sample_rate) + + +class PullingResync(MultiTapDelay): + """Gate audit round 2, item 1, planted: fix round 1's `_resync` + (d419ac4), which drops tap 1's pending block with a pull and primes the + tap node with zeros. A second call before the next pull takes a block + of the source's future, and every later head sounds a block early.""" + + NAME = 'MultiTapDelay' + _plant_pulling_resync = True + + def _resync(self): + pull = getattr(audiocore, "get_buffer", None) + self._fd.clear() + audiocore.reset_buffer(self._tapnode) + if pull is not None: + pull(self._tap1) + self._feed.play(self._hush) + self._tapnode.play(self._feed, loop=False) + self._feed.play(self._target(self._lean)) + self._plugged = self._lean + + +class NoRateFloor(MultiTapDelay): + """Gate audit round 2, item 2, planted: the class without its rate + floor (d419ac4). At 12 800 Hz Time 20 ms lands on 256 frames, head 1 is + handed an offset of 0, which reads a whole lap back, and every head + sounds 256 frames late; below it the constructor's own Time raises + from the node.""" + + NAME = 'MultiTapDelay' + + def _check_rate(self, rate): + del rate + + +class Head2OwnLoop(MultiTapDelay): + """T5 clause 1, built for real (Station C's plant, ported to the fix + round 1 graph): head 2 is read by a second tap node behind a second + lap node whose loop low-pass sits at half Repeat Tone's corner + (pre-warped the class's way), so on every lap after the first head 2 + darkens more than heads 1 and 3; lap 1, the lap nodes' dry pass, is the + same for every head. Both lap nodes are fed from a second Splitter on + tap 1, and head 2 is a third voice of the output Mixer. Both tap nodes + are wired the class's way, a block of zeros first and one block behind + the dry. Full graph only; read at Repeat Tone in.""" + + NAME = 'MultiTapDelay' + + def _damping2(self): + if self._macros[TONE_I] >= 1.0: + return 0.0 + return nominal_damping_hz(0.5 * self._hz(self._value(TONE_I)), + self._sample_rate) + + def _others(self): + return tuple(t for k, t in zip(self._selected, self._taps) if k != 2) + + def _mine(self): + return tuple(t for k, t in zip(self._selected, self._taps) if k == 2) + + def _wire(self, quiet=False): + rate, channels = self._sample_rate, self._channel_count + self._plant_head2_loop = True + split = audioroute.Splitter(self._tap1, taps=2) + self._fd.play(split.tap(0)) + self._fd2 = audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, + max_delay_ms=self._max_lap_ms + 1.0, delay_ms=self._lap_ms, + feedback=self._feedback, mix=self._feedback, + damping_hz=self._damping2(), cut_hz=0.0, delay_slew=0.0) + self._fd2.play(split.tap(1)) + self._head2 = audiodelays.MultiTapDelay( + max_delay_ms=int(math.ceil(self._max_lap_ms)) + 1, + delay_ms=self._tap_ms, decay=0.0, mix=1.0, taps=self._mine(), + buffer_size=BLOCK * channels * 2, sample_rate=rate, + channel_count=channels) + self._feed2 = audioroute.Port(self._hush) + self._head2.play(self._feed2) + self._tapnode.taps = self._others() + self._feed.play(self._hush) + self._tapnode.play(self._feed) + mix = self._value(MIX_I) + self._mixer = audiomixer.Mixer( + voice_count=3, buffer_size=BLOCK * channels * 4, + channel_count=channels, sample_rate=rate) + self._mixer.voice[0].level = min(1.0, 2.0 - mix) + self._mixer.voice[1].level = min(1.0, mix) + self._mixer.voice[2].level = min(1.0, mix) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry) + self._mixer.voice[1].play(self._tapnode) + self._mixer.voice[2].play(self._head2) + self._feed.play(self._fd) + self._feed2.play(self._fd2) + self._tail.play(self._mixer) + self._plugged = False + + def _refresh(self): + MultiTapDelay._refresh(self) + if getattr(self, "_head2", None) is not None: + self._tapnode.taps = self._others() + self._head2.taps = self._mine() + self._fd2.set(delay_ms=self._lap_ms, feedback=self._feedback, + mix=self._feedback, damping_hz=self._damping2(), + cut_hz=0.0, delay_slew=0.0) + self._mixer.voice[2].level = self._mixer.voice[1].level + + +class Counting(Endless): + """`Endless` that counts the blocks it has handed out.""" + + pulls = 0 + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self.pulls += 1 + return Endless._get_buffer(self, single_channel_output, + audio_channel) + + +def reset_pulls(cls, start, channels=2): + """Blocks of the borrowed source `reset()` takes from each starting + graph: patch 1 (lean), Repeat Tone at its out stop, and a class built + at Mix 0 that has never wired.""" + source = Counting(probes.noise_det(4 * BLOCK, channels=channels), + RATE, channels) + effect = cls(source, sample_rate=RATE, + mix=0.0 if start == "mix0" else 0.35) + if start == "patch1": + effect.program_change(1) + elif start == "tone127": + effect.set_macro(TONE_I, 127) + pull(effect, 8 * BLOCK) + before = source.pulls + effect.reset() + taken = source.pulls - before + effect.deinit() + return taken + + +def state_reading(cls, patch, channels=2): + """The kit's STATE at `patch`, 48 kHz: reset with the probe still + sounding, then a silent source must render exact zero.""" + data = probes.noise_det(RATE // 2, dbfs=-6.0, channels=channels) + holder = probes.SwitchableSource( + probes.ArraySource(data, rate=RATE, channels=channels, block=BLOCK)) + effect = cls.create(holder, RATE) + effect.program_change(patch) + silent = probes.ArraySource(probes.silence(2048, channels), rate=RATE, + channels=channels, block=BLOCK) + + def render(blocks): + return probes.render(effect.output, blocks * BLOCK, rate=RATE, + channels=channels, block=BLOCK, + class_name="MultiTapDelay") + + return kit.state(effect, pull=render, swap=holder.swap, + probe_source=holder.inner, silent_source=silent, + blocks=64, alloc_pulls=50) + + +class NoClear(MultiTapDelay): + """Tier 1's reset plant: `reset()` restores patch 0 and leaves both + lines full.""" + + NAME = 'MultiTapDelay' + + def reset(self): + self._check_live() + self.program_change(0) + + +def reach_build(cls): + return cls(Endless(silence()), sample_rate=RATE) + + +def reach(faulted, reading, **kw): + return kit_faults.fault_reachability(MultiTapDelay, faulted, reading, + reach_build, **kw) + + +def head_offset_error(effect): + """What the tap node does with what the class hands it: each sounding + head's offset, the node's way (the lap truncated from `delay_ms`, the + offset truncated from the position), less k n1 - LAG.""" + rate = effect._sample_rate + lap = int(rate / 1000.0 * effect._tap_ms) + return tuple(int(lap * position) - (k * effect._n1 - LAW_LAG) + for k, (position, _level) in zip(effect._selected, + effect._taps)) + + +def lap_node_error(effect): + """The lap node's frames, the node's way, less P, floored: 0 on the + clean class at every setting.""" + return int(math.floor(law_node_frames(effect._lap_ms, + effect._sample_rate))) - effect._lap + + +def tap_lap_error(effect): + return int(effect._sample_rate / 1000.0 * effect._tap_ms) - effect._lap + + +def time_span_landed(effect): + """The landed t1 in ms at Time's two stops, at the current Heads.""" + span = type(effect)._MACRO_RANGES[TIME_I] + rate = effect._sample_rate + out = [] + for position in (0.0, 1.0): + n1, _lap = law_landed(_component.macro_value(span, position), + effect._heads, rate, effect._max_lap_ms) + out.append(n1 * 1000.0 / rate) + return tuple(out) + + +def head_table(effect): + return tuple(effect._head_set(mode) for mode in range(1, 13)) + + +def click_at(frame, channels=2, level=20000, tail=BLOCK * 4, lanes=None): + """A click at `frame` (every lane, or the lanes named), then silence.""" + data = array("h", [0] * ((frame + tail) * channels)) + for ch in (range(channels) if lanes is None else lanes): + data[frame * channels + ch] = level + return data + + +def lane_hits(out): + return [nonzero(out, lane) for lane in range(out.shape[1])] + + +def ctor_route(cls, move, rate=RATE, channels=2, at=0, pattern=None): + """Gate audit round 1, item 1: the plain constructor (`mix=2.0, + feedback=0.0`), then one setting before the first pull, a click at + frame `at` of every lane; every lane's non-zero frames against the + law for the knob positions.""" + opts = dict(sample_rate=rate, mix=2.0, feedback=0.0) + if pattern is not None: + opts["pattern"] = pattern + effect = cls(Endless(click_at(at, channels), rate, channels), **opts) + if move is not None: + effect.set_macro(*move) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate, + effect._max_lap_ms) + law = [at + k * n1 for k in law_heads(effect._pattern_mode(), + effect._heads)] + hits = lane_hits(pull(effect, at + lap + 512)) + effect.deinit() + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + +def patch_route(cls, patch, rate=RATE, channels=2): + """Item 1's program_change route: the constructor's defaults, + `program_change(patch)`, Feedback 0 and Mix 127 before the first pull. + Read from frame 1: a Mix move after wiring leaves frame 0 at the old + dry level in stereo (the surface record's item 4).""" + effect = cls(Endless(click(channels=channels), rate, channels), + sample_rate=rate) + effect.program_change(patch) + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 127) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate, + effect._max_lap_ms) + law = [k * n1 for k in law_heads(effect._pattern_mode(), + effect._heads)] + hits = [[f for f in h if f > 0] + for h in lane_hits(pull(effect, lap + 512))] + effect.deinit() + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + +RESET_ORIGIN = 60 * BLOCK + 101 # source frame 15 461 + + +def reset_route(cls, start, rate=RATE, channels=2): + """Item 2: 30 blocks from `start`, then `reset()`, Feedback 0 and Mix + 1.0 (MIDI 63.5), a click at source frame 15 461: where the dry click + lands against the source, and the heads against the dry.""" + source = Endless(click_at(RESET_ORIGIN, channels), rate, channels) + if start == "patch1": + effect = cls(source, sample_rate=rate, patch=1) + elif start == "mix0": + effect = cls(source, sample_rate=rate, mix=0.0) + else: + effect = cls(source, sample_rate=rate) + if start == "tone127": + effect.set_macro(TONE_I, 127) + head = pull(effect, 30 * BLOCK) + effect.reset() + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 63.5) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + law = [k * n1 for k in law_heads(effect._pattern_mode(), + effect._heads)] + rest = pull(effect, RESET_ORIGIN - 30 * BLOCK + lap + 1024) + reported = effect.latency_samples + effect.deinit() + out = np.concatenate([head, rest]) + hits = lane_hits(out) + dry = hits[0][0] if hits[0] else None + heads = [f - dry for f in hits[0][1:]] if hits[0] else [] + same = all(h == hits[0] for h in hits) + return {"passed": dry == RESET_ORIGIN and heads == law and same + and reported == 0, "dry": None if dry is None + else dry - RESET_ORIGIN, "heads": heads, "law": law} + + +HOST_CLICKS = (0, 3000) + + +def host_reset_route(cls, patch=None, rate=RATE, channels=2): + """Item 3: clicks at source frames 0 and 3 000, the host resets the + output before its first pull (`tools/render_effect.py` does, and so + does a mixer voice's `play()`), Feedback 0: each click's dry at its own + frame and its heads at +k n1.""" + data = array("h", [0] * (12000 * channels)) + for at in HOST_CLICKS: + for ch in range(channels): + data[at * channels + ch] = 20000 + effect = cls(Endless(data, rate, channels), sample_rate=rate, + feedback=0.0, mix=1.0) + if patch is not None: + effect.program_change(patch) + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 63.5) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + audiocore.reset_buffer(effect.output) + out = pull(effect, 3000 + lap + 512) + effect.deinit() + law = sorted(set(at + k * n1 for at in HOST_CLICKS + for k in (0,) + law_heads(effect._pattern_mode(), + effect._heads))) + hits = lane_hits(out) + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + +def host_reset_lines_route(cls, rate=RATE, channels=2): + """Item 3 mid-stream (re-audit fix round 1): a click at source frame + 7 000, 30 blocks, the host resets the output, Feedback 0, Mix 1.0: the + host's reset leaves the lines as they are, so the click's heads sound + at +k n1 after it.""" + at = 7000 + data = array("h", [0] * (40000 * channels)) + for ch in range(channels): + data[at * channels + ch] = 20000 + effect = cls(Endless(data, rate, channels), sample_rate=rate, + feedback=0.0, mix=1.0) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + head = pull(effect, 30 * BLOCK) + audiocore.reset_buffer(effect.output) + rest = pull(effect, at + lap + 512 - 30 * BLOCK) + effect.deinit() + law = [at + k * n1 for k in (0,) + law_heads(effect._pattern_mode(), + effect._heads)] + hits = lane_hits(np.concatenate([head, rest])) + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + +def crossing_route(cls, crossings, rate=RATE, channels=2): + """Item 4: 40 blocks at Mix 1, Feedback 0, Time MIDI 64, then Repeat + Tone across its out stop and back `crossings` times in one gap, then + a click: the dry at its frame and the heads at +k n1 on every lane.""" + origin = 100 * BLOCK + 29 + effect = build(data=click_at(origin, channels), cls=cls, rate=rate, + channels=channels, midi={MIX_I: 63.5, TIME_I: 64}, + feedback=0.0) + head = pull(effect, 40 * BLOCK) + lean = False + for _ in range(crossings): + lean = not lean + effect.set_macro(TONE_I, 127 if lean else 68) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + law = [origin + k * n1 for k in (0,) + law_heads( + effect._pattern_mode(), effect._heads)] + rest = pull(effect, origin - 40 * BLOCK + lap + 1024) + effect.deinit() + hits = lane_hits(np.concatenate([head, rest])) + return {"passed": all(h == law for h in hits), + "hits": [[f - origin for f in h] for h in hits], + "law": [f - origin for f in law]} + + +#: Round 2, item 1: two clicks after an event made between two pulls at +#: 30 blocks, A in the first block after it and B in the second, at +#: different levels so their heads cannot be mistaken for each other. +RESYNC_CLICKS = ((30 * BLOCK + 10, 20000), (31 * BLOCK + 100, 10000)) + + +def resync_route(cls, steps, start="full", rate=RATE, channels=2): + """Gate audit round 2, item 1: 30 blocks from the defaults (the full + graph) or patch 1 (the lean graph), then `steps` between two pulls + ("reset" is `reset()`, "mix0" is Mix to 0 and back), then Feedback 0 + and Mix 1.0: every lane's non-zero frames against each click and its + heads at +k n1, n1 and the heads from the dossier's laws at the knob + positions.""" + total = 32 * BLOCK + 40000 + data = array("h", [0] * (total * channels)) + for at, level in RESYNC_CLICKS: + for ch in range(channels): + data[at * channels + ch] = level + source = Endless(data, rate, channels) + if start == "lean": + effect = cls(source, sample_rate=rate, patch=1) + else: + effect = cls(source, sample_rate=rate) + head = pull(effect, 30 * BLOCK) + for step in steps: + if step == "reset": + effect.reset() + else: + effect.set_macro(MIX_I, 0) + effect.set_macro(MIX_I, 63.5) + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 63.5) + heads = law_heads(effect._pattern_mode(), effect._heads) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + rest = pull(effect, 2 * BLOCK + 100 + lap + 512) + reported = effect.latency_samples + effect.deinit() + out = np.concatenate([head, rest]) + law = sorted(at + k * n1 for at, _level in RESYNC_CLICKS + for k in (0,) + heads) + hits = lane_hits(out) + return {"passed": all(h == law for h in hits) and reported == 0, + "hits": hits, "law": law} + + +def low_rate_first_lap(cls, rate, how, channels=2): + """Gate audit round 2, item 2: Time 20 ms by the constructor + (`time_ms=20`) or by `set_macro(0, 0)`, Feedback 0, Mix 2, a 20 000 + click in frame 0: every lane's non-zero frames against {k n1}.""" + if how == "ctor": + effect = cls(Endless(click(channels=channels), rate, channels), + sample_rate=rate, feedback=0.0, mix=2.0, time_ms=20.0) + else: + effect = cls(Endless(click(channels=channels), rate, channels), + sample_rate=rate, feedback=0.0, mix=2.0) + effect.set_macro(TIME_I, 0) + n1, lap = law_landed(20.0, 3, rate) + law = [k * n1 for k in law_heads(7, 3)] + hits = lane_hits(pull(effect, lap + 512)) + effect.deinit() + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + +def level_reading(cls, rate=RATE, tone=4000.0): + """Item 6, section 6 row 3's Feedback law (the material refuter's + reading): click 20 000, the defaults' cell (150 ms, mode 7, K 3), + Feedback MIDI 60; on every lane head 1's lap-n response summed over + one head spacing, against lap 1's, is the Feedback to the n - 1 within + 2 % (the loop low-pass has unity gain at DC); and at Feedback 0 nothing + sounds after the first lap.""" + f = 0.95 * 60 / 127.0 + midi = {FEEDBACK_I: 60, MIX_I: 127} + effect = build(cls, rate=rate, midi=midi, tone_hz=tone) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + out = pull(effect, 4 * lap + 512) + effect.deinit() + ratios = [] + err = 0.0 + for lane in range(out.shape[1]): + sums = [float(out[(n - 1) * lap + n1:(n - 1) * lap + 2 * n1, + lane].sum()) for n in range(1, 5)] + if sums[0] == 0.0: + return {"passed": False, "ratios": [], "err": 1.0, "after": 0} + lane_ratios = [value / sums[0] for value in sums] + ratios.append([round(value, 4) for value in lane_ratios]) + err = max([err] + [abs(lane_ratios[n] / f ** n - 1.0) + for n in range(1, 4)]) + zero = build(cls, rate=rate, midi={FEEDBACK_I: 0, MIX_I: 127}, + tone_hz=tone) + out = pull(zero, 2 * lap + n1 + 512) + zero.deinit() + after = int(np.count_nonzero(out[lap + 1:, :])) + return {"passed": err <= 0.02 and after == 0, "ratios": ratios, + "err": err, "after": after} + + +def corner_reading(cls, rate=RATE, tone=4000.0): + """Item 6, section 6 row 6's corner (the material refuter's reading): + one pass at Repeat Tone's corner, head 1's lap 2 against lap 1 there + less the same at 100 Hz, is 3 dB within 0.5 dB on every lane.""" + result = t45_reading(cls, rate=rate, tone=tone) + worst = max(abs(lane["c2"] - 3.0) for lane in result["lanes"]) + return {"passed": worst <= 0.5, "c2": [round(lane["c2"], 2) + for lane in result["lanes"]]} + + +def left_only_reading(cls, rate=RATE): + """Item 5, channel-different material: a click in the left lane only, + the full graph at the defaults, Feedback 0.45: the right lane stays + exactly zero, and the left lane has every lap's onset.""" + effect = build(cls, data=click_at(0, 2, lanes=(0,)), rate=rate, + midi={MIX_I: 127}, feedback=0.45) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + window = 4 * lap + 512 + out = pull(effect, window) + effect.deinit() + arrivals = lap_arrivals(n1, lap, (1, 2, 3), 5, window) + late = [a for a in arrivals if out[a, 0] == 0 or out[a - 1, 0] != 0] + right = int(np.count_nonzero(out[:, 1])) + return {"passed": right == 0 and not late, "right": right, + "right_peak": int(np.abs(out[:, 1]).max()), "late": late} + + +def damping_error(effect): + """The lap node's corner against the knob's own pre-warped corner, + as a ratio (1 on the clean class, 0 at the out stop).""" + if effect._damping <= 0.0: + return 0.0 + law = nominal_damping_hz(effect._hz(effect.macro(TONE_I)), + effect._sample_rate) + return round(effect._damping / law, 6) + + +def lap_mix_error(effect): + """The lap node's `mix` less its Feedback: 0 on the clean class.""" + return round(effect._lap_mix - effect._feedback, 6) + + +def flag(name): + return lambda effect: getattr(effect, name, False) + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + cls = MultiTapDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Pattern", "Heads", "Feedback", "Mix", + "Tilt", "Repeat Tone", "Sync", "Division")) + self.assertEqual(tuple(cls.MACRO_MODES[i] for i in range(9)), MODES) + self.assertEqual(len(cls.PATCHES), 7) + self.assertEqual(cls.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho", "audioroute")) + effect = build() + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.patch_index, None) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + self.assertIn("MultiTapDelay", rebuilt.ADOPTED) + self.assertNotIn("MultiTapDelay", rebuilt.parked()) + self.assertIs(audioeffects.MultiTapDelay, MultiTapDelay) + served = audioeffects.create( + "MultiTapDelay", Endless(silence(), RATE, 2), RATE) + self.assertIsInstance(served, MultiTapDelay) + served.deinit() + + def test_patch_table_is_the_dossier_on_the_grid(self): + for index, (name, values) in enumerate(DOSSIER_PATCHES): + engineering = list(values) + engineering[1] = engineering[1] - 1 + grid = tuple(_component.macro_of(span, value, mode) + for span, value, mode + in zip(SPANS, engineering, MODES)) + self.assertEqual(MultiTapDelay.PATCHES[index], (name, grid)) + + def test_patch_0_is_the_constructor_grid(self): + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE) + self.assertEqual(effect.patch_index, 0) + for index, expected in enumerate(MultiTapDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_constructor_options_clamp(self): + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE, + max_lap_ms=100.0, heads=8, time_ms=400.0) + self.assertEqual(effect._max_lap_ms, 540.0) + self.assertEqual(effect._n1, 25920 // 8) + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE, + max_lap_ms=float("nan"), time_ms=float("nan"), + tone_hz=0.0, pattern=40, heads=1) + self.assertEqual(effect._max_lap_ms, 1600.0) + self.assertEqual(effect._n1, 7200) + self.assertEqual(effect.get_macro(TONE_I), 127.0) + self.assertTrue(effect._lean) + self.assertEqual(effect._pattern_mode(), 12) + self.assertEqual(effect._heads, 3) + + def test_tilt_law(self): + # A7.11: K 4 mode 12, Tilt -1 reads 20 000 / 12 619 / 7 962 / 5 023. + frames, values, expected = first_lap( + MultiTapDelay, tone=0.0, heads=4, pattern=12, time_ms=20.0, + midi={TILT_I: 0}) + self.assertEqual(frames, expected) + levels = law_tilt_levels((1, 2, 3, 4), 4, -1.0) + self.assertEqual(values, [int(20000 * v) for v in levels]) + self.assertEqual(values, [20000, 12619, 7962, 5023]) + + def test_sync_reads_the_transport_only_when_on(self): + calls = [] + + def transport(): + calls.append(1) + return (True, 0.0, 120.0, 4, 4) + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE, + transport=transport) + self.assertEqual(calls, []) + effect.program_change(6) + self.assertTrue(calls) + # 1/8T at 120 bpm is 166.67 ms: 8000 frames. + self.assertEqual(effect._n1, 8000) + static = MultiTapDelay(Endless(silence()), sample_rate=RATE) + static.program_change(6) + self.assertEqual(static._n1, law_frames(law_time_ms(85), RATE)) + + def test_the_lap_node_hand_off_is_the_law(self): + # Section 4: never under P, at the 50 cells at 44.1 kHz where the + # plain value would land under. + effect = MultiTapDelay(Endless(silence(), rate=44100), + sample_rate=44100, mix=0.0) + under_plain = 0 + for heads in range(3, 9): + effect.set_macro(HEADS_I, law_heads_midi(heads)) + for time_midi in range(128): + effect.set_macro(TIME_I, time_midi) + lap = effect._lap + self.assertEqual(effect._lap_ms, law_lap_node_ms(lap, 44100)) + self.assertGreaterEqual( + law_node_frames(effect._lap_ms, 44100), lap) + if law_node_frames(lap * 1000.0 / 44100, 44100) < lap: + under_plain += 1 + self.assertEqual(under_plain, 50) + + +# -------------------------------------------------------------------------- +# Tier 1 + + +class Tier1(unittest.TestCase): + def test_mix_zero_is_a_wire(self): + for rate in RATES: + for channels in (2, 1): + for tone in (4000.0, 0.0): + data = probes.ramp_fs(8192, channels) + effect = MultiTapDelay( + Endless(data, rate, channels), sample_rate=rate, + mix=0.0, tone_hz=tone, feedback=0.95, pattern=12, + heads=8, time_ms=20.0) + out = pull(effect, 8192).reshape(-1) + self.assertTrue(np.array_equal( + out, np.array(data, dtype=np.int64)), + (rate, channels, tone)) + self.assertEqual(effect.tail_samples, 0) + + def test_mix_back_from_zero_starts_from_empty_lines(self): + effect = build(data=click(), mix=1.0, feedback=0.9, time_ms=20.0) + pull(effect, 2000) + effect.set_macro(MIX_I, 0) + pull(effect, 256) + effect.set_macro(MIX_I, 64) + out = pull(effect, 12000) + self.assertEqual(int(np.abs(out).max()), 0) + + def test_silence_stays_silence(self): + for tone in (4000.0, 0.0): + effect = build(data=silence(), tone_hz=tone, feedback=0.95) + self.assertEqual(int(np.abs(pull(effect, 48000)).max()), 0) + + def test_click_latency_is_zero(self): + effect = build(data=click(at=10), mix=1.0) + out = pull(effect, 7000) + self.assertEqual(nonzero(out), [10]) + self.assertEqual(int(out[10, 0]), 20000) + self.assertEqual(effect.latency_samples, 0) + + def test_tail_ends_inside_tail_samples(self): + for tone in (4000.0, 0.0): + for feedback in (0.45, 0.85): + probe = build(data=silence(), tone_hz=tone, + feedback=feedback, time_ms=20.0) + lap = probe._lap + burst = 4 * lap + tail = probe.tail_samples + probe.deinit() + noise = probes.noise_det(burst, dbfs=0.0) + effect = build(data=noise, mix=2.0, tone_hz=tone, + feedback=feedback, time_ms=20.0) + self.assertEqual(effect.tail_samples, tail) + out = pull(effect, burst + tail + 4 * lap) + last = nonzero(out)[-1] + self.assertLess(last - burst, tail, (tone, feedback)) + self.assertGreater(last - burst, 0) + + def test_tail_samples_is_finite_at_every_patch(self): + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE) + for index in sorted(MultiTapDelay.PATCHES): + effect.program_change(index) + self.assertIsInstance(effect.tail_samples, int) + self.assertGreater(effect.tail_samples, 0) + effect.program_change(0) + self.assertEqual(effect.tail_samples, 321435) + + def _after_reset(self, cls): + effect = build(cls, data=click(), mix=1.0, feedback=0.9, + time_ms=20.0) + pull(effect, 3000) + effect.reset() + return int(np.abs(pull(effect, 20000)).max()) + + def test_reset_empties_both_lines(self): + self.assertEqual(self._after_reset(MultiTapDelay), 0) + + def test_reset_plant_is_red(self): + self.assertGreater(self._after_reset(NoClear), 0) + + def test_reset_takes_nothing_from_the_source(self): + for start in ("patch1", "tone127", "mix0"): + for channels in (2, 1): + self.assertEqual(reset_pulls(MultiTapDelay, start, + channels), 0, + (start, channels)) + + def test_reset_priming_plant_is_red(self): + # The base's reset wires a never-wired class by priming the source. + # From a wired class a re-plug is a store and takes nothing, so the + # plant's other half is read by STATE below. + self.assertGreater(reset_pulls(PrimingReset, "mix0"), 0) + + def test_state_from_the_lean_graph(self): + for channels in (2, 1): + result = state_reading(MultiTapDelay, 1, channels) + self.assertEqual(result["values"]["reset_residual_lsb"], 0, + result["red"]) + self.assertTrue(result["values"]["resumed"]) + + def test_state_priming_plant_is_red(self): + result = state_reading(PrimingReset, 1) + self.assertGreater(result["values"]["reset_residual_lsb"], 0) + + def test_state_stale_block_plant_is_red(self): + for patch in (0, 1): + result = state_reading(StaleReset, patch) + self.assertGreater(result["values"]["reset_residual_lsb"], 0, + patch) + result = state_reading(MultiTapDelay, 0) + self.assertEqual(result["values"]["reset_residual_lsb"], 0) + + def test_deinit_leaves_the_source(self): + source = probes.ArraySource(probes.sine(440.0, 0.1, -6.0), + rate=RATE, channels=2) + effect = MultiTapDelay(source, sample_rate=RATE) + pull(effect, 512) + effect.deinit() + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + with self.assertRaises(RuntimeError): + effect.output + + +# -------------------------------------------------------------------------- +# T1 - taps on integer multiples of one base, exactly + + +class T1Grid(unittest.TestCase): + def test_first_lap_at_the_defaults_three_rates_both_graphs(self): + for rate in RATES: + for tone in (4000.0, 0.0): + result = first_lap_green(MultiTapDelay, rate=rate, tone=tone) + self.assertTrue(result["passed"], (rate, tone, result)) + result = first_lap_green(MultiTapDelay, channels=1) + self.assertTrue(result["passed"], result) + + def test_first_lap_over_modes_heads_and_time_stops(self): + for mode in (1, 7, 9, 12): + for heads in (3, 4, 8): + for time_midi in (0, 127): + result = first_lap_green( + MultiTapDelay, tone=0.0, + midi={PATTERN_I: law_pattern_midi(mode), + HEADS_I: law_heads_midi(heads), + TIME_I: time_midi}) + self.assertTrue(result["passed"], + (mode, heads, time_midi, result)) + + def test_laps_land_on_the_grid(self): + for rate in RATES: + lean = laps_reading(MultiTapDelay, rate=rate, tone=0.0) + self.assertTrue(lean["passed"], (rate, lean)) + full = laps_reading(MultiTapDelay, rate=rate) + self.assertTrue(full["passed"], (rate, full)) + self.assertEqual(full["arrivals"], 12) + # A cell where the plain P 1000 / fs lands under P in float32 at + # 44.1 kHz (K 3, Time MIDI 67, P 12 852): every onset on the grid. + full = laps_reading(MultiTapDelay, rate=44100, feedback=0.6, + midi={TIME_I: 67}) + self.assertTrue(full["passed"], full) + + def test_late_heads_are_red_at_the_defaults(self): + for rate in RATES: + result = first_lap_green(LateHeads, rate=rate) + self.assertFalse(result["passed"], rate) + frames, _values, expected = first_lap(LateHeads) + self.assertEqual(frames, [7201, 14401, 21600]) + self.assertEqual(expected, [7200, 14400, 21600]) + + def test_long_lap_is_red_on_the_lap_clause(self): + for rate in RATES: + result = laps_reading(LongLap, rate=rate) + self.assertFalse(result["passed"], rate) + # Nine of twelve onsets late, in each lane. + self.assertEqual(len(result["late"]), 18, rate) + + def test_the_t1_faults_are_not_on_the_surface(self): + result = reach(LateHeads, head_offset_error) + self.assertEqual(result["target"], (1, 1, 0)) + self.assertEqual(result["checked"], 9 * 17 + 7) + result = reach(LongLap, lap_node_error) + self.assertEqual(result["target"], 1) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: first_lap_green(cls), label="T1") + kit_faults.null_build_red( + MultiTapDelay, lambda cls: laps_reading(cls), label="T1 laps") + + +# -------------------------------------------------------------------------- +# T2 - one control moves the whole grid + + +def walk_reading(cls, heads, time_midis, rate=RATE): + bad = [] + for midi in time_midis: + frames, values, expected = first_lap( + cls, rate=rate, tone=0.0, pattern=12, + midi={HEADS_I: law_heads_midi(heads), TIME_I: midi}) + if frames != expected or values != [20000] * len(values): + bad.append(midi) + return {"passed": not bad, "bad": bad} + + +def span_reading(cls, heads, rate=RATE): + landed = [] + for midi in (0, 127): + frames, _values, _expected = first_lap( + cls, rate=rate, tone=0.0, pattern=12, + midi={HEADS_I: law_heads_midi(heads), TIME_I: midi}) + if not frames or frames[0] <= 0: + return {"passed": False, "span": 0.0} + landed.append(frames[0]) + span = landed[1] / float(landed[0]) + return {"passed": span >= 3.33, "span": span} + + +class T2OneControl(unittest.TestCase): + TIMES = tuple(range(0, 128, 8)) + (127,) + + def test_time_walk_keeps_every_head_on_the_grid(self): + for heads in (3, 8): + result = walk_reading(MultiTapDelay, heads, self.TIMES) + self.assertTrue(result["passed"], (heads, result)) + result = walk_reading(MultiTapDelay, 4, (0, 55, 127), rate=44100) + self.assertTrue(result["passed"], result) + + def test_span_is_at_least_the_re201s(self): + for heads, expected in ((3, 20.0), (8, 10.0)): + result = span_reading(MultiTapDelay, heads) + self.assertTrue(result["passed"], result) + self.assertAlmostEqual(result["span"], expected, places=6) + + def test_short_tap_lap_is_red_at_the_defaults(self): + for rate in RATES: + self.assertFalse(first_lap_green(ShortTapLap, rate=rate) + ["passed"], rate) + frames, _values, _expected = first_lap(ShortTapLap) + self.assertEqual(frames, [7200, 14399, 21599]) + + def test_narrow_time_span_is_red(self): + for heads in (3, 8): + result = span_reading(NarrowTime, heads) + self.assertFalse(result["passed"], result) + self.assertAlmostEqual(result["span"], 200.0 / 120.0, places=6) + # The constructor's 150 ms still lands on the same frame. + self.assertEqual(first_lap(NarrowTime)[0], [7200, 14400, 21600]) + + def test_the_t2_faults_are_not_on_the_surface(self): + result = reach(ShortTapLap, tap_lap_error) + self.assertEqual(result["target"], -1) + result = reach(NarrowTime, time_span_landed) + self.assertEqual(result["target"], (120.0, 200.0)) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: walk_reading(cls, 3, (0, 64, 127)), + label="T2 walk") + kit_faults.null_build_red( + MultiTapDelay, lambda cls: span_reading(cls, 3), label="T2 span") + + +# -------------------------------------------------------------------------- +# T3 - a subset selector over the grid + + +def table_reading(cls, heads, modes=range(1, 13)): + bad = [] + for mode in modes: + frames, _values, _expected = first_lap( + cls, tone=0.0, time_ms=20.0, + midi={PATTERN_I: law_pattern_midi(mode), + HEADS_I: law_heads_midi(heads)}) + n1 = law_frames(20.0, RATE) + sounded = tuple(f // n1 for f in frames if f > 0 and f % n1 == 0) + if sounded != law_heads(mode, heads) or len(sounded) != len(frames): + bad.append((mode, sounded)) + return {"passed": not bad, "bad": bad} + + +class T3Selector(unittest.TestCase): + def test_s1_table_at_four_heads(self): + result = table_reading(MultiTapDelay, 4) + self.assertTrue(result["passed"], result) + self.assertEqual(law_heads(10, 4), (1, 3, 4)) + + def test_other_heads(self): + result = table_reading(MultiTapDelay, 3, (8, 9, 10, 11, 12)) + self.assertTrue(result["passed"], result) + self.assertEqual([law_heads(m, 3) for m in (8, 9, 10, 11)], + [(1,), (3,), (1, 3), (1, 2)]) + result = table_reading(MultiTapDelay, 8, (4, 12)) + self.assertTrue(result["passed"], result) + + def test_rotated_table_is_red_at_the_defaults(self): + frames, _values, expected = first_lap(RotatedTable) + self.assertEqual(frames, [7200]) + self.assertEqual(expected, [7200, 14400, 21600]) + for rate in RATES: + self.assertFalse(first_lap_green(RotatedTable, rate=rate) + ["passed"], rate) + + def test_the_t3_fault_is_not_on_the_surface(self): + result = reach(RotatedTable, head_table) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: table_reading(cls, 4, (1, 7, 12)), + label="T3") + + +# -------------------------------------------------------------------------- +# T4 and T5 - one timbre per lap; darkening once per lap, and really there + + +def t4_green(result): + return all(lane["present"] and lane["t4"] <= 0.5 + for lane in result.get("lanes", [result])) + + +def t5_green(result, tone, rate): + top = 3620.0 if rate > 30000 else 2482.0 + for lane in result.get("lanes", [result]): + clause1 = lane["present"] and lane["t5"] <= 0.5 + clause2a = lane["c2"] >= 1.0 and \ + abs(lane["c4"] - 3 * lane["c2"]) <= 2.0 + clause2b = tone > top + 1.0 or lane["q4"] >= 15.0 + if not (clause1 and clause2a and clause2b): + return False + return True + + +def tone_of(midi): + return 800.0 * 20.0 ** (midi / 127.0) + + +def feedback_of(midi): + return 0.95 * midi / 127.0 + + +class T4T5Laps(unittest.TestCase): + def test_at_the_row_cells(self): + cells = [(RATE, fb, tone) for fb in (feedback_of(20), 0.45, 0.95) + for tone in (tone_of(0), tone_of(64), tone_of(126), 4000.0)] + cells += [(44100, 0.45, 4000.0), (22050, 0.45, 4000.0), + (22050, 0.95, tone_of(0))] + for rate, feedback, tone in cells: + result = t45_reading(MultiTapDelay, rate=rate, feedback=feedback, + tone=tone) + self.assertTrue(t4_green(result), (rate, feedback, tone, result)) + self.assertTrue(t5_green(result, tone, rate), + (rate, feedback, tone, result)) + self.assertLess(result["t4"], 0.001) + self.assertLess(result["t5"], 0.001) + self.assertEqual(result["lap1"], [24000.0] * 3) + + def test_other_mode_and_heads_pairs(self): + for heads, mode in ((4, 12), (4, 10), (8, 12)): + result = t45_reading(MultiTapDelay, heads=heads, mode=mode) + self.assertTrue(t4_green(result), (heads, mode, result)) + self.assertTrue(t5_green(result, 4000.0, RATE), + (heads, mode, result)) + + def test_constructor_defaults_numbers(self): + # A7.13 f: D2 3.00, D4 8.99 at the corner, D4 20.19 at 8 kHz. + result = t45_reading(MultiTapDelay) + self.assertAlmostEqual(result["c2"], 3.00, delta=0.05) + self.assertAlmostEqual(result["c4"], 8.99, delta=0.05) + self.assertAlmostEqual(result["q4"], 20.19, delta=0.05) + + def test_filtered_head_is_red_on_t4(self): + clean = t45_reading(MultiTapDelay) + result = t45_reading(FilteredHead) + self.assertTrue(result["present"], result) + self.assertGreater(result["t4"], 0.5, result) + self.assertTrue(t4_green(clean)) + + def test_front_filter_is_red_on_clause_2(self): + result = t45_reading(FrontFilter) + self.assertTrue(result["present"], result) + self.assertLess(abs(result["c2"]), 0.5, result) + self.assertLess(abs(result["q4"]), 0.5, result) + self.assertFalse(t5_green(result, 4000.0, RATE)) + + def test_emulated_per_head_darkening_is_red_on_clause_1(self): + # Emulated on the rendered windows, as the dossier states it. + result = t45_reading(MultiTapDelay, emulate=darken_head2_per_lap) + self.assertGreater(result["t5"], 0.5, result) + self.assertFalse(t5_green(result, 4000.0, RATE)) + + def test_the_t4_t5_faults_are_not_on_the_surface(self): + for faulted, flag in ((FilteredHead, "_plant_filtered_head"), + (FrontFilter, "_plant_front_filter")): + result = reach(faulted, + lambda e, flag=flag: getattr(e, flag, False)) + self.assertIs(result["target"], True) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: t4_green(t45_reading(cls)), + label="T4") + kit_faults.null_build_red( + MultiTapDelay, + lambda cls: t5_green(t45_reading(cls), 4000.0, RATE), + label="T5") + + +# -------------------------------------------------------------------------- +# Gate audit round 1: the routes the pack did not take, and both lanes + + +def reach_flag(faulted, name): + return reach(faulted, flag(name)) + + +class RoundOneRoutes(unittest.TestCase): + """Items 1-4: a setting made on the wired instance before the first + pull, a reset from the lean graph, a host's reset of the output, and + several Repeat Tone crossings in one gap. Each is red on its plant, + which reproduces the code of c80ca59.""" + + def test_setting_before_the_first_pull_keeps_both_lanes_on_the_law(self): + # Red on c80ca59: left [3 118, 9 241, 12 359, 18 482], right none. + result = ctor_route(MultiTapDelay, (TIME_I, 96)) + self.assertEqual(result["law"], [9241, 18482, 27723]) + self.assertTrue(result["passed"], result) + for rate in RATES: + for move, pattern in (((TIME_I, 0), None), ((TIME_I, 127), None), + ((HEADS_I, 127), 12)): + result = ctor_route(MultiTapDelay, move, rate=rate, + pattern=pattern) + self.assertTrue(result["passed"], (rate, move, result)) + self.assertTrue(ctor_route(MultiTapDelay, (TIME_I, 96), + channels=1)["passed"]) + + def test_program_change_before_the_first_pull(self): + for rate in RATES: + for patch in (0, 2, 4): + result = patch_route(MultiTapDelay, patch, rate=rate) + self.assertTrue(result["passed"], (rate, patch, result)) + + def test_primed_wire_plant_is_red_on_the_route_only(self): + result = ctor_route(PrimedWire, (TIME_I, 96)) + self.assertFalse(result["passed"], result) + self.assertEqual(result["hits"][0][:4], [3118, 9241, 12359, 18482]) + self.assertEqual(result["hits"][1], []) + self.assertFalse(patch_route(PrimedWire, 0)["passed"]) + # The control: from frame 256 the plant is on the law, as c80ca59 + # was, and so is the pack's build. + self.assertTrue(ctor_route(PrimedWire, (TIME_I, 96), + at=256)["passed"]) + self.assertTrue(ctor_route(MultiTapDelay, (TIME_I, 96), + at=256)["passed"]) + self.assertTrue(first_lap_green(PrimedWire)["passed"]) + result = reach_flag(PrimedWire, "_plant_primed_wire") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_reset_leaves_the_output_on_time(self): + # Red on c80ca59 from patch 1, Tone at its out stop and a class + # never wired: the dry at +256. + for start in ("patch1", "tone127", "mix0", "full"): + for rate, channels in ((RATE, 2), (RATE, 1), (44100, 2), + (22050, 2)): + result = reset_route(MultiTapDelay, start, rate, channels) + self.assertTrue(result["passed"], + (start, rate, channels, result)) + + def test_late_reset_plant_is_red(self): + result = reset_route(LateReset, "patch1") + self.assertFalse(result["passed"], result) + self.assertEqual(result["dry"], 256) + result = reach_flag(LateReset, "_plant_late_reset") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_a_host_reset_keeps_the_first_block(self): + # Red on c80ca59: the 3 000 click at 2 744 on all three + # interpreters (multitapdelay_stationC_hostreset.py). + for rate in RATES: + for channels in (2, 1): + for patch in (None, 1): + result = host_reset_route(MultiTapDelay, patch, rate, + channels) + self.assertTrue(result["passed"], + (rate, channels, patch, result)) + result = host_reset_lines_route(MultiTapDelay, rate, + channels) + self.assertTrue(result["passed"], (rate, channels, result)) + + def test_mixer_tail_plant_is_red(self): + # Since re-audit fix round 1 every wiring is quiet, so no voice + # holds the source's first block at construction and a host reset + # that reaches the Mixer drops nothing there (c80ca59 put the 3 000 + # click at 2 744). Mid-stream it still empties both lines: the + # click's heads are gone where the tail leaves them sounding. + self.assertTrue(host_reset_route(MixerTail)["passed"]) + for channels in (2, 1): + result = host_reset_lines_route(MixerTail, channels=channels) + self.assertFalse(result["passed"], result) + self.assertEqual(result["hits"], [[7000]] * channels) + result = reach_flag(MixerTail, "_plant_mixer_tail") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_tone_crossings_in_one_gap(self): + # Red on c80ca59 at two crossings: heads at +4 088 for +4 344. + for rate in RATES: + for crossings in (1, 2, 3, 4): + result = crossing_route(MultiTapDelay, crossings, rate) + self.assertTrue(result["passed"], (rate, crossings, result)) + self.assertTrue(crossing_route(MultiTapDelay, 2, + channels=1)["passed"]) + + def test_priming_plug_plant_is_red(self): + self.assertTrue(crossing_route(PrimingPlug, 1)["passed"]) + result = crossing_route(PrimingPlug, 2) + self.assertFalse(result["passed"], result) + self.assertEqual(result["hits"][0], [0, 4088, 8432, 12776]) + result = reach_flag(PrimingPlug, "_plant_priming_plug") + self.assertEqual(result["checked"], 9 * 17 + 7) + + +class RoundOneLanes(unittest.TestCase): + """Item 5: the lap and spectral readings read every lane, with a + lane-1 plant and channel-different material.""" + + def test_panned_laps_is_red_on_every_lane_reading(self): + for rate in RATES: + result = laps_reading(PannedLaps, rate=rate) + self.assertFalse(result["passed"], (rate, result)) + self.assertEqual(len(result["late"]), 9, rate) + self.assertFalse(t4_green(t45_reading(PannedLaps, rate=rate))) + self.assertTrue(laps_reading(PannedLaps, rate=rate, + channels=1)["passed"]) + result = reach_flag(PannedLaps, "_plant_panned_laps") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_left_only_click(self): + for rate in RATES: + result = left_only_reading(MultiTapDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + result = left_only_reading(CrossedLaps) + self.assertFalse(result["passed"], result) + self.assertGreater(result["right"], 0) + result = reach_flag(CrossedLaps, "_plant_crossed_laps") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: left_only_reading(cls), + label="left-only") + + +class RoundOneSurfaceLaws(unittest.TestCase): + """Item 6: section 6's Feedback law and Repeat Tone's corner, each + with a plant.""" + + def test_lap_levels_follow_feedback(self): + for rate in RATES: + for tone in (4000.0, 0.0): + result = level_reading(MultiTapDelay, rate, tone) + self.assertTrue(result["passed"], (rate, tone, result)) + + def test_lap_mix_one_is_red(self): + for rate in RATES: + result = level_reading(LapMixOne, rate) + self.assertFalse(result["passed"], (rate, result)) + self.assertGreater(result["ratios"][0][1], 0.99) + result = reach(LapMixOne, lap_mix_error) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_repeat_tone_corner_is_3_db(self): + for rate in RATES: + for midi in (0, 64, 126): + result = corner_reading(MultiTapDelay, rate, tone_of(midi)) + self.assertTrue(result["passed"], (rate, midi, result)) + self.assertTrue(corner_reading(MultiTapDelay, rate)["passed"]) + + def test_tone_half_is_red(self): + for rate in RATES: + result = corner_reading(ToneHalf, rate) + self.assertFalse(result["passed"], (rate, result)) + self.assertGreater(min(result["c2"]), 6.0) + result = reach(ToneHalf, damping_error) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: level_reading(cls), label="levels") + kit_faults.null_build_red( + MultiTapDelay, lambda cls: corner_reading(cls), label="corner") + + +class RoundTwo(unittest.TestCase): + """Gate audit round 2: a second `_resync` between two pulls, the rate + floor, and T5 clause 1's plant built on the fix round 1 graph.""" + + ROUTES = (("reset", "reset"), ("reset", "mix0"), ("mix0", "mix0"), + ("reset", "reset", "reset")) + + def test_a_second_resync_in_one_gap_keeps_the_heads_on_time(self): + # Red on d419ac4 (PullingResync below): click A's heads missing and + # B's 256 frames early at two, 512 at three. + for steps in self.ROUTES: + for rate, channels in ((RATE, 2), (44100, 2), (22050, 2), + (RATE, 1)): + result = resync_route(MultiTapDelay, steps, rate=rate, + channels=channels) + self.assertTrue(result["passed"], + (steps, rate, channels, result)) + result = resync_route(MultiTapDelay, steps, start="lean") + self.assertTrue(result["passed"], (steps, "lean", result)) + + def test_pulling_resync_plant_is_red(self): + # The control: one reset, and one return from Mix 0, are green on + # the plant too; that is all fix round 1 tested. + for steps in (("reset",), ("mix0",)): + self.assertTrue(resync_route(PullingResync, steps)["passed"], + steps) + self.assertTrue(resync_route(MultiTapDelay, steps)["passed"], + steps) + for steps in self.ROUTES: + for rate, channels in ((RATE, 2), (44100, 2), (22050, 2), + (RATE, 1)): + result = resync_route(PullingResync, steps, rate=rate, + channels=channels) + self.assertFalse(result["passed"], + (steps, rate, channels, result)) + result = resync_route(PullingResync, ("reset", "reset")) + # A (7 690) sounds dry only; B (8 036) and its heads 256 early. + # reset() restores patch 0: Time MIDI 85, n1 7 129 at 48 kHz. + n1 = law_landed(law_time_ms(85), 3, RATE)[0] + self.assertEqual(n1, 7129) + self.assertEqual(result["hits"][0], + [7690, 8036] + [8036 + k * n1 - 256 + for k in (1, 2, 3)]) + result = reach_flag(PullingResync, "_plant_pulling_resync") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_rates_below_the_floor_are_refused(self): + for rate in (8000, 11025, 12000, 12800, 12824): + for channels in (2, 1): + for options in ({}, {"time_ms": 20.0}): + with self.assertRaises(ValueError) as caught: + MultiTapDelay(Endless(silence(), rate, channels), + sample_rate=rate, **options) + self.assertIn("12825 Hz", str(caught.exception)) + with self.assertRaises(ValueError): + MultiTapDelay.create(Endless(silence(), rate, 2), rate) + + def test_time_20_ms_lands_on_the_grid_at_the_floor(self): + for rate in (12825, 16000): + for channels in (2, 1): + for how in ("ctor", "macro"): + result = low_rate_first_lap(MultiTapDelay, rate, how, + channels) + self.assertTrue(result["passed"], + (rate, channels, how, result)) + self.assertEqual(low_rate_first_lap(MultiTapDelay, 12825, "ctor") + ["law"], [257, 514, 771]) + + def test_no_rate_floor_plant_is_red(self): + # d419ac4 at 12 800 Hz: heads at [512, 768, 1 024] for + # [256, 512, 768]; at 12 000 Hz Time's low end raises from the node. + for how in ("ctor", "macro"): + result = low_rate_first_lap(NoRateFloor, 12800, how) + self.assertFalse(result["passed"], (how, result)) + self.assertEqual(result["hits"], [[512, 768, 1024]] * 2) + self.assertEqual(result["law"], [256, 512, 768]) + with self.assertRaises(ValueError) as caught: + low_rate_first_lap(NoRateFloor, 12000, how) + self.assertNotIn("12825 Hz", str(caught.exception)) + self.assertTrue(low_rate_first_lap(NoRateFloor, 16000, + "ctor")["passed"]) + + def test_head2_own_loop_is_red_on_clause_1(self): + # Built, not emulated: lap 1 is the same for every head (the lap + # nodes' dry pass), and head 2 darkens on its own after it. + for rate in RATES: + result = t45_reading(Head2OwnLoop, rate=rate) + for lane in result["lanes"]: + self.assertTrue(lane["present"], (rate, lane)) + self.assertEqual(lane["lap1"], [24000.0] * 3, rate) + self.assertGreater(lane["t5"], 0.5, (rate, lane)) + self.assertFalse(t5_green(result, 4000.0, RATE), rate) + self.assertTrue(t5_green(t45_reading(MultiTapDelay, rate=rate), + 4000.0, rate), rate) + result = reach_flag(Head2OwnLoop, "_plant_head2_loop") + self.assertIs(result["target"], True) + self.assertEqual(result["checked"], 9 * 17 + 7) + + +# -------------------------------------------------------------------------- +# Re-audit fix round 1: the pin at v0.6.3rc1, and the first block, the +# source's own buffers and Mix 0 (audit round 3's items 1-4) + + +class SteppedLaps(MultiTapDelay): + """The cure retired at audiodsp v0.6.3rc1: with Repeat Tone in, the lap + node handed the nearer edge of the stall window its Feedback sits in + (`clear_of_stalls`), a Feedback nobody set (0.95 played as 0.950016).""" + + NAME = 'MultiTapDelay' + + def _refresh(self): + MultiTapDelay._refresh(self) + if self._lean: + return + excess = mtd.tone_excess(self._damping, self._sample_rate)[1] + stepped = clear_of_stalls(self._feedback, excess) + if stepped != self._feedback: + self._feedback = stepped + self._lap_mix = stepped + self._fd.set(feedback=stepped, mix=stepped) + + +class BareAdapter(MultiTapDelay): + """Mix 0 on the input adapter itself, without `_through`: a host's + reset of the output reaches the adapter and drops what it holds.""" + + NAME = 'MultiTapDelay' + + def _bypass(self): + return self._adapter + + +#: One numpy-free script, run under CPython, MicroPython and CircuitPython: +#: the class after a route of calls made before the first pull, and after +#: an event between two pulls with sources whose buffers are not 256 frames, +#: every lane against the source and the dossier's head law. Three plants +#: put back ac2181f's code for the three causes. Each output line is +#: `PART|plant|route|rate|channels|samples wrong`. +ROUTES_MODULE = """from array import array + +import audiocore # noqa: E402 +from audioeffects import rebuilt # noqa: E402 + +VENDOR = "PyDevices" + +M = rebuilt.module_class("MultiTapDelay") +BLOCK = 256 +FB_I = 3 +MIX_I = 4 +RATES = (48000, 44100, 22050) + + +class OldWiring(M): + '''ac2181f's wiring: quiet only inside reset(), so a class wired at + construction or by a Mix move holds the source's first block in the dry + voice and on tap 1 until the first pull.''' + + NAME = 'MultiTapDelay' + + def _quiet_wiring(self): + return self._resetting + + +class AdapterReset(M): + '''ac2181f's reset: the input adapter owned with a reset, which drops + what it holds of a source buffer.''' + + NAME = 'MultiTapDelay' + + def _build(self, *args, **kwargs): + M._build(self, *args, **kwargs) + self._resets[self._nodes.index(self._adapter)] = True + + +class SourceBypass(M): + '''ac2181f's Mix 0: the output port on the borrowed source itself, past + whatever the input adapter holds.''' + + NAME = 'MultiTapDelay' + + def _bypass(self): + return self._source + + +PLANTS = {"clean": M, "oldwiring": OldWiring, "adapterreset": AdapterReset, + "sourcebypass": SourceBypass} + + +def noise(channels, total): + values = array("h", bytes(2 * total * channels)) + state = 987654 + for i in range(total): + for c in range(channels): + state = (state * 1103515245 + 12345) & 0x7FFFFFFF + tri = (i * (37 + 29 * c)) % 16001 - 8000 + values[i * channels + c] = tri + (state >> 8) % 8001 - 4000 + return values + + +def clicks(channels, total, where): + values = array("h", bytes(2 * total * channels)) + for s, v in where: + for c in range(channels): + values[s * channels + c] = v + return values + + +def source(values, rate, channels, frames): + raw = audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + if frames is None: + return raw + import audiofilters + block = audiofilters.Filter(filter=None, mix=1.0, + buffer_size=frames * channels * 2, + sample_rate=rate, channel_count=channels) + block.play(raw) + return block + + +def samples(data): + out = array("h") + try: + out.extend(memoryview(data).cast("h")) + except (AttributeError, TypeError): + import struct + out.extend(struct.unpack("<%dh" % (len(data) // 2), data)) + return out + + +def pull_frames(e, frames): + ch = e.channel_count + out = array("h") + while len(out) < frames * ch: + out.extend(samples(bytes(audiocore.get_buffer(e.output)[1]))) + return out + + +#: Routes between construction and the first pull. "mix0 up" is a class +#: built at Mix 0 and turned up, which wires it. +ROUTES = ("none", "program_change(1)", "reset", "reset x2", + "Mix 0 and back", "reset, Mix 0 and back", "mix0 up, reset") + + +def build(cls, src, rate, name): + if name.startswith("mix0 up"): + e = cls(src, sample_rate=rate, mix=0.0) + e.set_macro(MIX_I, 22) + else: + e = cls(src, sample_rate=rate) + for step in name.split(", "): + if step in ("none", "mix0 up"): + continue + if step == "reset": + e.reset() + elif step == "reset x2": + e.reset() + e.reset() + elif step == "Mix 0 and back": + e.set_macro(MIX_I, 0) + e.set_macro(MIX_I, 22) + elif step == "program_change(1)": + e.program_change(1) + else: + raise ValueError(step) + return e + + +def law(where, n1, heads, unfed_before=0): + want = {} + for s, v in where: + want[s] = v + if s < unfed_before: + continue + for k in heads: + want[s + k * n1] = want.get(s + k * n1, 0) + v + return want + + +def judge(out, channels, frames, want): + '''Samples wrong, over every lane: missing or wrong law frames plus + non-zero frames the law does not name.''' + wrong = 0 + for c in range(channels): + got = {} + for f in range(frames): + v = out[f * channels + c] + if v: + got[f] = v + wrong += len([s for s in want if s < frames and got.get(s) != want[s]]) + wrong += len([s for s in got if s not in want]) + return wrong + + +def part_firstdry(cls, label): + for rate in RATES: + for channels in (2, 1): + values = noise(channels, 8 * BLOCK) + for name in ROUTES: + e = build(cls, source(values, rate, channels, BLOCK), rate, + name) + window = min(e._n1, 2 * BLOCK) + out = pull_frames(e, window) + e.deinit() + diff = len([i for i in range(window * channels) + if out[i] != values[i]]) + print("FIRSTDRY|%s|%s|%d|%d|%d" % (label, name, rate, + channels, diff)) + + +def part_firstheads(cls, label): + where = ((10, 20000), (300, 10000), (700, 5000)) + for rate in RATES: + for channels in (2, 1): + for name in ROUTES: + total = 700 + 3 * (rate * 150 // 1000 + 1) + 4 * BLOCK + values = clicks(channels, total, where) + e = build(cls, source(values, rate, channels, BLOCK), rate, + name) + e.set_macro(FB_I, 0) + e.set_macro(MIX_I, 63.5) + n1 = e._n1 + frames = 700 + 3 * n1 + 2 * BLOCK + out = pull_frames(e, frames) + e.deinit() + wrong = judge(out, channels, frames, + law(where, n1, (1, 2, 3))) + print("FIRSTHEADS|%s|%s|%d|%d|%d" % (label, name, rate, + channels, wrong)) + + +def part_blocks(cls, label): + where = ((8400, 20000), (8900, 10000), (10100, 5000)) + for rate, channels in ((48000, 2), (48000, 1), (22050, 1)): + for frames_in in (256, 512, 100, None): + for pulls in (31, 32): + for event in ("none", "reset", "Mix 0 and back", + "Mix-0 run"): + total = 10100 + 3 * (rate * 150 // 1000 + 1) + 6 * BLOCK + values = clicks(channels, total, where) + e = cls(source(values, rate, channels, frames_in), + sample_rate=rate) + out = pull_frames(e, pulls * BLOCK) + if event == "reset": + e.reset() + elif event == "Mix 0 and back": + e.set_macro(MIX_I, 0) + e.set_macro(MIX_I, 22) + elif event == "Mix-0 run": + e.set_macro(MIX_I, 0) + out.extend(pull_frames(e, 4 * BLOCK)) + e.set_macro(FB_I, 0) + e.set_macro(MIX_I, 63.5) + n1 = e._n1 + span = 10100 + 3 * n1 + BLOCK - len(out) // channels + out.extend(pull_frames(e, span)) + e.deinit() + frames = len(out) // channels + unfed = (pulls + 4) * BLOCK if event == "Mix-0 run" else 0 + wrong = judge(out, channels, frames, + law(where, n1, (1, 2, 3), unfed)) + print("BLOCKS|%s|%s %d %s|%d|%d|%d" + % (label, frames_in or "raw", pulls, event, rate, + channels, wrong)) + + +class QuietAlways(M): + '''A wiring that primes zeros on every build, get_buffer or not: where + get_buffer is left out nothing hands the primed zeros out, so the + output opens with a silent block. It moves the first block, which is + what the no-get_buffer words describe.''' + + NAME = 'MultiTapDelay' + + def _quiet_wiring(self): + return True + + +PLANTS["quietalways"] = QuietAlways + + +class NoGetBuffer: + '''audiocore without get_buffer, every other name passed through: what + a patched CircuitPython board build that leaves it out gives the class. + Installed in the two modules that look it up; the pulls below keep the + real one.''' + + def __getattr__(self, name): + if name == "get_buffer": + raise AttributeError(name) + return getattr(audiocore, name) + + +def use_audiocore(module): + from audioeffects import _component + top = __import__("audioeffects.rebuilt.multitapdelay") + _component.audiocore = module + top.rebuilt.multitapdelay.audiocore = module + + +#: Clicks for the no-get_buffer routes: two in the first block, one in +#: block 23 (the block before an event at block 24), two after block 24. +NG_CLICKS = ((10, 20000), (700, 9000), (6000, 8000), (6500, 7000), + (9000, 5000)) +NG_AT = 24 * BLOCK +#: route -> (constructor Mix, {block: [actions]}). +NG_ROUTES = ( + ("constructed", 1.0, {}), + ("Mix 0 first, back at 24", 1.0, {0: ["mix0"], 24: ["back"]}), + ("Mix 0 first, reset at 24", 1.0, {0: ["mix0"], 24: ["reset"]}), + ("Mix 0 and back first", 1.0, {0: ["mix0", "back"]}), + ("built at Mix 0, reset first", 0.0, {0: ["reset"]}), + ("reset at 24", 1.0, {24: ["reset"]}), + ("Mix 0 at 24, back at 48", 1.0, {24: ["mix0"], 48: ["back"]}), +) + + +def ng_act(e, action): + if action == "mix0": + e.set_macro(MIX_I, 0) + elif action == "back": + e.set_macro(MIX_I, 63.5) + else: + e.reset() + e.set_macro(FB_I, 0) + e.set_macro(MIX_I, 63.5) + + +def ng_law(route, pulls, n1a, n1b, frames): + '''What the docstring says each route plays, every lane: `pulls` False + is a build without get_buffer. n1a is the base as built, n1b after the + route's events (a reset restores patch 0's Time).''' + want = {} + + def put(f, v): + if f < frames: + want[f] = want.get(f, 0) + v + + def heads(o, v, n1, stop=frames): + for k in (1, 2, 3): + if o + k * n1 < stop: + put(o + k * n1, v) + + late = not pulls + for s, v in NG_CLICKS: + block = s // BLOCK + if route in ("constructed", "Mix 0 and back first"): + put(s, v) + heads(s, v, n1a) + elif route.startswith("Mix 0 first"): + if not late: + put(s, v) + if s >= NG_AT: + heads(s, v, n1b) + elif block == 0: + # the first block, held in the dry, plays at the return + put(s + NG_AT, v) + heads(s + NG_AT, v, n1b) + elif block <= 24: + # the Mix-0 run: one block early, no heads + put(s - BLOCK, v) + else: + put(s, v) + heads(s, v, n1b) + elif route == "built at Mix 0, reset first": + o = s + BLOCK if late else s + put(o, v) + heads(o, v, n1b) + elif route == "reset at 24": + put(s, v) + if s >= NG_AT or (late and block == 23): + heads(s, v, n1b) + else: + heads(s, v, n1a, NG_AT) + else: # Mix 0 at 24, back at 48 + put(s, v) + if s < NG_AT: + heads(s, v, n1a, NG_AT) + if late and block == 23: + heads(s + NG_AT, v, n1a) + elif s >= 2 * NG_AT: + heads(s, v, n1a) + return want + + +def ng_run(cls, route, mix, plan, rate, channels, pulls): + n1 = (rate * 150 + 500) // 1000 + frames = 2 * NG_AT + 3 * n1 + 2 * BLOCK + values = clicks(channels, frames + 16 * BLOCK, NG_CLICKS) + use_audiocore(audiocore if pulls else NoGetBuffer()) + try: + e = cls(source(values, rate, channels, BLOCK), sample_rate=rate, + feedback=0.0, mix=mix) + n1a = e._n1 + out = array("h") + block = 0 + while len(out) < frames * channels: + for action in plan.get(block, ()): + ng_act(e, action) + out.extend(samples(bytes(audiocore.get_buffer(e.output)[1]))) + block += 1 + n1b = e._n1 + e.deinit() + finally: + use_audiocore(audiocore) + return judge(out, channels, frames, + ng_law(route, pulls, n1a, n1b, frames)) + + +def part_ng(cls, label, pulls): + for route, mix, plan in NG_ROUTES: + for rate, channels in ((48000, 2), (48000, 1), (22050, 1)): + wrong = ng_run(cls, route, mix, plan, rate, channels, pulls) + print("%s|%s|%s|%d|%d|%d" % ("GB" if pulls else "NOGB", label, + route, rate, channels, wrong)) + + +def main(args): + '''Each argument is `plant,plant=part,part`.''' + parts = {"firstdry": part_firstdry, "firstheads": part_firstheads, + "blocks": part_blocks, + "gb": lambda cls, label: part_ng(cls, label, True), + "nogb": lambda cls, label: part_ng(cls, label, False)} + for group in args: + plants, names = group.split("=") + for plant in plants.split(","): + for part in names.split(","): + parts[part](PLANTS[plant], plant) + print("DONE") +""" + +ROUTES_RUNNER = """import sys +sys.path.insert(0, "lib") +sys.path.insert(0, sys.argv[1]) +import mtd_routes +mtd_routes.main(sys.argv[2:]) +""" + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +#: The routes before the first pull that `_resync` or a wiring takes, and +#: the two that take neither (the controls). +RESYNC_ROUTES = ("reset", "reset x2", "Mix 0 and back", + "reset, Mix 0 and back", "mix0 up, reset") +ROUTE_CONTROLS = ("none", "program_change(1)") +ROUTE_RATES = (48000, 44100, 22050) +BLOCK_CASES = ((48000, 2), (48000, 1), (22050, 1)) +#: Where a source's buffers leave the adapter holding frames at the event: +#: 512 after 31 blocks (256 held), 100 and one RawSample after 31 or 32. +HELD = (("512", 31), ("100", 31), ("100", 32), ("raw", 31), ("raw", 32)) + + +def run_routes(binary, groups): + """{(part, plant, route, rate, channels): samples wrong} from one run + of `ROUTES_MODULE` under `binary`.""" + with tempfile.TemporaryDirectory() as directory: + with open(os.path.join(directory, "mtd_routes.py"), "w") as handle: + handle.write(ROUTES_MODULE) + runner = os.path.join(directory, "run.py") + with open(runner, "w") as handle: + handle.write(ROUTES_RUNNER) + env = dict(os.environ, MICROPYPATH="lib", GCOV_PREFIX=directory, + PYTHONDONTWRITEBYTECODE="1") + argv = [binary] + ([] if binary == sys.executable + else ["-X", "heapsize=256M"]) + done = subprocess.run(argv + [runner, directory] + list(groups), + capture_output=True, text=True, cwd=ROOT, + env=env) + lines = done.stdout.splitlines() + if done.returncode != 0 or not lines or lines[-1] != "DONE": + raise AssertionError("%s: %s" % (binary, done.stderr[-2000:])) + out = {} + for line in lines[:-1]: + part, plant, name, rate, channels, wrong = line.split("|") + out[(part, plant, name, int(rate), int(channels))] = int(wrong) + return out + + +def red_cells(results, part, plant): + return sorted(key[2:] for key, wrong in results.items() + if key[0] == part and key[1] == plant and wrong) + + +def current_native_interpreters(): + """{"micropython": path, "circuitpython": path} from the workspace's + `bin/`, each the first whose provenance stamp contains `AUDIODSP_PIN`'s + commit (None where no binary does), or None where there is no + workspace `bin/` with its provenance tool above this checkout.""" + from tools import provenance_gate + here = ROOT + workspace = None + for _up in range(5): + here = os.path.dirname(here) + if os.path.isdir(os.path.join(here, "bin")) and os.path.isfile( + os.path.join(here, "tools", "provenance.py")): + workspace = here + break + if workspace is None: + return None + bindir = os.path.join(workspace, "bin") + tool = os.path.join(workspace, "tools", "provenance.py") + pin = provenance_gate.audiodsp_pin() + found = {} + for family in ("micropython", "circuitpython"): + found[family] = None + names = sorted(name for name in os.listdir(bindir) + if name.startswith(family) and "." not in name) + for name in names: + path = os.path.join(bindir, name) + done = subprocess.run( + [sys.executable, tool, "check", path, "--source", "audiodsp", + "--contains", "audiodsp=%s" % pin], capture_output=True) + if done.returncode == 0: + found[family] = path + break + return found + + +def routes_module(): + """`ROUTES_MODULE` imported in this process, for its plants.""" + module = sys.modules.get("mtd_routes") + if module is None: + module = types.ModuleType("mtd_routes") + sys.modules["mtd_routes"] = module + exec(ROUTES_MODULE, module.__dict__) + return module + + +def stall_cell(cls, rate=RATE, channels=2): + """Feedback 0.5 by the constructor (Repeat Tone 800 Hz's k = 1 window), + Time 20 ms, Mix 2, a 2 LSB DC for 1 s, then silence: (the Feedback the + lap node is handed, the knob's own Feedback, tail_samples, frames from + the input's end to the last non-zero sample, whether the last frame + rendered is non-zero).""" + frames = rate + data = array("h", [2] * (frames * channels)) + effect = cls(Endless(data, rate, channels), sample_rate=rate, + feedback=0.5, tone_hz=800.0, time_ms=20.0, mix=2.0) + declared = effect.tail_samples + total = frames + declared + 8 * effect._lap + out = pull(effect, total) + handed = effect._feedback + knob = effect.macro(FEEDBACK_I) + effect.deinit() + nz = np.nonzero(np.any(out != 0, axis=1))[0] + last = int(nz[-1]) if len(nz) else -1 + return handed, knob, declared, last - frames + 1, last == total - 1 + + +class ReauditRoundOne(unittest.TestCase): + """Re-audit fix round 1 (2026-09-28): the class at audiodsp v0.6.3rc1, + and the gate audit's round 3 items 1-4.""" + + def test_the_feedback_is_handed_as_set(self): + # Up to audiodsp v0.6.2 the lap node's loop low-pass could hold a + # small value for ever a hair either side of 1 - 0.5 / k, and the + # class handed it the nearer edge of the window instead: only + # Feedback 127 (0.95, k = 10) ever moved, at every Repeat Tone + # position and rate, to 0.950016. Since v0.6.3rc1 (#157) every + # position is handed as set and the bound counts the landing's lap: + # 167 laps at 0.95, Repeat Tone at the default (166 stepped). + for rate in RATES: + effect = MultiTapDelay(Endless(silence(), rate), sample_rate=rate) + for midi in range(128): + effect.set_macro(FEEDBACK_I, midi) + want = min(0.95, effect.macro(FEEDBACK_I)) + self.assertEqual(effect._feedback, want, (rate, midi)) + self.assertEqual(effect._lap_mix, want, (rate, midi)) + self.assertIsInstance(effect.tail_samples, int) + excess = mtd.tone_excess(effect._damping, rate)[1] + self.assertEqual(effect._feedback, 0.95) + self.assertEqual(laps_to_zero(0.95, excess), 167) + # Planted: the retired stepping hands a Feedback nobody set. + stepped = SteppedLaps(Endless(silence(), rate), sample_rate=rate) + stepped.set_macro(FEEDBACK_I, 127) + self.assertNotEqual(stepped._feedback, 0.95, rate) + self.assertLess(abs(stepped._feedback - 0.95), 3e-5) + self.assertEqual(laps_to_zero(stepped._feedback, excess), 166) + memory = mtd.tone_excess(effect._damping, rate)[0] + self.assertEqual(effect.tail_samples - stepped.tail_samples, + effect._lap + 1 + memory) + + def test_a_stall_window_cell_reaches_zero_at_the_feedback_set(self): + # Feedback 0.5 with Repeat Tone at 800 Hz is inside a stall window + # (the retired stepping moves it), and up to v0.6.2 the node held + # this cell for ever there. Since v0.6.3rc1 it is handed as set and + # the tail ends inside the bound. At v0.6.2 the same cell holds + # past it (the pack's re-audit fix round 1 section, on + # bin/micropython-062 and bin/circuitpython-062). SteppedLaps below + # is red on the handed Feedback only: this cell's tail is far + # inside the bound, and the tail reading's plant (NoTapLap) is in + # ReauditRoundTwo, at a cell where the tail comes within a lap. + for rate, channels in ((RATE, 2), (RATE, 1), (44100, 2), + (22050, 2)): + handed, knob, declared, tail, held = stall_cell( + MultiTapDelay, rate, channels) + # The knob's 0.5 (0.49999999999999994 through the position) is + # inside the window: the retired stepping moves it. + self.assertLess(abs(knob - 0.5), 1e-15) + self.assertNotEqual(clear_of_stalls(knob, mtd.tone_excess( + nominal_damping_hz(800.0, rate), rate)[1]), knob) + self.assertEqual(handed, knob, (rate, channels)) + self.assertFalse(held, (rate, channels)) + self.assertGreater(tail, 0, (rate, channels)) + self.assertLessEqual(tail, declared, (rate, channels)) + stepped = SteppedLaps(Endless(silence()), sample_rate=RATE, + feedback=0.5, tone_hz=800.0) + self.assertNotEqual(stepped._feedback, stepped.macro(FEEDBACK_I)) + self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + + def _judge_routes(self, results, native): + """Items 1 and 2 on one interpreter's run.""" + for part in ("FIRSTDRY", "FIRSTHEADS"): + self.assertEqual(red_cells(results, part, "clean"), [], part) + cells = [key for key in results + if key[0] == part and key[1] == "clean"] + self.assertEqual(len(cells), 7 * 3 * 2, part) + # The plant: ac2181f's wiring. Every route that resyncs or wires + # before the first pull loses the first block's heads, in every + # lane; the controls stay green. + self.assertEqual( + red_cells(results, "FIRSTHEADS", "oldwiring"), + sorted((name, rate, channels) for name in RESYNC_ROUTES + for rate in ROUTE_RATES for channels in (2, 1))) + # And on a native interpreter, in mono, the dry's first block: all + # 256 samples zero. The CPython shim copies the primed block, so + # there the dry is kept (which is why this runs natively). + dry = red_cells(results, "FIRSTDRY", "oldwiring") + if native: + self.assertEqual(dry, sorted( + (name, rate, 1) for name in RESYNC_ROUTES + for rate in ROUTE_RATES)) + for name, rate, channels in dry: + self.assertEqual(results[("FIRSTDRY", "oldwiring", name, + rate, channels)], 256) + else: + self.assertEqual(dry, []) + + def _judge_blocks(self, results): + """Items 3 and 4 on one interpreter's run.""" + self.assertEqual(red_cells(results, "BLOCKS", "clean"), []) + cells = [key for key in results + if key[0] == "BLOCKS" and key[1] == "clean"] + self.assertEqual(len(cells), 3 * 4 * 2 * 4) + # The adapter registered for a reset drops its frames at reset() and, + # through the base's `_rejoin`, at every return from Mix 0 as well; + # ac2181f, which had no `_rejoin`, lost them at reset() only. Mix 0 + # on the source itself skips them while Mix is 0. The 256-frame + # control, which leaves the adapter holding nothing, stays green. + for plant, events in (("adapterreset", + ("reset", "Mix 0 and back", "Mix-0 run")), + ("sourcebypass", ("Mix-0 run",))): + want = sorted(("%s %d %s" % (buffers, pulls, event), rate, + channels) + for buffers, pulls in HELD for event in events + for rate, channels in BLOCK_CASES) + self.assertEqual(red_cells(results, "BLOCKS", plant), want, + plant) + + def test_routes_before_the_first_pull_and_source_buffers_cpython(self): + results = run_routes(sys.executable, ( + "clean,oldwiring=firstdry,firstheads", + "clean,adapterreset,sourcebypass=blocks")) + self._judge_routes(results, native=False) + self._judge_blocks(results) + + def test_routes_before_the_first_pull_and_source_buffers_native(self): + found = current_native_interpreters() + if found is None: + self.skipTest("no workspace bin/ above this checkout") + for family, binary in sorted(found.items()): + # A binary present but built before the pin renders a node the + # pin does not name; that is a failure, not a skip. + self.assertIsNotNone(binary, "no %s in the workspace bin/ " + "contains AUDIODSP_PIN's audiodsp" % family) + results = run_routes(binary, ( + "clean,oldwiring=firstdry,firstheads", + "clean,adapterreset,sourcebypass=blocks")) + self._judge_routes(results, native=True) + self._judge_blocks(results) + + def test_the_route_plants_are_not_on_the_surface(self): + module = routes_module() + for faulted, reading, target in ( + (module.OldWiring, lambda e: e._quiet_wiring(), False), + (module.AdapterReset, + lambda e: e._resets[e._nodes.index(e._adapter)], True), + (module.SourceBypass, lambda e: e._bypass() is e._source, + True)): + result = reach(faulted, reading) + self.assertIs(result["target"], target, faulted.__name__) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_mix_zero_hands_out_the_input_adapter(self): + # A wire at Mix 0 through the adapter (behind `_through`): a bare + # RawSample, byte for byte from the first frame, on the three rates + # and both channel counts. + for rate in RATES: + for channels in (2, 1): + data = probes.ramp_fs(4096, channels) + raw = audiocore.RawSample(data, sample_rate=rate, + channel_count=channels) + effect = MultiTapDelay(raw, sample_rate=rate, mix=0.0) + self.assertIs(_component.port_target(effect._output), + effect._through) + out = pull(effect, 4096).reshape(-1) + self.assertTrue(np.array_equal( + out, np.array(data, dtype=np.int64)), (rate, channels)) + effect.deinit() + + def test_a_host_reset_at_mix_zero_keeps_the_timeline(self): + # 31 blocks at Mix 0 from a source in 512-frame buffers, so the + # adapter holds 256 frames, then the host resets the output: the + # click at 8 400 stays at 8 400 (the adapter itself as the port's + # target puts it at 8 144). + module = routes_module() + for channels in (2, 1): + values = module.clicks(channels, 20000, ((8400, 20000),)) + for cls, want in ((MultiTapDelay, 8400), (BareAdapter, 8144)): + effect = cls(module.source(values, RATE, channels, 512), + sample_rate=RATE, mix=0.0) + head = pull(effect, 31 * BLOCK) + audiocore.reset_buffer(effect.output) + out = np.concatenate([head, pull(effect, 2000)]) + effect.deinit() + self.assertEqual(lane_hits(out), [[want]] * channels, + (cls.__name__, channels)) + result = reach(BareAdapter, lambda e: e._bypass() is e._adapter) + self.assertIs(result["target"], True) + self.assertEqual(result["checked"], 9 * 17 + 7) + + +# -------------------------------------------------------------------------- +# Re-audit fix round 2: the words for a build without get_buffer, and a +# plant for the tail reading + + +class NoTapLap(MultiTapDelay): + """A tail bound one lap short: `tail_samples` leaves out the lap the + tap node needs to read its line out (the re-refuter's plant).""" + + NAME = 'MultiTapDelay' + + def _tail_bound(self): + bound = MultiTapDelay._tail_bound(self) + return bound - self._lap if bound else bound + + +def tail_reading(cls, rate, channels, settings, level=32767): + """DC at `level` for max(rate / 2, two laps), then silence: (tail_samples, + frames from the input's end to the last non-zero sample, whether the + last frame rendered is non-zero).""" + probe = cls(Endless(silence(channels=channels), rate, channels), + sample_rate=rate) + for index, midi in settings: + probe.set_macro(index, midi) + hold = max(rate // 2, 2 * probe._lap) + probe.deinit() + effect = cls(Endless(array("h", [level] * (hold * channels)), rate, + channels), sample_rate=rate) + for index, midi in settings: + effect.set_macro(index, midi) + declared = effect.tail_samples + total = hold + declared + 3 * effect._lap + 2 * BLOCK + out = pull(effect, total) + effect.deinit() + nz = np.nonzero(np.any(out != 0, axis=1))[0] + last = int(nz[-1]) if len(nz) else -1 + return declared, max(0, last - hold + 1), last == total - 1 + + +#: The route cells of `ROUTES_MODULE`'s gb / nogb parts. +NG_ROUTE_NAMES = ("constructed", "Mix 0 first, back at 24", + "Mix 0 first, reset at 24", "Mix 0 and back first", + "built at Mix 0, reset first", "reset at 24", + "Mix 0 at 24, back at 48") +NG_CASES = ((48000, 2), (48000, 1), (22050, 1)) +NG_GROUPS = ("clean=gb,nogb", "oldwiring=gb", "quietalways=nogb") + + +class ReauditRoundTwo(unittest.TestCase): + """Re-audit fix round 2 (2026-09-28): the gate audit's re-audit round 1 + items 1 and 2.""" + + def _judge_ng(self, results): + def cells(names): + return sorted((name, rate, channels) for name in names + for rate, channels in NG_CASES) + + # The clean class plays what the docstring says, with get_buffer + # and without it, on every route and in every lane. + for part in ("GB", "NOGB"): + self.assertEqual(len([key for key in results if key[0] == part + and key[1] == "clean"]), 7 * 3, part) + self.assertEqual(red_cells(results, part, "clean"), [], part) + # With get_buffer the law is the source on time: ac2181f's wiring + # breaks it wherever Mix goes to 0 before the first pull. + self.assertEqual(red_cells(results, "GB", "oldwiring"), cells( + ("Mix 0 first, back at 24", "Mix 0 first, reset at 24", + "Mix 0 and back first"))) + # Without it the law is the one the words state (the Mix-0 run one + # block early, the first block at the return with its heads, the + # pending block's heads after a reset or a Mix-0 run): a wiring + # that primes zeros on every build moves the first block, and is + # red wherever the class was built above Mix 0. + self.assertEqual(red_cells(results, "NOGB", "quietalways"), cells( + name for name in NG_ROUTE_NAMES + if name != "built at Mix 0, reset first")) + + def test_the_no_get_buffer_words_cpython(self): + self._judge_ng(run_routes(sys.executable, NG_GROUPS)) + + def test_the_no_get_buffer_words_native(self): + found = current_native_interpreters() + if found is None: + self.skipTest("no workspace bin/ above this checkout") + for family, binary in sorted(found.items()): + self.assertIsNotNone(binary, "no %s in the workspace bin/ " + "contains AUDIODSP_PIN's audiodsp" % family) + self._judge_ng(run_routes(binary, NG_GROUPS)) + + def test_quiet_always_is_not_on_the_surface(self): + # Where get_buffer is left out, no macro position or patch makes + # the clean class's wiring outside reset() quiet. + module = routes_module() + module.use_audiocore(module.NoGetBuffer()) + try: + result = reach(module.QuietAlways, lambda e: e._quiet_wiring()) + finally: + module.use_audiocore(audiocore) + self.assertIs(result["target"], True) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_the_tail_reading_is_red_on_a_bound_one_lap_short(self): + # The stall cell's tail sits far inside its bound, so the stall + # test's tail half cannot see a bound one lap short. Full-scale DC + # at the shortest lap, Feedback MIDI 96, Repeat Tone MIDI 126, comes + # within one lap of it: inside the bound, and over it on NoTapLap. + settings = ((TIME_I, 0), (FEEDBACK_I, 96), (MIX_I, 127), + (TONE_I, 126)) + for rate, channels in ((RATE, 2), (22050, 1)): + declared, tail, held = tail_reading(MultiTapDelay, rate, + channels, settings) + self.assertFalse(held, (rate, channels)) + self.assertLessEqual(tail, declared, (rate, channels)) + short, planted, held = tail_reading(NoTapLap, rate, channels, + settings) + self.assertFalse(held, (rate, channels)) + self.assertEqual(planted, tail, (rate, channels)) + self.assertGreater(planted, short, (rate, channels)) + + +# -------------------------------------------------------------------------- +# The docstring's claims, each tied to the test that asserts it + +#: (sentence, word for word as the class docstring has it, and the test +#: that asserts it). Every sentence in the class docstring that makes a +#: claim is here; one that could not be tied to a test was struck (the +#: trial fixer's dated note in the dossier lists them). +#: What the boards measured, marginal ms a block at 48 kHz stereo (the +#: anchor's phase5_probes/boards_063/RESULTS.md, audiodsp v0.6.3). The +#: budget is Brad's G6 bar, 80 % of the block. +BOARD_COST = { + "measured_at": "v0.6.3", + "block_ms": 5.333, + "budget_ms": 4.267, + "S3": {3: 4.163, 4: 5.567}, + "P4": {3: 2.748, 4: 3.865}, +} + +CLAIMS = ( + ("Measured at v0.6.3, patch 4 costs 5.567 ms a block on the ESP32-S3, " + "where a block lasts 5.333 ms, so it needs a P4-class board: the " + "ESP32-P4 runs it in 3.865 ms.", + "test_the_board_figures_are_the_table"), + ("Patch 3 costs 4.163 ms on the ESP32-S3, inside the 4.267 ms budget, " + "and may need a P4-class board too, especially when the board is " + "running anything else.", + "test_the_board_figures_are_the_table"), + ("Time is the gap to head 1, landed on a whole frame, and head k sounds " + "at exactly k times that gap.", + "test_first_lap_over_modes_heads_and_time_stops"), + ("Heads is how many heads the grid has, and the pattern repeats once per " + "trip past the farthest of them.", "test_laps_land_on_the_grid"), + ("Pattern picks which heads sound, from the twelve positions of the " + "Roland RE-202's mode selector.", "test_s1_table_at_four_heads"), + ("Its last position sounds every head on the grid, not the RE-202's " + "unpublished positions.", "test_other_heads"), + ("The lap is the farthest head on the grid, sounded or not, so heads " + "past the ones a position sounds lengthen the lap without sounding.", + "test_the_lap_is_the_farthest_head"), + ("Feedback sends the pattern round again, each lap quieter.", + "test_lap_levels_follow_feedback"), + ("Repeat Tone darkens each lap a little more than the one before, once " + "per lap, never once per head.", "test_at_the_row_cells"), + ("Tilt leans the pattern's levels towards the near heads or the far " + "ones.", "test_tilt_law"), + ("Up to Mix 1 the dry passes at unity, and at Mix 2 the echoes play " + "alone.", "test_the_mix_stops"), + ("On CircuitPython alone, a stereo dry's right lane reads one LSB hot " + "on source values within 32 LSB of the rails.", + "test_the_right_lane_is_hot_only_on_circuitpython"), + ("Mix 0 is a wire.", "test_mix_zero_is_a_wire"), + ("Sync locks Time to Division of the host's beat, clamped to Time's " + "span.", "test_sync_is_division_clamped_to_the_span"), + ("With no host tempo, Time stays on the knob.", + "test_sync_reads_the_transport_only_when_on"), + ("Time runs from 20 to 400 ms, Heads from 3 to 8, Feedback from 0 to " + "0.95 and Mix from 0 to 2.", "test_the_knob_spans"), + ("`max_lap_ms` (1600 by default, 540 at least) caps the lap: where a " + "grid would pass it, Time comes down to fit.", + "test_constructor_options_clamp"), + ("A click comes out on the frame it went in: there is no latency.", + "test_click_latency_is_zero"), + ("A one-channel source gets the same effect on its one channel.", + "test_first_lap_at_the_defaults_three_rates_both_graphs"), + ("The sample rate must be at least 12 825 Hz: below it the constructor " + "raises `ValueError`.", "test_rates_below_the_floor_are_refused"), + ("The lines are not fed while Mix is 0, so a return from Mix 0 starts " + "both lines empty.", "test_mix_back_from_zero_starts_from_empty_lines"), + ("`reset()` empties both lines and returns to patch 0.", + "test_reset_empties_both_lines_and_returns_to_patch_0"), + ("Neither a reset nor a trip to Mix 0 drops a frame of your source or " + "plays one twice, whatever size of buffer it hands out.", + "test_reset_and_mix_zero_keep_the_timeline"), + ("`tail_samples` is an upper bound on how long the echoes take to reach " + "exact zero once your input stops.", "test_tail_ends_inside_tail_samples"), + ("The tail rings on while your source hands back nothing.", + "test_the_tail_rings_on_while_the_source_is_dry"), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "test_a_jumping_control_steps_the_output"), +) + + +#: Every source value within 64 LSB of either rail, and a few inside, +#: through the class at Mix 1 in one run of 256-frame blocks shorter than +#: the first head: prints `channels|left wrong|right wrong|worst|least |v|`. +HOT_SCRIPT = """ +import audiocore +from array import array +from audioeffects.rebuilt.multitapdelay import MultiTapDelay +values = list(range(-32768, -32704)) + list(range(32704, 32768)) + [ + -20000, -1, 0, 1, 20000] +for channels in (2, 1): + data = array("h", [0] * (len(values) * channels)) + for i in range(len(values)): + for c in range(channels): + data[i * channels + c] = values[i] + source = audiocore.RawSample(data, sample_rate=48000, + channel_count=channels) + effect = MultiTapDelay(source, sample_rate=48000, mix=1.0) + got = array("h") + while len(got) < len(data): + got.extend(memoryview(audiocore.get_buffer(effect.output)[1]).cast( + "h") if hasattr(memoryview, "cast") else + audiocore.get_buffer(effect.output)[1]) + lanes = [0, 0] + worst = 0 + least = 99999 + for i in range(len(values)): + for c in range(channels): + d = got[i * channels + c] - values[i] + if d: + lanes[c] += 1 + worst = max(worst, abs(d)) + least = min(least, abs(values[i])) + print("%d|%d|%d|%d|%d" % (channels, lanes[0], lanes[1], worst, least)) + effect.deinit() +print("DONE") +""" + + +def run_hot(binary): + """{channels: (left wrong, right wrong, worst, least |v| wrong)}.""" + with tempfile.TemporaryDirectory() as directory: + script = os.path.join(directory, "hot.py") + with open(script, "w") as handle: + handle.write(HOT_SCRIPT) + env = dict(os.environ, MICROPYPATH="lib", GCOV_PREFIX=directory, + PYTHONDONTWRITEBYTECODE="1", + PYTHONPATH=os.path.join(ROOT, "lib")) + argv = [binary] + ([] if binary == sys.executable + else ["-X", "heapsize=256M"]) + done = subprocess.run(argv + [script], capture_output=True, + text=True, cwd=ROOT, env=env) + lines = done.stdout.splitlines() + if done.returncode != 0 or not lines or lines[-1] != "DONE": + raise AssertionError("%s: %s%s" % (binary, done.stdout[-1000:], + done.stderr[-2000:])) + out = {} + for line in lines[:-1]: + parts = [int(v) for v in line.split("|")] + out[parts[0]] = tuple(parts[1:]) + return out + + +def _flat(text): + return " ".join(text.split()) + + +class TheClaims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + doc = _flat(MultiTapDelay.__doc__) + tests = set() + for value in globals().values(): + if isinstance(value, type) and issubclass(value, + unittest.TestCase): + tests.update(n for n in dir(value) if n.startswith("test_")) + rest = doc + for sentence, test in CLAIMS: + self.assertIn(sentence, doc, sentence) + self.assertIn(test, tests, sentence) + rest = rest.replace(sentence, " ") + self.assertIn("**Limits shared by the family.**", doc) + numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] + self.assertEqual(numbers, []) + + def test_the_board_figures_are_the_table(self): + cost = BOARD_COST + doc = _flat(MultiTapDelay.__doc__) + dense = ("Measured at %s, patch 4 costs %.3f ms a block on the " + "ESP32-S3, where a block lasts %.3f ms, so it needs a " + "P4-class board: the ESP32-P4 runs it in %.3f ms." % ( + cost["measured_at"], cost["S3"][4], cost["block_ms"], + cost["P4"][4])) + far = ("Patch 3 costs %.3f ms on the ESP32-S3, inside the %.3f ms " + "budget, and may need a P4-class board too, especially when " + "the board is running anything else." % ( + cost["S3"][3], cost["budget_ms"])) + self.assertIn(dense, doc) + self.assertIn(far, doc) + # "needs", "runs it" and "inside" are what the table says. + self.assertAlmostEqual(cost["budget_ms"], 0.8 * cost["block_ms"], + places=2) + self.assertGreater(cost["S3"][4], cost["block_ms"]) + self.assertLess(cost["P4"][4], cost["budget_ms"]) + self.assertLess(cost["S3"][3], cost["budget_ms"]) + self.assertLess(cost["P4"][3], cost["budget_ms"]) + self.assertEqual(MultiTapDelay.PATCHES[4][0], "Eight Heads, Dense") + self.assertEqual(MultiTapDelay.PATCHES[3][0], + "Four Heads, Far Loudest") + + def test_the_right_lane_is_hot_only_on_circuitpython(self): + # The stock audiomixer's pan law: 32768 / 32767 on the right lane, + # which moves the 63 values from 32 736 up (and down from -32 736, + # the rail itself clipping) one LSB out. Mono and the left lane are + # exact, and every other interpreter is exact. + exact = {2: (0, 0, 0, 99999), 1: (0, 0, 0, 99999)} + self.assertEqual(run_hot(sys.executable), exact) + found = current_native_interpreters() + if found is None: + self.skipTest("no workspace bin/ above this checkout") + for family, binary in sorted(found.items()): + self.assertIsNotNone(binary, family) + got = run_hot(binary) + if family == "circuitpython": + self.assertEqual(got[1], exact[1]) + left, right, worst, least = got[2] + self.assertEqual((left, worst, least), (0, 1, 32736)) + self.assertEqual(right, 63) + else: + self.assertEqual(got, exact, family) + + def test_the_knob_spans(self): + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE) + self.assertEqual(effect._max_lap_ms, 1600.0) + for index, low, high in ((TIME_I, 20.0, 400.0), + (FEEDBACK_I, 0.0, 0.95), + (MIX_I, 0.0, 2.0)): + effect.set_macro(index, 0) + self.assertAlmostEqual(effect._value(index), low, places=9) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect._value(index), high, places=9) + effect.set_macro(HEADS_I, 0) + self.assertEqual(effect._heads_count(), 3) + effect.set_macro(HEADS_I, 127) + self.assertEqual(effect._heads_count(), 8) + + def test_the_lap_is_the_farthest_head(self): + # Mode 1 sounds head 1 alone. With Repeat Tone out (single-frame + # laps) and the echoes alone, a click at frame 10 comes back at + # 10 + n1 and then once per lap of K n1 (n1 = 960 at 20 ms), so the + # heads it does not sound lengthen the lap. + for heads, laps in ((3, (1, 4, 7)), (8, (1, 9))): + effect = build(data=click(at=10), mix=2.0, feedback=0.5, + time_ms=20.0, tone_hz=0.0, pattern=1, + heads=heads) + out = pull(effect, 10 + 960 * laps[-1] + 100) + self.assertEqual(nonzero(out), [10 + 960 * k for k in laps], + heads) + effect.deinit() + + def test_the_mix_stops(self): + for channels in (2, 1): + for mix in (0.35, 1.0): + effect = build(data=click(at=10, channels=channels), + channels=channels, mix=mix) + out = pull(effect, 7300) + self.assertEqual(int(out[10, 0]), 20000, (channels, mix)) + effect.deinit() + effect = build(data=click(at=10, channels=channels), + channels=channels, mix=2.0) + out = pull(effect, 7300) + self.assertEqual(nonzero(out)[0], 10 + 7200, channels) + effect.deinit() + + def test_sync_is_division_clamped_to_the_span(self): + # 1/1 at 30 bpm is 8 s, clamped to 400 ms; 1/32 at 600 bpm is + # 12.5 ms, clamped to 20 ms; 1/4 at 120 bpm is 500 ms, over the top. + for bpm, division, n1 in ((30.0, 15, 19200), (600.0, 0, 960), + (120.0, 9, 19200), (100.0, 6, 14400)): + effect = MultiTapDelay( + Endless(silence()), sample_rate=RATE, division=division, + transport=lambda bpm=bpm: (True, 0.0, bpm, 4, 4)) + effect.set_macro(SYNC_I, 127) + self.assertEqual(effect._n1, n1, (bpm, division)) + effect.deinit() + + def test_reset_empties_both_lines_and_returns_to_patch_0(self): + effect = build(data=click(), mix=1.0, feedback=0.9, time_ms=20.0) + effect.program_change(3) + pull(effect, 3000) + effect.reset() + self.assertEqual(effect.patch_index, 0) + self.assertEqual(int(np.abs(pull(effect, 20000)).max()), 0) + + def test_reset_and_mix_zero_keep_the_timeline(self): + import lifecycle + names = ("E1-reset@block", "E1-reset@part", "E5-mix0", "E9-src100", + "E9-src512", "E9-src1000", "E9-srcraw", "E10-reset", + "E10-mix0") + for patch in (None, 1): + events = dict((e.name, e) for e in + lifecycle.events(MultiTapDelay, patch)) + for channels in (2, 1): + for name in names: + got = lifecycle.p1(MultiTapDelay, events[name], RATE, + channels, patch, {}) + self.assertIn(got, ("ok", "na"), + (name, patch, channels)) + + def test_the_tail_rings_on_while_the_source_is_dry(self): + # The source hands back an empty buffer, done, from the third block + # on; the heads and laps of a click it played before keep coming, + # every pull a full block, on every lap of the lean graph. + import lifecycle + values = array("h", [0] * (BLOCK * 4)) + values[10] = 20000 + feed = lifecycle.Feed(values, RATE, 1, "256", loop=False) + effect = MultiTapDelay(feed.port, sample_rate=RATE, time_ms=20.0, + mix=2.0, feedback=0.5, tone_hz=0.0) + out = bytearray() + for _ in range(2): + out += bytes(audiocore.get_buffer(effect.output)[1]) + feed.port.play(feed.empty) + for _ in range(40): + chunk = bytes(audiocore.get_buffer(effect.output)[1]) + self.assertEqual(len(chunk), BLOCK * 2) + out += chunk + got = np.frombuffer(bytes(out), dtype=" 0.0: + self._tone_was_in = True + elif getattr(self, "_tone_was_in", False): + self._delay.set(damping_hz=0.001) + if self._cut > 0.0: + self._cut_was_in = True + elif getattr(self, "_cut_was_in", False): + self._delay.set(cut_hz=0.001) + + def _clear(self): + PingPongDelay._clear(self) + self._tone_was_in = self._cut_was_in = False + + +class SteppedPingPong(PingPongDelay): + """The workaround retired at audiodsp v0.6.3rc1: with Repeat Tone in, + the Feedback handed at the nearer edge of the stall window + (`clear_of_stalls`), a Feedback nobody set.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + PingPongDelay._refresh(self) + self._step() + + def _step(self): + if self._damping > 0.0 and self._feedback > 0.0: + excess = pp.tone_excess(self._damping, self._sample_rate)[1] + stepped = clear_of_stalls(self._feedback, excess) + if stepped != self._feedback: + self._feedback = stepped + self._delay.set(feedback=stepped) + + +class TrackingPingPong(SteppedPingPong): + """The class before audiodsp v0.6.3rc1: once Repeat Tone has been in, + its out stop hands `damping_hz` at 32 x the rate (the low-pass tracking + the tap), and the Feedback is stepped clear as with Tone in.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + PingPongDelay._refresh(self) + if self._damping > 0.0: + self._tone_was_in = True + elif getattr(self, "_tone_was_in", False): + self._damping = 32.0 * self._sample_rate + self._delay.set(damping_hz=self._damping) + self._step() + + def _clear(self): + PingPongDelay._clear(self) + self._tone_was_in = False + + +class HeldCutPingPong(PingPongDelay): + """The class before audiodsp v0.6.3rc1: once Repeat Cut has been in, + its bottom stop stays in circuit at the 20 Hz corner, and + `tail_samples` is `None` while it lasts.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + PingPongDelay._refresh(self) + if self._cut > 0.0: + self._cut_was_in = True + elif getattr(self, "_cut_was_in", False): + self._cut = pp.nominal_cut_hz(self._hz(pp.CUT_MIN_HZ), + self._sample_rate) + self._delay.set(cut_hz=self._cut) + + def _clear(self): + PingPongDelay._clear(self) + self._cut_was_in = False + + +# -------------------------------------------------------------------------- +# Sources and pulls + + +def to_source(values, channels=2, rate=RATE): + """A mono sequence, copied to every channel.""" + x = np.clip(np.round(np.asarray(values, dtype=np.float64)), + -32768, 32767).astype(np.int16) + data = array("h", np.repeat(x[:, None], channels, axis=1) + .reshape(-1).tobytes()) + return probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), data + + +def silence_src(frames, channels=2, rate=RATE): + return probes.ArraySource(array("h", [0] * (frames * channels)), + rate=rate, channels=channels, block=BLOCK) + + +def pull(effect, frames, channels=None, on_block=None): + """(frames, channels) int16; `on_block(frame)` runs before each block.""" + channels = channels or effect.channel_count + out = array("h") + while len(out) < frames * channels: + if on_block is not None: + on_block(len(out) // channels) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + out.extend([0] * (frames * channels - len(out))) + break + out.extend(memoryview(data).cast("h")) + return np.array(out[:frames * channels], + dtype=np.int16).reshape(-1, channels) + + +def render(cls, values, rate=RATE, channels=2, macros=None, **options): + """`values` through `cls` built with `options`, then `macros` + ({index: MIDI}) set, so a macro can be held over a patch.""" + source, _ = to_source(values, channels, rate) + effect = cls(source, sample_rate=rate, **options) + for index in sorted(macros or {}): + effect.set_macro(index, macros[index]) + return pull(effect, len(values), channels) + + +def click(frames, level=20000): + x = np.zeros(frames) + x[0] = level + return x + + +def burst(rate, ms=50.0, peak=8192): + """T3's 50 ms deterministic noise burst: numpy `RandomState(12345)`, + uniform, peak 8 192 LSB (-12 dBFS).""" + n = int(round(ms * rate / 1000.0)) + return np.random.RandomState(12345).uniform(-peak, peak, n) + + +def material(name, rate, channels): + """A kit probe as it ships, (frames, channels) int16.""" + path = os.path.join(PROBES, str(rate), "%dch" % channels, name + ".wav") + with wave.open(path, "rb") as handle: + data = handle.readframes(handle.getnframes()) + return np.frombuffer(data, dtype=np.int16).reshape(-1, channels) + + +def render_pcm(cls, pcm, rate, macros=None, **options): + channels = pcm.shape[1] + source = probes.ArraySource(array("h", pcm.reshape(-1).tobytes()), + rate=rate, channels=channels, block=BLOCK) + effect = cls(source, sample_rate=rate, **options) + for index in sorted(macros or {}): + effect.set_macro(index, macros[index]) + return pull(effect, pcm.shape[0], channels) + + +def cell_frames(options, macros=None, rate=RATE): + """The cell's whole-frame T by the dossier's law: a constructor Time + as given, a patch or a Time macro at its grid value.""" + macros = macros or {} + if TIME_I in macros: + return law_frames(law_time_ms(macros[TIME_I]), rate) + if "patch" in options: + midi = PingPongDelay.PATCHES[options["patch"]][1] + return law_frames(law_time_ms(midi[TIME_I]), rate) + return law_frames(options.get("time_ms", 280.0), rate) + + +def cell_feedback(options, macros=None): + macros = macros or {} + if FEEDBACK_I in macros: + return law_feedback(macros[FEEDBACK_I]) + if "patch" in options: + return law_feedback(PingPongDelay.PATCHES[options["patch"]][1][1]) + return options.get("feedback", 0.45) + + +# -------------------------------------------------------------------------- +# T1: repeats alternate + + +def windows(y, T, rate, repeats=8): + """(own channel, opposite channel, own peak, peak offset) per repeat, + in +-1 ms windows at n T, First Side left: odd n on channel 0.""" + half = int(math.floor(rate / 1000.0)) + rows = [] + for n in range(1, repeats + 1): + at = n * T + lo, hi = max(0, at - half), min(len(y), at + half + 1) + own = 0 if n % 2 else 1 + seg = y[lo:hi].astype(np.int32) + if len(seg) == 0: + rows.append((own, 0, 0, None)) + continue + peak_at = int(np.argmax(np.abs(seg[:, own]))) + rows.append((own, int(np.count_nonzero(seg[:, 1 - own])), + int(np.abs(seg[:, own]).max()), lo + peak_at - at)) + return rows + + +def t1_measure(cls, rate=RATE, macros=None, peak_clause=True, **options): + """T1 at one cell: the click (20 000 LSB, both channels) at Mix 2 and + Spread 1; per repeat 1-8 the opposite channel exact zero in its +-1 ms + window, its own peak at least 200 LSB, and (with Repeat Tone out) the + peak on n T; and the First Side right render the left one swapped.""" + options = dict(options) + options.setdefault("mix", 2.0) + options.setdefault("spread", 1.0) + T = cell_frames(options, macros, rate) + frames = 9 * T + int(0.02 * rate) + x = click(frames) + left = render(cls, x, rate, 2, macros=macros, first_side="left", + **options) + right = render(cls, x, rate, 2, macros=macros, first_side="right", + **options) + rows = windows(left, T, rate) + leaks = sum(row[1] for row in rows) + weak = [n + 1 for n, row in enumerate(rows) if row[2] < FLOOR] + late = [n + 1 for n, row in enumerate(rows) + if row[3] is None or row[3] != 0] + swapped = int(np.count_nonzero(right != left[:, ::-1])) + passed = (leaks == 0 and not weak and swapped == 0 + and (not peak_clause or not late)) + return {"passed": passed, "leaks": leaks, "weak": weak, "late": late, + "swapped": swapped, "peaks": [row[2] for row in rows], + "offsets": [row[3] for row in rows], "T": T} + + +def t1_default(cls): + return t1_measure(cls, feedback=0.6) + + +# -------------------------------------------------------------------------- +# T2: one decay ratio across the alternation + + +def t2_measure(cls, rate=RATE, fit=True, **options): + """Peaks of repeats 1-8 in time order across both channels; each + successive ratio within 1 % of f, and (from Feedback MIDI 28) the two + channels' fitted decays per 2T within 1 % of each other. Only peaks of + at least 200 LSB enter; fewer than three, or fewer than two on a + channel for the fit, reads red.""" + options = dict(options) + options.setdefault("mix", 2.0) + options.setdefault("spread", 1.0) + f = cell_feedback(options) + T = cell_frames(options, None, rate) + frames = 9 * T + int(0.02 * rate) + y = render(cls, click(frames), rate, 2, **options) + rows = windows(y, T, rate) + peaks = [row[2] for row in rows] + kept = [p for p in peaks if p >= FLOOR] + # Peaks in time order stop at the first one under the floor. + run = [] + for p in peaks: + if p < FLOOR: + break + run.append(p) + ratios = [run[k + 1] / float(run[k]) for k in range(len(run) - 1)] + worst = max([abs(r / f - 1.0) for r in ratios] or [float("inf")]) + ok_ratio = len(run) >= 3 and worst <= 0.01 + agree = None + ok_fit = True + if fit: + decays = [] + for own in (0, 1): + n = [k + 1 for k, row in enumerate(rows) + if row[0] == own and row[2] >= FLOOR] + if len(n) < 2: + decays.append(None) + continue + logs = [math.log(rows[k - 1][2]) for k in n] + slope = np.polyfit(n, logs, 1)[0] + decays.append(math.exp(2.0 * slope)) + if None in decays: + ok_fit = False + else: + agree = abs(decays[0] / decays[1] - 1.0) + ok_fit = agree <= 0.01 + one_channel = [rows[k][2] for k in range(0, 8, 2) if rows[k][2] >= FLOOR] + down = (one_channel[1] / float(one_channel[0]) + if len(one_channel) >= 2 else None) + return {"passed": ok_ratio and ok_fit, "worst": worst, "agree": agree, + "kept": len(kept), "peaks": peaks, "down_one_channel": down} + + +def t2_default(cls): + return t2_measure(cls) + + +# -------------------------------------------------------------------------- +# T3: the mono sum is an ordinary delay, exactly + + +def reference(x, rate, T, f, damping=0.0, cut=0.0): + """A plain one-channel `FeedbackDelay(cross_feed=0, input_pan=0)` at + whole-frame T and f, Mix 2.""" + node = pp.audioecho.FeedbackDelay( + sample_rate=rate, channel_count=1, max_delay_ms=1001.0, + delay_ms=T * 1000.0 / rate, feedback=f, mix=2.0, + damping_hz=damping, cut_hz=cut, cross_feed=0.0, input_pan=0.0) + source, _ = to_source(x, 1, rate) + node.play(source) + out = array("h") + while len(out) < len(x): + out.extend(memoryview(bytes(audiocore.get_buffer(node)[1])) + .cast("h")) + node.deinit() + return np.array(out[:len(x)], dtype=np.int32) + + +def t3_measure(cls, rate=RATE, what="click", damping=0.0, cut=0.0, + **options): + """The class's stereo render summed in int32, the class's mono render + and the plain reference, compared sample for sample at Mix 2 (at + Spread 0 the sum is twice the reference).""" + options = dict(options) + macros = None + if "patch" in options: + # The patch's own Mix is 0.2992: Mix is held at 2 over it. + macros = {MIX_I: 127} + else: + options.setdefault("mix", 2.0) + options.setdefault("spread", 1.0) + T = cell_frames(options, None, rate) + f = cell_feedback(options) + frames = 9 * T + int(0.1 * rate) + x = np.zeros(frames) + if what == "click": + x[0] = 20000 + elif what == "quiet": + x[0] = 328 + else: + b = np.round(burst(rate)) + x[:len(b)] = b + stereo = render(cls, x, rate, 2, macros=macros, + **options).astype(np.int32) + total = stereo[:, 0] + stereo[:, 1] + mono = render(cls, x, rate, 1, macros=macros, + **options)[:, 0].astype(np.int32) + ref = reference(x, rate, T, f, damping, cut) + scale = 2 if options["spread"] == 0.0 else 1 + sum_diff = int(np.count_nonzero(total != scale * ref)) + mono_diff = int(np.count_nonzero(mono != ref)) + repeats_ref = int(np.count_nonzero(ref[1:])) + repeats_mono = int(np.count_nonzero(mono[1:])) + passed = sum_diff == 0 and mono_diff == 0 and repeats_mono >= repeats_ref + return {"passed": passed, "sum_diff": sum_diff, "mono_diff": mono_diff, + "repeats": (repeats_mono, repeats_ref)} + + +def t3_default(cls): + return t3_measure(cls, feedback=0.6) + + +# -------------------------------------------------------------------------- +# T4: Spread's law + + +def t4_measure(cls, rate=RATE, spreads=None, time_ms=280.0, feedback=0.6, + level=20000, swap=False): + """On the channel-identical click at Mix 2: every repeat 1-8 on both + channels within 4 LSB of the law (values under 4 LSB recorded, not + claimed), L - R exact zero at Spread 0, the wrong-multiple windows exact + zero at Spread 1, and (`swap`) the First Side right render the left one + swapped.""" + spreads = [k / 10.0 for k in range(11)] if spreads is None else spreads + T = law_frames(time_ms, rate) + frames = 9 * T + int(0.02 * rate) + x = click(frames, level) + worst = 0.0 + red = [] + for s in spreads: + left = render(cls, x, rate, 2, time_ms=time_ms, feedback=feedback, + mix=2.0, spread=s, first_side="left") + for n in range(1, 9): + for channel, first in ((0, True), (1, False)): + law = level * law_spread(n, s, feedback, first) + got = float(left[n * T, channel]) + if abs(law) < 4.0 and law != 0.0: + continue + if law == 0.0 and got != 0.0: + red.append(("zero", s, n, channel, got)) + error = abs(got - law) + worst = max(worst, error) + if error > 4.0: + red.append(("law", s, n, channel, got, law)) + if s == 0.0: + lr = int(np.count_nonzero(left[:, 0] != left[:, 1])) + if lr: + red.append(("L-R", s, lr)) + if s == 1.0: + leaks = sum(row[1] for row in windows(left, T, rate)) + if leaks: + red.append(("wrong multiple", s, leaks)) + if swap: + right = render(cls, x, rate, 2, time_ms=time_ms, + feedback=feedback, mix=2.0, spread=s, + first_side="right") + differ = int(np.count_nonzero(right != left[:, ::-1])) + if differ: + red.append(("swap", s, differ)) + return {"passed": not red, "worst": worst, "red": red} + + +def t4_default(cls): + return t4_measure(cls, spreads=(0.0, 0.5, 1.0)) + + +# -------------------------------------------------------------------------- +# T5: the dry path is a wire until the first repeat + + +T5_MATERIALS = ("ramp_fs", "tones_step", "sweep_log") + + +def lr_material(rate): + """Fix round 1: channel-different stereo, independent L and R noise + (`RandomState(7)`, uniform, peak 16 000 LSB = -6.2 dBFS), 2 T at + 280 ms long. Every kit probe is channel-identical, and on those a dry + that is mono-summed or swapped is byte-identical to the source.""" + n = 2 * law_frames(280.0, rate) + rs = np.random.RandomState(7) + left = rs.uniform(-16000, 16000, n) + right = rs.uniform(-16000, 16000, n) + return np.round(np.stack([left, right], axis=1)).astype(np.int16) + + +def antiphase_material(rate): + """R = -L: `lr_material`'s left channel against its own negative.""" + left = lr_material(rate)[:, 0] + return np.stack([left, -left], axis=1) + + +def read_dry_crosstalk(effect): + """How much of the right input reaches the left output before the first + repeat, at this instance's macro positions: two copies on 256 frames, + (L = ramp, R = 0) and (L = ramp, R = -ramp), and the count of left + output samples that differ. 0 for a dry that is each channel's own + signal, at every Mix: the first repeat is never inside 256 frames + (the material refuter's reading).""" + rate = effect._sample_rate + n = 256 + ramp = np.linspace(-30000, 30000, n) + outs = [] + for right in (np.zeros(n), -ramp): + pcm = np.stack([ramp, right], axis=1).round().astype(np.int16) + other = type(effect)(probes.ArraySource( + array("h", pcm.reshape(-1).tobytes()), rate=rate, channels=2, + block=BLOCK), sample_rate=rate) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + outs.append(pull(other, n, 2)) + other.deinit() + return int(np.count_nonzero(outs[0][:, 0] != outs[1][:, 0])) + + +def t5_measure(cls, rate=RATE, channels=2, materials=T5_MATERIALS, + macros=None, pcm=None, **options): + """WIRE over the first T - 1 frames on each material, and, where Mix is + above 0, the output not the source somewhere in the T frames from + frame T. `pcm`, when given, is the one material, as (frames, + channels) int16.""" + T = cell_frames(options, macros, rate) + probe = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + for index in sorted(macros or {}): + probe.set_macro(index, macros[index]) + mix = probe.macro(MIX_I) + probe.deinit() + differing = 0 + absent = [] + given = pcm + for name in (("given",) if given is not None else materials): + pcm = given if given is not None else material(name, rate, channels) + end = min(len(pcm), 2 * T) + y = render_pcm(cls, pcm[:end], rate, macros=macros, **options) + differing += int(np.count_nonzero(y[:T - 1] != pcm[:T - 1])) + if mix > 0.0 and len(pcm) > T: + if not np.count_nonzero(y[T:end] != pcm[T:end]): + absent.append(name) + return {"passed": differing == 0 and not absent, + "differing": differing, "absent": absent, "T": T} + + +def t5_default(cls): + return t5_measure(cls, materials=("tones_step",)) + + +def t5_lr_default(cls): + return t5_measure(cls, pcm=lr_material(RATE)) + + +# -------------------------------------------------------------------------- +# The walk (section 8.5), measured as the dossier's A6.6 did + + +def walk_cents(cls, rate=RATE, start_ms=280.0, target_ms=200.0): + """997 Hz at 12 000 LSB, Mix 2, Feedback 0; Time moved on a block + boundary at least 8 192 frames after the start Time's wet begins; the + pitch over the walk by the kit's `instantaneous_hz`, median over the + walk less 400 frames each end. (cents, walk frames).""" + T0 = law_frames(start_ms, rate) + move = ((T0 + 8192) // BLOCK + 1) * BLOCK + walk = int(round(abs(target_ms - start_ms) / 1000.0 / pp.SLEW * rate)) + frames = move + walk + rate // 2 + x = 12000 * np.sin(2.0 * math.pi * 997.0 * np.arange(frames) / rate) + source, _ = to_source(x, 2, rate) + effect = cls(source, sample_rate=rate, time_ms=start_ms, feedback=0.0, + mix=2.0) + target_midi = 127.0 * math.log(target_ms / 20.0) / math.log(50.0) + + def on_block(frame): + if frame == move: + effect.set_macro(TIME_I, target_midi) + + y = pull(effect, frames, 2, on_block)[:, 0].astype(float) + hz = instantaneous_hz(y, rate) + segment = hz[move + 400:move + walk - 400] + return 1200.0 * math.log(float(np.median(segment)) / 997.0, 2.0), walk + + +def law_cents(start_ms, target_ms): + ratio = 1.0 + pp.SLEW if target_ms < start_ms else 1.0 - pp.SLEW + return 1200.0 * math.log(ratio, 2.0) + + +# -------------------------------------------------------------------------- +# Reachability: what the node is handed at the position walked + + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node as `_handed` (the latest value of each).""" + + def __enter__(self): + node_class = pp.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._handed.update(options) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def handed(effect, name): + return effect._delay._handed[name] + + +def read_cross_pan(effect): + return (round(float(handed(effect, "cross_feed")), 6), + round(float(handed(effect, "input_pan")), 6)) + + +def read_frames(effect): + return round(float(handed(effect, "delay_ms")) * effect._sample_rate + / 1000.0, 3) + + +def read_feedback(effect): + return round(float(handed(effect, "feedback")), 4) + + +def read_slew(effect): + return float(handed(effect, "delay_slew")) + + +def read_dry_gain(effect): + """The dry path's gain before the first repeat: a copy at these + positions on a 256-frame full-scale ramp, least-squares out / in.""" + ramp = probes.ramp_fs(frames=256, channels=effect.channel_count) + other = type(effect)(probes.ArraySource( + ramp, rate=effect._sample_rate, channels=effect.channel_count, + block=BLOCK), sample_rate=effect._sample_rate) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + out = pull(other, 256).reshape(-1).astype(np.float64) + src = np.array(ramp, dtype=np.float64) + other.deinit() + return round(float(np.dot(out, src) / np.dot(src, src)), 5) + + +#: The fine grid every handed-value walk also runs on: quarter steps, 509 +#: positions per macro. +FINE = tuple(i / 4.0 for i in range(509)) + +#: (name, fault, reading, channels). Every walk runs at 48, 44.1 and +#: 22.05 kHz on the kit's grid, and the handed-value walks on `FINE` too. +REACH_WALKS = ( + ("LossyCrossPingPong", LossyCrossPingPong, read_cross_pan, 2), + ("LateReadPingPong", LateReadPingPong, read_frames, 2), + ("ScaledFeedbackPingPong", ScaledFeedbackPingPong, read_feedback, 2), + ("MonoStereoSettingsPingPong", MonoStereoSettingsPingPong, + read_cross_pan, 1), + ("PanlessPingPong", PanlessPingPong, read_cross_pan, 2), + ("NoSlewPingPong", NoSlewPingPong, read_slew, 2), +) + + +def reach(faulted, reading, rate, channels=2, grid=None): + def build(cls): + return cls(silence_src(512, channels, rate), sample_rate=rate) + + with NodeSpy(): + return kit_faults.fault_reachability(PingPongDelay, faulted, reading, + build, grid=grid) + + +def spied(cls=None, **options): + rate = options.pop("rate", RATE) + channels = options.pop("channels", 2) + with NodeSpy(): + effect = (cls or PingPongDelay)(silence_src(512, channels, rate), + sample_rate=rate, **options) + return effect + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + """Every node `set` in these tests is recorded, so a macro moved after + construction reads back what the node was handed.""" + + def setUp(self): + self._spy = NodeSpy().__enter__() + + def tearDown(self): + self._spy.__exit__(None, None, None) + + def test_macros_patches_tier_latency(self): + cls = PingPongDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Feedback", "Mix", "Spread", "First Side", + "Sync", "Division", "Repeat Tone", "Repeat Cut")) + self.assertEqual(tuple(cls.MACRO_MODES[i] for i in range(9)), + DOSSIER_MODES) + self.assertEqual(len(cls.PATCHES), 7) + self.assertEqual(cls.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho",)) + effect = cls(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ("tempo_sync",)) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects + self.assertIn("PingPongDelay", rebuilt.ADOPTED) + self.assertNotIn("PingPongDelay", rebuilt.parked()) + self.assertIs(audioeffects.PingPongDelay, PingPongDelay) + served = audioeffects.create("PingPongDelay", silence_src(64), RATE) + self.assertIsInstance(served, PingPongDelay) + served.deinit() + + def test_patches_are_the_dossier_settings_on_the_grid(self): + for index, (name, values) in enumerate(DOSSIER_PATCHES): + label, midi = PingPongDelay.PATCHES[index] + self.assertEqual(label, name) + self.assertEqual(midi, tuple( + _component.macro_of(span, value, mode) + for span, value, mode in zip(DOSSIER_SPANS, values, + DOSSIER_MODES))) + + def test_patch_0_is_the_constructor_grid(self): + effect = PingPongDelay(silence_src(512), sample_rate=RATE) + for index, expected in enumerate(PingPongDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_what_patch_0_hands_the_node(self): + # Tier 3's reference patch on the grid: 13 575 frames, 0.4521, + # 0.2992, the full cross, both filters out. + effect = spied(patch=0) + self.assertEqual(read_frames(effect), 13575.0) + self.assertEqual(read_feedback(effect), 0.4521) + self.assertAlmostEqual(handed(effect, "mix"), 0.2992, places=4) + self.assertEqual(read_cross_pan(effect), (1.0, -1.0)) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + self.assertEqual(handed(effect, "delay_slew"), 0.1875) + + def test_time_lands_on_a_whole_frame(self): + for rate in RATES: + effect = spied(rate=rate) + self.assertEqual(read_frames(effect), law_frames(280.0, rate)) + for midi in [127.0 * k / 16.0 for k in range(17)]: + effect.set_macro(TIME_I, midi) + self.assertEqual(read_frames(effect), + law_frames(law_time_ms(midi), rate), + (rate, midi)) + # The stops at 48 kHz: 960 and 48 000 frames. + effect = spied() + effect.set_macro(TIME_I, 0) + self.assertEqual(read_frames(effect), 960.0) + effect.set_macro(TIME_I, 127) + self.assertEqual(read_frames(effect), 48000.0) + + def test_where_the_node_lands_the_handed_frame(self): + # Section 6, restated at Station B fix round 1: the node turns the + # handed ms back into frames in float32 + # (`audiodsp_feedback_delay.c:148`). At 48 kHz every grid position + # lands exactly; at 44.1 and 22.05 kHz these land one float32 step + # off, which the class cannot avoid (the node ask is drafted). + off_frame = { + 48000: [], + 44100: [2, 3, 4, 19, 20, 24, 25, 28, 38, 43, 47, 48, 50, 63, 65, + 67, 69, 70, 83, 92, 93, 95, 107, 108, 114], + 22050: [2, 4, 19, 28, 38, 47, 63, 65, 67, 69, 70, 83, 88, 92, 93, + 95, 108, 110, 112, 114], + } + f32 = np.float32 + for rate in RATES: + effect = spied(rate=rate) + missed = [] + for midi in range(128): + effect.set_macro(TIME_I, midi) + ms = f32(handed(effect, "delay_ms")) + frames = (ms * f32(rate)) / f32(1000.0) + law = law_frames(law_time_ms(midi), rate) + self.assertEqual(read_frames(effect), law, (rate, midi)) + if float(frames) != law: + self.assertLessEqual(abs(float(frames) - law), + 2.0 ** -9, (rate, midi)) + missed.append(midi) + self.assertEqual(missed, off_frame[rate], rate) + + def test_an_off_frame_time_leaks_into_the_next_frame(self): + # The reviewer's cell: MIDI 95 at 44.1 kHz is 16 457 frames, and a + # 20 000 click's repeat reads 39 one frame early; at 48 kHz the same + # position is exact. + for rate, window in ((44100, [0, 39, 19961, 0]), + (48000, [0, 0, 20000, 0])): + frames = law_frames(law_time_ms(95), rate) + y = render(PingPongDelay, click(frames + 64), rate=rate, + macros={TIME_I: 95}, mix=2.0, feedback=0.0) + total = y.astype(np.int32).sum(axis=1) + self.assertEqual([int(v) for v in total[frames - 2:frames + 2]], + window, rate) + + def test_a_host_echoing_time_keeps_the_frame(self): + # 135 ms at 44.1 kHz is 5 953.5 frames, which the law lands up. + effect = spied(rate=44100, time_ms=135.0) + self.assertEqual(read_frames(effect), 5954.0) + effect.set_macro(TIME_I, effect.get_macro(TIME_I)) + self.assertEqual(read_frames(effect), 5954.0) + + def test_a_lowered_ceiling_clamps_visibly(self): + effect = spied(max_time_ms=300.0) + for midi in (100, 110, 127): + effect.set_macro(TIME_I, midi) + self.assertEqual(read_frames(effect), 14400.0) + self.assertAlmostEqual(effect.get_macro(TIME_I), 87.91, + delta=0.005) + self.assertEqual(spied(max_time_ms=float("nan"))._max_time_ms, + 1000.0) + self.assertEqual(spied(max_time_ms=5000.0)._max_time_ms, 1000.0) + + def test_sync_quantises_time_and_clamps(self): + def at_120(): + return (True, 0.0, 120.0, 4, 4) + + expected = {0: 3000, 6: 12000, 9: 24000, 12: 48000, 15: 48000} + for index, frames in expected.items(): + with NodeSpy(): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + transport=at_120, sync=True, + division=index) + self.assertEqual(read_frames(effect), float(frames), index) + self.assertAlmostEqual(effect.get_macro(TIME_I), 127.0, places=6) + # The static transport, and hosts whose tempo is not finite and + # positive, leave Time on the knob. + for transport in (None, lambda: (True, 0.0, 0.0, 4, 4), + lambda: (True, 0.0, float("nan"), 4, 4), + lambda: (True, 0.0, float("inf"), 4, 4)): + with NodeSpy(): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + transport=transport, sync=True) + self.assertEqual(read_frames(effect), 13440.0) + + def test_sync_patches_follow_the_beat(self): + def at_120(): + return (True, 0.0, 120.0, 4, 4) + + with NodeSpy(): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + transport=at_120) + effect.program_change(1) + self.assertEqual(read_frames(effect), 12000.0) + effect.program_change(2) + self.assertEqual(read_frames(effect), 24000.0) + effect.program_change(0) + self.assertEqual(read_frames(effect), 13575.0) + + def test_spread_and_first_side_hand_the_pair(self): + effect = spied() + for k in range(11): + effect.set_macro(SPREAD_I, 12.7 * k) + s = handed(effect, "cross_feed") + self.assertAlmostEqual(s, k / 10.0, places=12) + self.assertEqual(handed(effect, "input_pan"), -s) + effect.set_macro(SIDE_I, 127) + self.assertEqual(handed(effect, "input_pan"), s) + effect.set_macro(SIDE_I, 0) + # Mono: inert, (0, 0) at every position and patch. + mono = spied(channels=1) + for index in (SPREAD_I, SIDE_I): + for midi in (0, 64, 127): + mono.set_macro(index, midi) + self.assertEqual(read_cross_pan(mono), (0.0, 0.0)) + for patch in PingPongDelay.PATCHES: + mono.program_change(patch) + self.assertEqual(read_cross_pan(mono), (0.0, 0.0)) + + def test_the_filter_stops(self): + for rate in RATES: + effect = spied(rate=rate) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + effect.set_macro(TONE_I, 126) + corner = 800.0 * 20.0 ** (126 / 127.0) + corner = min(corner, rate * 0.5 * 0.98) + self.assertAlmostEqual(handed(effect, "damping_hz"), + nominal_damping_hz(corner, rate), + places=6) + # Out after in is out: since audiodsp v0.6.3rc1 the node keeps + # an out filter's state live (#158, #159), so both out stops + # hand exactly 0 whatever came before, and the bound is finite. + effect.set_macro(TONE_I, 127) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + effect.set_macro(CUT_I, 1) + self.assertGreater(handed(effect, "cut_hz"), 0.0) + self.assertIsNone(effect.tail_samples) + effect.set_macro(CUT_I, 0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + self.assertIsNotNone(effect.tail_samples) + # Planted: the retired cures (Tone tracking at 32 x the rate, + # Cut held in at the 20 Hz corner with no bound). + for cls, name, want in ( + (TrackingPingPong, "damping_hz", 32.0 * rate), + (HeldCutPingPong, "cut_hz", + pp.nominal_cut_hz(20.0, rate))): + old = spied(cls, rate=rate) + old.set_macro(TONE_I, 126) + old.set_macro(TONE_I, 127) + old.set_macro(CUT_I, 1) + old.set_macro(CUT_I, 0) + self.assertAlmostEqual(handed(old, name), want, places=9) + + def test_a_constructor_filter_out_again_is_out(self): + # A constructor or patch filter, then a patch without it: both out + # stops hand exactly 0 since audiodsp v0.6.3rc1 (up to v0.6.2 they + # counted as having been in and handed the tracking stop and the + # 20 Hz corner). Planted: the retired cures. + for ctor in ({"tone_hz": 5000.0, "cut_hz": 100.0}, {"patch": 5}): + effect = spied(**ctor) + effect.program_change(0) + self.assertEqual(handed(effect, "damping_hz"), 0.0, ctor) + self.assertEqual(handed(effect, "cut_hz"), 0.0, ctor) + old = spied(TrackingPingPong, **ctor) + old.program_change(0) + self.assertEqual(handed(old, "damping_hz"), 32.0 * RATE, ctor) + old = spied(HeldCutPingPong, cut_hz=100.0) + old.program_change(0) + self.assertAlmostEqual(handed(old, "cut_hz"), + pp.nominal_cut_hz(20.0, RATE), places=9) + + def test_repeat_tone_clamps_at_22k(self): + effect = spied(rate=22050) + values = set() + for midi in range(111, 127): + effect.set_macro(TONE_I, midi) + values.add(handed(effect, "damping_hz")) + self.assertEqual(len(values), 1) + effect.set_macro(TONE_I, 110) + self.assertNotIn(handed(effect, "damping_hz"), values) + + def test_the_walk_bends_by_the_law_and_the_jump_does_not(self): + for start, target in ((280.0, 200.0), (200.0, 280.0)): + law = law_cents(start, target) + got, walk = walk_cents(PingPongDelay, RATE, start, target) + self.assertAlmostEqual(got, law, delta=1.5, msg=(start, target)) + self.assertEqual(walk, 20480) # 427 ms at 48 kHz + jumped, _ = walk_cents(NoSlewPingPong, RATE, start, target) + self.assertLess(abs(jumped), 1.0, (start, target)) + + def test_tail_samples_follows_time_feedback_and_tone(self): + self.assertEqual(spied().tail_samples, 14 * 13441) + self.assertEqual(spied(patch=0).tail_samples, 190064) + self.assertEqual(spied(patch=5).tail_samples, 487944) + self.assertEqual(spied(time_ms=1000.0, feedback=0.99).tail_samples, + 685 * 48001) + for patch in (3, 4, 6): + self.assertEqual(spied(patch=patch).tail_samples, 190064) + self.assertIsNone(spied(cut_hz=40.0).tail_samples) + # Cut in, then out: since audiodsp v0.6.3rc1 the out stop is out + # (the node holds an out high-pass at zero, #159), so the bound is + # the one Cut never in has. Planted: the retired held 20 Hz corner. + effect = spied(cut_hz=40.0) + effect.set_macro(CUT_I, 0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + self.assertEqual(effect.tail_samples, 14 * 13441) + held = HeldCutPingPong(silence_src(64), sample_rate=RATE, + cut_hz=40.0) + held.set_macro(CUT_I, 0) + self.assertIsNone(held.tail_samples) + + def test_the_feedback_is_handed_as_set(self): + # Up to audiodsp v0.6.2 the class stepped the Feedback clear of the + # loop low-pass's stall windows with Repeat Tone in (0.99 played as + # 0.989976102). Since v0.6.3rc1 the node lands a stalled low-pass + # (#157): with Tone in or out every Feedback position is handed as + # set, and the bound is finite. Planted: the retired stepping. + for rate in RATES: + for tone in (0.0, 800.0, 16000.0): + effect = spied(rate=rate, tone_hz=tone) + for midi in range(128): + effect.set_macro(FEEDBACK_I, midi) + want = min(0.99, effect._value(FEEDBACK_I)) + self.assertEqual(effect._feedback, want, + (rate, tone, midi)) + self.assertEqual(handed(effect, "feedback"), want) + self.assertIsNotNone(effect.tail_samples) + stepped = SteppedPingPong(silence_src(64, rate=rate), + sample_rate=rate, tone_hz=800.0, + feedback=0.99) + self.assertNotEqual(stepped._feedback, 0.99, rate) + self.assertLess(abs(stepped._feedback - 0.99), 3e-5) + + def test_constructor_clamps_and_nan(self): + nan = float("nan") + effect = spied(time_ms=nan, feedback=nan, mix=nan, spread=nan, + division=nan, tone_hz=nan, cut_hz=nan) + self.assertEqual(read_frames(effect), 13440.0) + self.assertEqual(read_feedback(effect), 0.45) + self.assertAlmostEqual(handed(effect, "mix"), 0.3) + self.assertEqual(read_cross_pan(effect), (1.0, -1.0)) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + effect = spied(time_ms=0.0, feedback=2.0, mix=-1.0, spread=3.0, + tone_hz=0.0, cut_hz=-5.0) + self.assertEqual(read_frames(effect), 960.0) + self.assertEqual(read_feedback(effect), 0.99) + self.assertEqual(handed(effect, "mix"), 0.0) + self.assertEqual(read_cross_pan(effect), (1.0, -1.0)) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + self.assertEqual(read_frames(spied(time_ms=5000.0)), 48000.0) + self.assertEqual(read_cross_pan(spied(first_side="Right")), + (1.0, 1.0)) + with self.assertRaises(ValueError): + spied(first_side="middle") + + +# -------------------------------------------------------------------------- +# The Tier 2 rows + + +class T1RepeatsAlternate(unittest.TestCase): + def test_the_three_feedbacks_at_three_rates(self): + for rate in RATES: + for f in (0.6, 0.85, 0.99): + result = t1_measure(PingPongDelay, rate, feedback=f) + self.assertTrue(result["passed"], (rate, f, result)) + + def test_the_time_stops_and_the_patch_cells(self): + for options, macros in (({"time_ms": 20.0}, None), + ({"time_ms": 1000.0}, None), + ({}, {TIME_I: 63.5}), + ({}, {TIME_I: 86}), + ({}, {TIME_I: 99})): + result = t1_measure(PingPongDelay, feedback=0.85, + macros=macros, **options) + self.assertTrue(result["passed"], (options, macros, result)) + + def test_the_lowest_claimed_feedback(self): + for rate in RATES: + result = t1_measure(PingPongDelay, rate, time_ms=20.0, + macros={FEEDBACK_I: 67}) + self.assertTrue(result["passed"], (rate, result)) + self.assertGreaterEqual(min(result["peaks"]), FLOOR) + + def test_repeat_cut_from_60_ms(self): + for rate in RATES: + for midi in (1, 32, 127): + result = t1_measure(PingPongDelay, rate, time_ms=60.0, + feedback=0.99, macros={CUT_I: midi}) + self.assertTrue(result["passed"], (rate, midi, result)) + + def test_repeat_tone_in_keeps_the_exclusion_not_the_peak(self): + result = t1_measure(PingPongDelay, feedback=0.6, + macros={TONE_I: 0}, peak_clause=False) + self.assertEqual(result["leaks"], 0) + self.assertEqual(result["swapped"], 0) + # Named in advance: presence past repeat 3 and the peak position. + self.assertEqual(result["weak"][0], 4) + self.assertTrue(result["late"]) + + def test_presence_is_not_claimed_with_a_loop_filter_in(self): + # Fix round 1 (audit item 4): presence is claimed with both loop + # filters out. At the Feedback floor with Cut at its 400 Hz stop a + # repeat falls under 200 LSB, and the exclusion, the peak and the + # swap still hold on the same render. + weak = {48000: [8], 44100: [8], 22050: [7, 8]} + for rate in RATES: + result = t1_measure(PingPongDelay, rate, time_ms=280.0, + macros={FEEDBACK_I: 67, CUT_I: 127}) + self.assertEqual(result["weak"], weak[rate], rate) + self.assertEqual(result["leaks"], 0, rate) + self.assertEqual(result["late"], [], rate) + self.assertEqual(result["swapped"], 0, rate) + # Tone near its top loses repeat 8 at f 0.6. + result = t1_measure(PingPongDelay, rate, feedback=0.6, + macros={TONE_I: 126}, peak_clause=False) + self.assertEqual(result["weak"], [8], rate) + self.assertEqual(result["leaks"], 0, rate) + self.assertEqual(result["swapped"], 0, rate) + + def test_a_lossy_cross_is_red(self): + for rate in RATES: + result = t1_measure(LossyCrossPingPong, rate, feedback=0.6) + self.assertFalse(result["passed"], rate) + self.assertGreater(result["leaks"], 0) + + def test_a_late_read_is_red_on_the_peak(self): + for rate in RATES: + result = t1_measure(LateReadPingPong, rate, feedback=0.6) + self.assertFalse(result["passed"], rate) + self.assertEqual(result["leaks"], 0) + self.assertEqual(result["offsets"][:4], [1, 2, 3, 4]) + + +class T2OneDecayRatio(unittest.TestCase): + def test_the_constructor_feedbacks_at_the_named_times(self): + for rate in RATES: + for f in (0.6, 0.85): + for time_ms in (20.0, 280.0): + result = t2_measure(PingPongDelay, rate, feedback=f, + time_ms=time_ms) + self.assertTrue(result["passed"], + (rate, f, time_ms, result)) + + def test_the_feedback_span_ends(self): + for midi, fit in ((13, False), (28, True), (60, True), (127, True)): + result = t2_measure(PingPongDelay, fit=fit, + feedback=law_feedback(midi)) + self.assertTrue(result["passed"], (midi, result)) + # Below MIDI 28 the other channel has one peak: the fit reads red. + self.assertFalse(t2_measure(PingPongDelay, + feedback=law_feedback(27))["passed"]) + + def test_reading_down_one_channel_returns_f_squared(self): + # The seeds' fault is the measurement's own: why the ratio is taken + # across both channels. + for f in (0.6, 0.85): + result = t2_measure(PingPongDelay, feedback=f) + self.assertAlmostEqual(result["down_one_channel"], f * f, + delta=0.001) + + def test_a_scaled_feedback_is_red(self): + for rate in RATES: + for f in (0.6, 0.85): + result = t2_measure(ScaledFeedbackPingPong, rate, feedback=f) + self.assertFalse(result["passed"], (rate, f)) + self.assertGreater(result["worst"], 0.019) + + +class T3MonoSum(unittest.TestCase): + def test_the_named_pairs_at_three_rates(self): + for rate in RATES: + for options in ({"feedback": 0.45}, {"feedback": 0.6}, + {"feedback": 0.99}, + {"time_ms": 20.0, "feedback": 0.85}, + {"patch": 0}): + result = t3_measure(PingPongDelay, rate, **options) + self.assertTrue(result["passed"], (rate, options, result)) + + def test_the_quiet_click_the_burst_and_first_side_right(self): + for what in ("quiet", "burst"): + result = t3_measure(PingPongDelay, what=what, feedback=0.85) + self.assertTrue(result["passed"], (what, result)) + result = t3_measure(PingPongDelay, feedback=0.6, first_side="right") + self.assertTrue(result["passed"], result) + + def test_spread_0_is_twice_the_reference(self): + result = t3_measure(PingPongDelay, feedback=0.6, spread=0.0) + self.assertTrue(result["passed"], result) + + def test_the_loop_filters_read_exact(self): + damping = nominal_damping_hz(800.0, RATE) + result = t3_measure(PingPongDelay, feedback=0.6, tone_hz=800.0, + damping=damping) + self.assertTrue(result["passed"], result) + cut = pp.nominal_cut_hz(400.0, RATE) + result = t3_measure(PingPongDelay, feedback=0.6, cut_hz=400.0, + cut=cut) + self.assertTrue(result["passed"], result) + + def _gap(self, k, peak, opts, damping=0.0, cut=0.0, macros=None): + """The row's burst lengthened to end `k` frames before T (280 ms, + f 0.6, 48 kHz), `macros` set after construction: (sum differing, + max LSB, first differing frame, mono differing).""" + T = law_frames(280.0, RATE) + frames = 9 * T + int(0.1 * RATE) + n = T - k + x = np.zeros(frames) + x[:n] = np.round(np.random.RandomState(12345) + .uniform(-peak, peak, n)) + stereo = render(PingPongDelay, x, RATE, 2, macros=macros, + time_ms=280.0, feedback=0.6, mix=2.0, + **opts).astype(np.int32) + total = stereo[:, 0] + stereo[:, 1] + mono = render(PingPongDelay, x, RATE, 1, macros=macros, + time_ms=280.0, feedback=0.6, mix=2.0, + **opts)[:, 0].astype(np.int32) + ref = reference(x, RATE, T, 0.6, damping, cut) + where = np.flatnonzero(total != ref) + return (len(where), int(np.abs(total - ref).max()), + int(where[0]) if len(where) else None, + int(np.count_nonzero(mono != ref))) + + def test_the_filter_cells_need_material_ending_512_frames_before_t(self): + # Fix round 1 (audit item 5): each lane's loop filter meets its + # own lane's next repeat 2 T later, the reference's the very next, + # so a burst ending close to T parts them. The filter cells are + # claimed on material ending at least 512 frames before T. + damping = nominal_damping_hz(800.0, RATE) + self.assertEqual(self._gap(1, 8192, {"tone_hz": 800.0}, damping), + (295, 1, 2 * law_frames(280.0, RATE), 0)) + self.assertEqual(self._gap(1, 8192, {}), (0, 0, None, 0)) + cut = pp.nominal_cut_hz(400.0, RATE) + for peak in (8192, 32767): + self.assertEqual(self._gap(512, peak, {"cut_hz": 400.0}, + cut=cut)[0], 0, peak) + # At 0 dBFS 256 frames is not enough for Cut 400 Hz. + self.assertEqual(self._gap(256, 32767, {"cut_hz": 400.0}, + cut=cut)[0], 16) + + def test_the_gap_grows_as_repeat_cut_goes_down(self): + # Fix round 2 (audit round 2, item 4): 512 frames is the three + # named cells' gap, not any loop filter's. At Cut MIDI 1 (the knob + # at 20 x 20^(1/127) Hz) and 0 dBFS a burst ending 4 096 frames + # before T still differs in 511 samples, and 8 192 is exact; the + # mono build is exact at both. The old "512 frames" is red there. + cut = pp.nominal_cut_hz(20.0 * 20.0 ** (1.0 / 127.0), RATE) + macros = {CUT_I: 1} + at_512 = self._gap(512, 32767, {}, cut=cut, macros=macros) + self.assertGreater(at_512[0], 0) + self.assertEqual(at_512[3], 0) + at_4096 = self._gap(4096, 32767, {}, cut=cut, macros=macros) + self.assertEqual((at_4096[0], at_4096[3]), (511, 0)) + at_8192 = self._gap(8192, 32767, {}, cut=cut, macros=macros) + self.assertEqual((at_8192[0], at_8192[3]), (0, 0)) + + def _tone_in_then_out(self, cls, rate): + """(samples differing, largest difference) between the channels' + sum with Tone 5 kHz in then out and the mono reference at + Feedback 0.99, on a click, Time 280 ms, Mix 2.""" + T = law_frames(280.0, rate) + frames = 9 * T + int(0.1 * rate) + x = click(frames) + src, _ = to_source(x, 2, rate) + effect = cls(src, sample_rate=rate, time_ms=280.0, feedback=0.99, + mix=2.0, tone_hz=5000.0) + effect.set_macro(TONE_I, 127) + stereo = pull(effect, frames).astype(np.int32) + total = stereo[:, 0] + stereo[:, 1] + ref = reference(x, rate, T, 0.99) + return (int(np.count_nonzero(total != ref)), + int(np.abs(total - ref).max())) + + def test_tone_in_then_out_is_inside_the_row(self): + # Fix round 1 put this cell outside the row: the out stop after + # Tone had been in handed a Feedback stepped clear of the stall + # window (0.99 -> 0.989976102), 5 samples up to 5 LSB off. Since + # audiodsp v0.6.3rc1 Tone out is out whatever came before, and the + # cell reads exact at three rates. Planted: the retired tracking + # stop with its stepping. + for rate in RATES: + self.assertEqual(self._tone_in_then_out(PingPongDelay, rate), + (0, 0), rate) + self.assertEqual(self._tone_in_then_out(TrackingPingPong, rate), + (5, 5), rate) + + def test_the_mono_stereo_settings_are_red(self): + for rate in RATES: + result = t3_measure(MonoStereoSettingsPingPong, rate, + feedback=0.6) + self.assertFalse(result["passed"], rate) + self.assertEqual(result["sum_diff"], 0) + self.assertLess(result["repeats"][0], result["repeats"][1]) + + +class T4SpreadLaw(unittest.TestCase): + def test_the_eleven_positions(self): + for rate in RATES: + for f in (0.3, 0.99): + result = t4_measure(PingPongDelay, rate, feedback=f) + self.assertTrue(result["passed"], (rate, f, result["red"])) + self.assertLessEqual(result["worst"], 1.5) + + def test_the_short_time_and_the_quiet_click(self): + result = t4_measure(PingPongDelay, time_ms=20.0, feedback=0.85) + self.assertTrue(result["passed"], result["red"]) + result = t4_measure(PingPongDelay, level=328) + self.assertTrue(result["passed"], result["red"]) + + def test_first_side_right_is_the_swap_at_every_position(self): + result = t4_measure(PingPongDelay, swap=True) + self.assertTrue(result["passed"], result["red"]) + + def test_the_law_misses_off_the_frame(self): + # Fix round 1 (audit item 3): at the Times the node lands one + # float32 step off the frame the two-tap read leaks each pass into + # the frame beside it, and the law is not claimed there. MIDI 95 + # misses at 44.1 and 22.05 kHz; MIDI 86 and 127 hold. + miss = {44100: 289.0, 22050: 145.0} + for rate in (44100, 22050): + result = t4_measure(PingPongDelay, rate, spreads=(1.0,), + time_ms=law_time_ms(95), feedback=0.99) + self.assertFalse(result["passed"], rate) + self.assertGreater(result["worst"], miss[rate], rate) + for midi in (86, 127): + result = t4_measure(PingPongDelay, rate, spreads=(1.0,), + time_ms=law_time_ms(midi), + feedback=0.99) + self.assertTrue(result["passed"], (rate, midi)) + + def test_a_constructor_time_off_the_frame_is_outside_the_law(self): + # Fix round 2 (audit round 2, item 1): T4's exclusion is a + # condition, any Time the node lands off the frame, not a list of + # knob positions. A constructor or Sync Time reaches every whole + # frame; at 44.1 kHz 750.0227 ms (n 33 076) is the worst band, + # 1/256 of a frame off, where the old docstring said at most 1/512. + rate = 44100 + f32 = np.float32 + for n, miss, readback, worst in ((33076, 2.0 ** -8, [19922, 78], + 575.31), + (33077, 0.0, [20000, 0], 0.69)): + time_ms = n * 1000.0 / rate + effect = spied(rate=rate, time_ms=time_ms) + self.assertEqual(read_frames(effect), float(n)) + ms = f32(handed(effect, "delay_ms")) + landed = float((ms * f32(rate)) / f32(1000.0)) + self.assertEqual(abs(landed - n), miss, n) + y = render(PingPongDelay, click(n + 8), rate=rate, + time_ms=time_ms, mix=2.0, feedback=0.0, spread=0.0) + self.assertEqual([int(v) for v in y[n:n + 2, 0]], readback, n) + result = t4_measure(PingPongDelay, rate, spreads=(0.0, 0.5, 1.0), + time_ms=time_ms, feedback=0.99) + self.assertAlmostEqual(result["worst"], worst, 2) + self.assertEqual(result["passed"], miss == 0.0, n) + # The old bound, 1/512 of a frame, is red on n 33 076. + effect = spied(rate=rate, time_ms=33076 * 1000.0 / rate) + ms = f32(handed(effect, "delay_ms")) + self.assertGreater(abs(float((ms * f32(rate)) / f32(1000.0)) + - 33076), 2.0 ** -9) + + def test_the_panless_spread_is_red(self): + for rate in RATES: + result = t4_measure(PanlessPingPong, rate, spreads=(0.5, 1.0)) + self.assertFalse(result["passed"], rate) + self.assertGreaterEqual(result["worst"], 19999.0) + + +class T5DryPath(unittest.TestCase): + def test_the_defaults_stereo_and_mono(self): + for rate in RATES: + for channels in (2, 1): + result = t5_measure(PingPongDelay, rate, channels) + self.assertTrue(result["passed"], (rate, channels, result)) + + def test_the_named_cells(self): + cells = ( + ({"mix": 0.2}, None), + ({"mix": 0.2, "spread": 0.5}, None), + ({"mix": 0.2, "first_side": "right"}, None), + ({"feedback": 0.99, "tone_hz": 800.0, "cut_hz": 400.0, + "mix": 1.0}, None), + ({"time_ms": 20.0}, None), + ({}, {MIX_I: 63}), + ) + for options, macros in cells: + result = t5_measure(PingPongDelay, macros=macros, **options) + self.assertTrue(result["passed"], (options, macros, result)) + for patch in PingPongDelay.PATCHES: + result = t5_measure(PingPongDelay, patch=patch, + materials=("tones_step",)) + self.assertTrue(result["passed"], (patch, result)) + + def test_mix_0_is_a_wire_without_presence(self): + result = t5_measure(PingPongDelay, mix=0.0) + self.assertTrue(result["passed"], result) + + def test_a_dry_gain_is_red(self): + for rate in RATES: + for channels in (2, 1): + for options in ({}, {"mix": 0.2}): + result = t5_measure(DryGainPingPong, rate, channels, + **options) + self.assertFalse(result["passed"], + (rate, channels, options)) + self.assertGreater(result["differing"], 0) + + def test_never_spread_on_independent_channels(self): + # Fix round 1 (audit item 6): the kit's materials are + # channel-identical, so a dry that is mono-summed or swapped reads + # green on them. On independent L and R the window still reads 0, + # and both faults read red. + for rate in RATES: + pcm = lr_material(rate) + for options in ({}, {"mix": 0.2}, {"spread": 0.5}, + {"first_side": "right"}): + result = t5_measure(PingPongDelay, rate, pcm=pcm, **options) + self.assertTrue(result["passed"], (rate, options, result)) + for cls in (MonoInPingPong, SwapInPingPong): + for options in ({}, {"mix": 0.2}): + self.assertTrue(t5_measure(cls, rate, **options) + ["passed"], (rate, cls, options)) + result = t5_measure(cls, rate, pcm=pcm, **options) + self.assertFalse(result["passed"], (rate, cls, options)) + self.assertGreater(result["differing"], 0) + + def test_an_antiphase_source_does_not_repeat_at_spread_1(self): + # Disclosed, not a row: at Spread 1 the loop hears (L + R) / 2 + # (`input_pan` hard over), so R = -L puts nothing in the loop. The + # defaults are a byte wire on it and Mix 2 is silence; at Spread + # 0.5 it repeats. + for rate in RATES: + T = law_frames(280.0, rate) + anti = antiphase_material(rate) + y = render_pcm(PingPongDelay, anti, rate) + self.assertEqual(int(np.count_nonzero(y != anti)), 0, rate) + y = render_pcm(PingPongDelay, anti, rate, mix=2.0) + self.assertEqual(int(np.count_nonzero(y[T:])), 0, rate) + y = render_pcm(PingPongDelay, anti, rate, spread=0.5) + self.assertGreater(int(np.count_nonzero(y[T:] != anti[T:])), 0, + rate) + # Fix round 2 (audit round 2, item 5): where the source + # saturates R cannot be -L (L is -32 768), so the full-scale + # ramp leaves 1 LSB at Mix 2, not the silence the old sentence + # said: the exact-zero assertion above is red on it. + left = material("ramp_fs", rate, 1)[:, 0].astype(np.int32) + sat = np.stack([left, np.clip(-left, -32768, 32767)], + axis=1).astype(np.int16) + self.assertGreater(int(np.count_nonzero(left == -32768)), 0) + y = render_pcm(PingPongDelay, sat, rate) + self.assertEqual(int(np.count_nonzero(y != sat)), 0, rate) + y = render_pcm(PingPongDelay, sat, rate, mix=2.0) + self.assertEqual(int(np.abs(y[T:].astype(np.int32)).max()), 1, + rate) + self.assertGreater(int(np.count_nonzero(y[T:])), 0, rate) + + +# -------------------------------------------------------------------------- +# Tier 1 + + +class Tier1Fast(unittest.TestCase): + def test_mix_zero_is_a_wire_on_the_full_scale_ramp(self): + for rate in RATES: + for channels in (2, 1): + ramp = probes.ramp_fs(frames=4 * 13440, channels=channels) + src = np.array(ramp, dtype=np.int16).reshape(-1, channels) + for options in ({"mix": 0.0}, + {"mix": 0.0, "feedback": 0.99, + "time_ms": 20.0, "tone_hz": 800.0}): + y = render_pcm(PingPongDelay, src, rate, **options) + self.assertEqual(int(np.count_nonzero(y != src)), 0, + (rate, channels, options)) + + def test_silence_stays_silence(self): + for rate in RATES: + y = render(PingPongDelay, np.zeros(rate), rate, + feedback=0.99, tone_hz=800.0, cut_hz=400.0) + self.assertEqual(int(np.count_nonzero(y)), 0) + + def _round_trip(self, cls, index, first, back, rate, channels=2): + """300 Hz at 30 000 LSB for 0.5 s with the filter in, the filter + out as the input stops, 2 s of silence, the filter back in; the + largest sample after the return (Wide Bounce's Time, Feedback 0, + Mix 2).""" + on = rate // 2 + frames = on + 2 * rate + rate // 2 + x = np.zeros(frames) + x[:on] = 30000 * np.sin(2.0 * math.pi * 300.0 * np.arange(on) + / rate) + source, _ = to_source(x, channels, rate) + effect = cls(source, sample_rate=rate, feedback=0.0, mix=2.0) + effect.set_macro(index, first) + rest = (on // BLOCK + 1) * BLOCK + back_at = rest + 2 * rate // BLOCK * BLOCK + + def on_block(frame): + if frame == rest: + effect.set_macro(index, 127 if index == TONE_I else 0) + elif frame == back_at: + effect.set_macro(index, back) + + y = pull(effect, frames, channels, on_block) + return int(np.abs(y[back_at:].astype(np.int32)).max()) + + def test_a_filter_back_in_after_silence_stays_silent(self): + tone_2k = 127.0 * math.log(2000.0 / 800.0) / math.log(20.0) + for rate in RATES: + for channels in (2, 1): + self.assertEqual(self._round_trip( + PingPongDelay, TONE_I, tone_2k, 0, rate, channels), 0) + self.assertEqual(self._round_trip( + PingPongDelay, CUT_I, 127, 1, rate, channels), 0) + # Planted: a filter whose state cannot move (left in at 0.001 Hz, + # as the node's out stop behaved up to v0.6.2) plays it back. + self.assertGreater(self._round_trip( + FrozenFilterPingPong, TONE_I, tone_2k, 0, RATE), 10000) + self.assertGreater(self._round_trip( + FrozenFilterPingPong, CUT_I, 127, 1, RATE), 15000) + + def _tone_out_after_in(self, x, rate, cls=PingPongDelay, **options): + """Largest |difference| between Tone out after Tone 2 kHz was in + (on `cls`) and Tone never in, on `x`, Mix 2.""" + frames = len(x) + source, _ = to_source(x, 2, rate) + touched = cls(source, sample_rate=rate, mix=2.0, tone_hz=2000.0, + **options) + touched.set_macro(TONE_I, 127) + clean, _ = to_source(x, 2, rate) + plain = PingPongDelay(clean, sample_rate=rate, mix=2.0, **options) + a = pull(touched, frames).astype(np.int32) + b = pull(plain, frames).astype(np.int32) + return int(np.abs(a - b).max()) + + def test_tone_out_after_tone_in_is_the_filter_out(self): + # Up to audiodsp v0.6.2 the out stop after Tone had been in kept + # the low-pass tracking the tap and the Feedback stepped clear of + # the stall windows: 0 at 0.85, up to 6 LSB at 0.99 on 2 s of + # 0 dBFS noise, and at Time 20 ms up to 19 LSB during the noise and + # 37 through the tail. Since v0.6.3rc1 (#158, #157) it hands 0, and + # every one of those cells renders the same bytes as Tone never in. + # Planted: the retired tracking stop with its stepping (35 LSB + # through the 20 ms tail on the fixed node; 37 on v0.6.2's). + for rate in RATES: + frames = 2 * rate + x = np.frombuffer(probes.noise_det(frames=frames, dbfs=0.0, + channels=1), + dtype=np.int16)[:frames].astype(float) + for feedback in (0.85, 0.99): + self.assertEqual(self._tone_out_after_in( + x, rate, feedback=feedback), 0, (rate, feedback)) + self.assertGreater(self._tone_out_after_in( + x, rate, cls=TrackingPingPong, feedback=0.99), 1, rate) + n = 2 * RATE + probe = PingPongDelay(silence_src(64), sample_rate=RATE, + time_ms=20.0, feedback=0.99) + frames = n + probe.tail_samples + law_frames(20.0, RATE) + probe.deinit() + x = np.zeros(frames) + x[:n] = np.frombuffer(probes.noise_det(frames=n, dbfs=0.0, + channels=1), + dtype=np.int16)[:n] + for cls, worst in ((PingPongDelay, 0), (TrackingPingPong, 35)): + source, _ = to_source(x, 2, RATE) + touched = cls(source, sample_rate=RATE, mix=2.0, time_ms=20.0, + feedback=0.99, tone_hz=2000.0) + touched.set_macro(TONE_I, 127) + clean, _ = to_source(x, 2, RATE) + plain = PingPongDelay(clean, sample_rate=RATE, mix=2.0, + time_ms=20.0, feedback=0.99) + d = np.abs(pull(touched, frames).astype(np.int32) + - pull(plain, frames).astype(np.int32)).max() + self.assertEqual(int(d), worst, cls.__name__) + + def test_cut_out_after_cut_in_is_the_filter_out(self): + # Up to audiodsp v0.6.2 Cut's bottom stop stayed in at 20 Hz once + # Cut had been in. Since v0.6.3rc1 (#159) it hands 0, and 2 s of + # 0 dBFS noise at Feedback 0.99 renders the same bytes as Cut never + # in, at three rates. Planted: the retired held corner. + for rate in RATES: + frames = 2 * rate + x = np.frombuffer(probes.noise_det(frames=frames, dbfs=0.0, + channels=1), + dtype=np.int16)[:frames].astype(float) + for cls, differs in ((PingPongDelay, False), + (HeldCutPingPong, True)): + source, _ = to_source(x, 2, rate) + touched = cls(source, sample_rate=rate, mix=2.0, + feedback=0.99, cut_hz=400.0) + touched.set_macro(CUT_I, 0) + clean, _ = to_source(x, 2, rate) + plain = PingPongDelay(clean, sample_rate=rate, mix=2.0, + feedback=0.99) + d = int(np.abs(pull(touched, frames).astype(np.int32) + - pull(plain, frames).astype(np.int32)).max()) + self.assertEqual(d > 0, differs, (rate, cls.__name__, d)) + + def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): + # Feedback 0.5 with Repeat Tone 800 Hz in is a stall centre (k = 1): + # up to audiodsp v0.6.2 the node could hold 1 LSB there for ever and + # the class stepped the Feedback clear. Since v0.6.3rc1 (#157) 0.5 + # is handed as set, and a 2 LSB DC for 1 s ends inside the bound. + for rate in RATES: + frames = 4 * rate + x = np.zeros(frames) + x[:rate] = 2.0 + source, _ = to_source(x, 2, rate) + effect = PingPongDelay(source, sample_rate=rate, feedback=0.5, + mix=2.0, time_ms=100.0, tone_hz=800.0) + self.assertEqual(effect._feedback, 0.5) + declared = effect.tail_samples + y = pull(effect, frames) + nonzero = np.flatnonzero(y.any(axis=1)) + self.assertGreater(len(nonzero), 0, rate) + last = int(nonzero[-1]) + self.assertLess(last, frames - 1, rate) + self.assertLessEqual(last - rate + 1, declared, rate) + stepped = SteppedPingPong(silence_src(64), sample_rate=RATE, + feedback=0.5, tone_hz=800.0) + self.assertNotEqual(stepped._feedback, 0.5) + self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + + def _in_then_out(self, cls, which, rate): + """(samples differing, largest difference) from the fresh noise on: + 1 s of 0 dBFS noise with Tone 2 kHz or Cut 400 Hz really in, the + filter out as the noise stops, silence past `tail_samples`, then + 1 s of fresh noise, against an instance whose filter was never in. + Time 20 ms, Feedback 0.5 (a stall window centre), Mix 2.""" + loud = rate // BLOCK * BLOCK + bound = PingPongDelay(silence_src(64, rate=rate), sample_rate=rate, + time_ms=20.0, feedback=0.5).tail_samples + fresh = (loud + bound + rate // 4) // BLOCK * BLOCK + frames = fresh + rate + bound + rate // 4 + x = np.zeros(frames) + x[:loud] = np.random.RandomState(12345).uniform(-32767, 32767, loud) + x[fresh:fresh + rate] = np.random.RandomState(777).uniform( + -32767, 32767, rate) + ctor = {"time_ms": 20.0, "feedback": 0.5, "mix": 2.0} + filt = {"tone_hz": 2000.0} if which == TONE_I else {"cut_hz": 400.0} + source, _ = to_source(x, 2, rate) + touched = cls(source, sample_rate=rate, **dict(ctor, **filt)) + + def move(frame): + if frame == loud: + touched.set_macro(which, 127 if which == TONE_I else 0) + + a = pull(touched, frames, 2, move).astype(np.int32) + clean, _ = to_source(x, 2, rate) + b = pull(PingPongDelay(clean, sample_rate=rate, **ctor), + frames).astype(np.int32) + d = np.abs(a[fresh:] - b[fresh:]) + return int(np.count_nonzero(d)), int(d.max()) + + def test_a_filter_really_in_then_out_is_out(self): + # The out-stop tests above hand the out stop before any audio. + # Here the filter plays 1 s of noise first: once those repeats have + # died, the instance renders the same bytes as one whose filter was + # never in, at three rates. Planted: the retired cures, the Tone + # stop tracking with the Feedback stepped, and Cut held at 20 Hz. + for rate in RATES: + for which in (TONE_I, CUT_I): + self.assertEqual(self._in_then_out(PingPongDelay, which, + rate), (0, 0), + (rate, which)) + self.assertGreater(self._in_then_out(TrackingPingPong, TONE_I, + rate)[0], 0, rate) + self.assertGreater(self._in_then_out(HeldCutPingPong, CUT_I, + rate)[0], 0, rate) + + def test_the_tail_after_cut_in_then_out_is_inside_the_bound(self): + # Since audiodsp v0.6.3rc1 the bound is finite once Cut is out, + # whatever came before. Full-scale noise with Cut in (400 Hz and + # MIDI 1), Cut out as it stops: the tail ends inside the bound read + # after the move. Planted: the retired held Cut (no bound) and the + # bound one lap short. + + class OneLapShort(PingPongDelay): + NAME = 'PingPongDelay' + + def _tail_bound(self): + laps = pp.laps_to_zero(self._feedback, 0.0) + return int((laps - 1) * (self._reach + 1)) + + def cell(cls, cut_midi, feedback, spread): + loud = RATE // 2 // BLOCK * BLOCK + bound = PingPongDelay(silence_src(64), sample_rate=RATE, + time_ms=20.0, feedback=feedback, + spread=spread).tail_samples + frames = loud + bound + RATE // 4 + x = np.zeros(frames) + x[:loud] = np.random.RandomState(4242).uniform(-32767, 32767, + loud) + source, _ = to_source(x, 2) + effect = cls(source, sample_rate=RATE, time_ms=20.0, + feedback=feedback, spread=spread, mix=2.0) + effect.set_macro(CUT_I, cut_midi) + seen = {} + + def move(frame): + if frame == loud: + effect.set_macro(CUT_I, 0) + seen["declared"] = effect.tail_samples + + y = pull(effect, frames, 2, move) + last = int(np.flatnonzero(y.any(axis=1))[-1]) - loud + 1 + return seen["declared"], last + + for cut_midi in (127, 1): + for feedback in (0.45, 0.85): + for spread in (1.0, 0.0): + declared, tail = cell(PingPongDelay, cut_midi, feedback, + spread) + self.assertIsNotNone(declared) + self.assertLessEqual(tail, declared, + (cut_midi, feedback, spread)) + self.assertGreater(tail, declared // 2) + self.assertIsNone(cell(HeldCutPingPong, 127, 0.45, 1.0)[0]) + declared, tail = cell(OneLapShort, 127, 0.45, 1.0) + self.assertGreater(tail, declared) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + on = 200 * RATE // 1000 + for options in ({}, {"patch": 0}, {"patch": 4}, {"patch": 5}, + {"patch": 6}): + probe = PingPongDelay(silence_src(64), sample_rate=RATE, + **options) + bound = probe.tail_samples + probe.deinit() + frames = on + bound + 1024 + x = np.zeros(frames) + x[:on] = 16384 * np.sin(2.0 * math.pi * 1000.0 + * np.arange(on) / RATE) + y = render(PingPongDelay, x, **options) + nonzero = np.flatnonzero(y.any(axis=1)) + last = int(nonzero[-1]) - on + 1 if len(nonzero) else 0 + self.assertLessEqual(last, bound, options) + self.assertGreater(last, bound // 2, options) + + def test_a_full_scale_fill_at_every_spread(self): + T = law_frames(20.0, RATE) + fill = 4 * T + for f in (0.45, 0.85): + for spread in (1.0, 0.5, 0.0): + for mix in (0.3, 2.0): + probe = PingPongDelay(silence_src(64), sample_rate=RATE, + time_ms=20.0, feedback=f, + spread=spread, mix=mix) + bound = probe.tail_samples + probe.deinit() + x = np.zeros(fill + bound + 1024) + x[:fill] = 32767 + y = render(PingPongDelay, x, time_ms=20.0, feedback=f, + spread=spread, mix=mix) + last = int(np.flatnonzero(y.any(axis=1))[-1]) - fill + 1 + self.assertLessEqual(last, bound, (f, spread, mix)) + + def test_a_falling_walk_keeps_the_old_time_in_the_tail(self): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + time_ms=1000.0) + long_tail = effect.tail_samples + effect.set_macro(TIME_I, 0) + self.assertEqual(effect.tail_samples, long_tail) + effect.reset() + self.assertEqual(effect.tail_samples, 190064) + + def test_reset_silences_a_full_line(self): + # A line full of repeats, reset, then silence in: silence out. + frames = RATE + x = np.concatenate([16384 * np.sin(2.0 * math.pi * 440.0 + * np.arange(frames) / RATE), + np.zeros(3 * frames)]) + source, _ = to_source(x, 2) + effect = PingPongDelay(source, sample_rate=RATE, feedback=0.85) + stop = (frames // BLOCK + 1) * BLOCK + seen = [] + + def on_block(frame): + if frame == stop: + effect.reset() + seen.append(frame) + + y = pull(effect, 4 * frames, 2, on_block) + self.assertEqual(seen, [stop]) + self.assertEqual(int(np.count_nonzero(y[stop:])), 0) + + def test_deinit_leaves_the_source(self): + source, _ = to_source(8000 * np.sin(2 * math.pi * 440.0 + * np.arange(1024) / RATE)) + effect = PingPongDelay(source, sample_rate=RATE) + pull(effect, 256) + effect.deinit() + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + with self.assertRaises(RuntimeError): + effect.tail_samples + + def test_click_delay_is_zero(self): + for rate in (48000, 44100): + y = render(PingPongDelay, click(2048), rate) + self.assertEqual(int(np.argmax(np.abs(y[:, 0]))), 0) + self.assertEqual(int(y[0, 0]), 20000) + + def test_the_transport_is_read_only_with_sync_on(self): + calls = [] + + def host(): + calls.append(1) + return (True, 0.0, 120.0, 4, 4) + + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + transport=host) + self.assertEqual(calls, []) + pull(effect, 4 * BLOCK) + self.assertEqual(calls, []) + effect.set_macro(SYNC_I, 127) + self.assertEqual(len(calls), 1) + pull(effect, 4 * BLOCK) + self.assertEqual(len(calls), 1) + + +def railed_samples(cls, dbfs, seconds, values=None, **options): + """Output samples on the int16 rail that the source did not put there: + the kit's `noise_det` at `dbfs` peak (or `values`), 48 kHz stereo.""" + frames = int(seconds * RATE) + if values is None: + values = np.frombuffer(probes.noise_det(frames=frames, dbfs=dbfs, + channels=1), + dtype=np.int16)[:frames].astype(float) + x = np.round(values).astype(np.int32) + y = render(cls, values, RATE, 2, **options).astype(np.int32) + source = ((x >= 32767) | (x <= -32768))[:, None] + out = (y >= 32767) | (y <= -32768) + return int(np.count_nonzero(out & ~source)) + + +class InputCeiling(unittest.TestCase): + """Fix round 1 (audit-3 ruling (o)): the docstring's ceiling on + `noise_det`, 48 kHz stereo, over 20 s. The defaults are clean at + -3 dBFS peak and patch 4 (Spread 0, the first shipped patch to rail) + at -3.1; each is red 1 dB over.""" + + SECONDS = 20.0 + + def test_the_stated_ceiling_is_clean_and_1_db_over_is_not(self): + for options, ceiling in (({}, -3.0), ({"patch": 4}, -3.1)): + self.assertEqual(railed_samples(PingPongDelay, ceiling, + self.SECONDS, **options), 0, + options) + self.assertGreater(railed_samples(PingPongDelay, ceiling + 1.0, + self.SECONDS, **options), 0, + options) + + def test_the_any_material_bound(self): + # With Repeat Cut out, a DC one LSB under floor(32767 (1 - Mix)) + # never reaches the rail at Feedback 0.99, at any Spread; at + # floor(32767 (1 - Mix)) itself the sum can round onto 32767. + for mix in (0.5, 0.3): + edge = int(math.floor(32767 * (1.0 - mix))) + for spread in (1.0, 0.0): + for level, reaches in ((edge - 1, False), + (edge, mix == 0.5)): + y = render(PingPongDelay, [level] * (RATE // 2), RATE, 2, + time_ms=12.5, feedback=0.99, mix=mix, + spread=spread) + self.assertEqual(bool(np.any(y >= 32767)), reaches, + (mix, spread, level)) + + def test_repeat_cut_needs_more_room(self): + # The loop high-pass overshoots a square's edges: at the defaults' + # Mix a 40 Hz square wave rails at -3.1 dBFS with Cut in, and not + # with Cut out. + t = np.arange(int(4 * RATE)) / RATE + wave_ = np.sign(np.sin(2.0 * math.pi * 40.0 * t + 1e-9)) + loud = 32767.0 * 10.0 ** (-3.1 / 20.0) * wave_ + for cut_hz in (40.0, 400.0): + self.assertGreater(railed_samples(PingPongDelay, None, 4.0, + values=loud, cut_hz=cut_hz), 0, + cut_hz) + self.assertEqual(railed_samples(PingPongDelay, None, 4.0, + values=loud), 0) + + def test_the_cut_in_ceiling(self): + # Fix round 2 (audit round 2, item 2): "clean from -4 dBFS" was the + # 40 Hz square at the defaults only. The worst cell tried, a 5 Hz + # square with Cut at 40 Hz at Feedback 0.99, is clean at the stated + # -5.5 dBFS and rails 1 dB over; at -4 dBFS it rails, so the old + # sentence is red on it. Under the arithmetic bound, + # floor(32767 - 65535 Mix) - 1 = 13 105 LSB at Mix 0.3, it and the + # defaults' 5 Hz square at Cut MIDI 1 are clean. + t = np.arange(int(4 * RATE)) / RATE + wave_ = np.sign(np.sin(2.0 * math.pi * 5.0 * t + 1e-9)) + + def railed(dbfs=None, peak=None, macros=None, **options): + level = peak if peak is not None else 32767.0 * 10.0 ** ( + dbfs / 20.0) + values = level * wave_ + x = np.round(values).astype(np.int32) + y = render(PingPongDelay, values, RATE, 2, macros=macros, + **options).astype(np.int32) + source = ((x >= 32767) | (x <= -32768))[:, None] + return int(np.count_nonzero(((y >= 32767) | (y <= -32768)) + & ~source)) + + corner = {"cut_hz": 40.0, "feedback": 0.99} + self.assertEqual(railed(-5.5, **corner), 0) + self.assertGreater(railed(-4.5, **corner), 0) + self.assertGreater(railed(-4.0, **corner), 0) + edge = int(math.floor(32767 - 65535 * 0.3)) - 1 + self.assertEqual(edge, 13105) + self.assertEqual(railed(peak=edge, **corner), 0) + self.assertEqual(railed(peak=edge, macros={CUT_I: 1}), 0) + self.assertGreater(railed(-4.0, macros={CUT_I: 1}), 0) + self.assertEqual(railed(-4.5, macros={CUT_I: 1}), 0) + + +# -------------------------------------------------------------------------- +# The gate's two checks on every fault + + +class FaultsAreUnreachable(unittest.TestCase): + """Every fault's reachability walk, reading only what the node is + handed (or, for the dry gain, a copy's dry path), at 48, 44.1 and + 22.05 kHz on the kit's grid (17 positions per macro) plus the seven + patches, and the handed-value walks on the fine grid (509) too.""" + + CHECKED = 9 * 17 + 7 + CHECKED_FINE = 9 * len(FINE) + 7 + + def test_every_fault_is_off_the_surface(self): + for name, faulted, reading, channels in REACH_WALKS: + for rate in RATES: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, channels) + self.assertEqual(result["checked"], self.CHECKED) + + def test_every_fault_is_off_the_fine_grid(self): + for name, faulted, reading, channels in REACH_WALKS: + for rate in RATES: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, channels, + grid=FINE) + self.assertEqual(result["checked"], self.CHECKED_FINE) + + def test_the_dry_gain_is_off_the_surface(self): + for rate in RATES: + for channels in (2, 1): + result = reach(DryGainPingPong, read_dry_gain, rate, + channels) + self.assertEqual(result["checked"], self.CHECKED) + + def test_the_spread_dry_is_off_the_surface(self): + # Fix round 1: T5's "never spread" faults, read as the right input + # reaching the left output before the first repeat. + for cls in (MonoInPingPong, SwapInPingPong): + for rate in RATES: + with self.subTest(fault=cls.__name__, rate=rate): + result = reach(cls, read_dry_crosstalk, rate) + self.assertEqual(result["checked"], self.CHECKED) + self.assertEqual(result["clean"], 0) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire, beside + a control on the real class that must pass.""" + + def test_every_row_is_red_on_a_wire(self): + for name, measure in (("T1", t1_default), ("T2", t2_default), + ("T3", t3_default), ("T4", t4_default), + ("T5", t5_default), + ("T5 independent L/R", t5_lr_default)): + with self.subTest(row=name): + result = kit_faults.null_build_red( + PingPongDelay, measure, label="PingPongDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + + +# -------------------------------------------------------------------------- +# The trial of the second process: the tests the docstring's claims and the +# re-audit's restatements needed that the rows above did not already give + + +class HeldFloorPingPong(PingPongDelay): + """The pre-v0.6.3rc3 cross-feed stall (audiodsp#170), planted on the + output: once a channel has read `held` LSB it never reads less, so the + tail sits on a DC of `held` for ever.""" + + NAME = 'PingPongDelay' + held = 1 + + def _build(self, *arguments, **options): + PingPongDelay._build(self, *arguments, **options) + self._output = _HoldFloor(self._delay, type(self).held) + + +class _HoldFloor(kit_faults._Node): + def __init__(self, source, held): + kit_faults._Node.__init__(self, source) + self.held = held + self._sign = [0] * source.channel_count + + def _process(self, frames): + ch = self.channel_count + x = frames.reshape(-1, ch).copy() + for c in range(ch): + col = x[:, c] + if not self._sign[c]: + hit = np.flatnonzero(np.abs(col) == self.held) + if len(hit): + self._sign[c] = 1 if col[hit[0]] > 0 else -1 + tail = col[hit[0]:] + low = np.abs(tail) < self.held + tail[low] = self._sign[c] * self.held + continue + low = np.abs(col) < self.held + col[low] = self._sign[c] * self.held + return x.reshape(-1) + + +def _midtail(ctor, moves, at=2048, rate=RATE): + """A full-scale DC burst of 50 ms in both channels at Mix 2, then + silence; `moves` made `at` frames into the silence. Returns + (`tail_samples` read as the input stops, `tail_samples` read just after + the moves, frames from the moves to the output's last non-zero frame). + The render runs to the larger of the two bounds plus a second.""" + burst = int(0.05 * rate) // BLOCK * BLOCK + move = burst + at + options = dict(ctor, mix=2.0) + probe = PingPongDelay(silence_src(64, rate=rate), sample_rate=rate, + **options) + for index, value in moves: + probe.set_macro(index, value) + longest = max(probe.tail_samples, 13454) + rate + probe.deinit() + frames = move + longest + BLOCK + values = np.zeros(frames) + values[:burst] = 32767 + source, _ = to_source(values, 2, rate) + effect = PingPongDelay(source, sample_rate=rate, **options) + seen = {} + + def on_block(frame): + if frame == burst: + seen["before"] = effect.tail_samples + if frame == move: + for index, value in moves: + effect.set_macro(index, value) + seen["after"] = effect.tail_samples + + y = pull(effect, frames, 2, on_block) + effect.deinit() + nonzero = np.flatnonzero(y[move:].any(axis=1)) + last = int(nonzero[-1]) + 1 if len(nonzero) else 0 + return seen["before"], seen["after"], last + + +class TrialClaims(unittest.TestCase): + def test_the_knob_spans(self): + effect = PingPongDelay(silence_src(64), sample_rate=RATE) + for index, low, high in ((TIME_I, 20.0, 1000.0), + (FEEDBACK_I, 0.0, 0.99), + (MIX_I, 0.0, 2.0), (SPREAD_I, 0.0, 1.0)): + effect.set_macro(index, 0) + self.assertAlmostEqual(effect._value(index), low, places=9) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect._value(index), high, places=9) + effect.set_macro(FEEDBACK_I, 127) + self.assertEqual(effect._feedback, 0.99) + effect.set_macro(FEEDBACK_I, 0) + self.assertEqual(effect._feedback, 0.0) + + def test_mix_2_is_the_repeats_alone(self): + # A click at Mix 2: nothing until the repeat, Time later. + T = law_frames(100.0, RATE) + y = render(PingPongDelay, click(2 * T), mix=2.0, time_ms=100.0, + feedback=0.0) + self.assertEqual(int(np.count_nonzero(y[:T])), 0) + self.assertEqual(int(y[T, 0]), 20000) + + HZ = 997.0 + LEVEL = 8000.0 + + def _bar(self): + """1.5 x the tone's own largest step.""" + t = np.arange(4096) / RATE + x = np.round(self.LEVEL * np.sin(2.0 * math.pi * self.HZ * t)) + return 1.5 * float(np.max(np.abs(np.diff(x)))) + + def _mix_moves(self, steps): + """The tone at Time 987 frames (the repeat half a cycle off the + dry), Feedback 0, Mix from MIDI 0 to 127 in `steps` equal moves, one + a block from frame 20 480; the largest step in the output from + there.""" + frames = 20480 + (steps + 8) * BLOCK + t = np.arange(frames) / RATE + values = self.LEVEL * np.sin(2.0 * math.pi * self.HZ * t) + source, _ = to_source(values) + effect = PingPongDelay(source, sample_rate=RATE, + time_ms=987 * 1000.0 / RATE, feedback=0.0, + mix=0.0) + + def move(frame): + k = (frame - 20480) // BLOCK + 1 + if frame >= 20480 and k <= steps: + effect.set_macro(MIX_I, 127.0 * k / steps) + + y = pull(effect, frames, 2, move)[:, 0].astype(np.int32) + effect.deinit() + return float(np.max(np.abs(np.diff(y[20479:])))) + + def test_a_jump_steps_and_small_steps_do_not(self): + # The family limit (audiocomponents#117), the matrix's E4-m2=127 + # P5 cells: Mix 0 -> 2 in one move steps the output; the same move + # in 127 steps from the host, one a block, stays under the bar. + self.assertGreater(self._mix_moves(1), 4.0 * self._bar()) + self.assertLess(self._mix_moves(127), self._bar()) + + def _tail_across_a_stop(self, stop): + """A 50 ms tone burst into Time 100 ms, Feedback 0.5, Mix 2, then + silence; after 24 pulls the source hands empty buffers for `stop` + pulls, then silence again. Returns (the bytes handed while it was + stopped, the 40 blocks pulled after it came back).""" + import lifecycle + tone = np.round(self.LEVEL * np.sin( + 2.0 * math.pi * self.HZ * np.arange(2400) / RATE)).astype(int) + burst = array("h") + for v in tone: + burst.extend((int(v), int(v))) + feed = lifecycle.Feed(burst, RATE, 2, "256", False) + effect = PingPongDelay(feed.port, sample_rate=RATE, time_ms=100.0, + feedback=0.5, mix=2.0) + for _ in range(24): + audiocore.get_buffer(effect.output) + feed.point(feed.empty) + stopped = bytearray() + for _ in range(stop): + stopped.extend(bytes(audiocore.get_buffer(effect.output)[1])) + feed.point(feed.sil) + after = bytearray() + while len(after) < 40 * BLOCK * 4: + after.extend(bytes(audiocore.get_buffer(effect.output)[1])) + effect.deinit() + return bytes(stopped), bytes(after[:40 * BLOCK * 4]) + + def test_the_tail_waits_for_the_source(self): + # The family limit (audiodsp#180): a source that hands empty + # buffers stops the tail; when it feeds again the tail carries on + # where it was, as if the stop had not happened. + stopped, after = self._tail_across_a_stop(30) + self.assertEqual(stopped.strip(b"\x00"), b"") + self.assertGreater(max(abs(v) for v in array("h", after)), 1000) + self.assertEqual(after, self._tail_across_a_stop(0)[1]) + + def test_tail_samples_holds_for_the_settings_as_they_stand(self): + # Re-audit 1's first restatement: the bound counts from when it is + # read, for the settings as they stand. Each row moves a setting + # 2 048 frames into the silence and reads `tail_samples` after it: + # the output is exact zero within that many frames of the move. + rows = (({"time_ms": 20.0}, [(FEEDBACK_I, 127)]), + ({"time_ms": 20.0, "feedback": 0.99}, [(FEEDBACK_I, 0)]), + ({"time_ms": 20.0, "feedback": 0.99}, [(FEEDBACK_I, 64)]), + ({"time_ms": 20.0, "feedback": 0.85}, [(TIME_I, 30)]), + ({"time_ms": 60.0, "feedback": 0.85}, [(TIME_I, 0)]), + ({"time_ms": 20.0, "feedback": 0.85}, [(TONE_I, 0)]), + ({"time_ms": 20.0, "feedback": 0.85, "tone_hz": 800.0}, + [(TONE_I, 127)]), + ({"time_ms": 20.0, "feedback": 0.85}, [(SPREAD_I, 39)]), + ({"time_ms": 20.0, "feedback": 0.85, "cut_hz": 400.0}, + [(CUT_I, 0)])) + for ctor, moves in rows: + before, after, last = _midtail(ctor, moves) + self.assertGreater(last, 0, (ctor, moves)) + self.assertIsNotNone(after, (ctor, moves)) + self.assertLessEqual(last, after, (ctor, moves, before, after)) + # The value read before a Feedback move up does not hold after it. + before, after, last = _midtail({"time_ms": 20.0}, + [(FEEDBACK_I, 127)]) + self.assertGreater(last, before) + self.assertGreater(after, before) + + #: (typed feedback, Spread, k): where the pre-rc3 node's float32 + #: cross-feed sum handed k back on both lanes for ever (audiodsp#170). + #: The first is the re-audit's typed cell; the rest are the cells the + #: fixer's probe (`trial/PingPongDelay/stall_probe.py` in the workspace) + #: finds held under every order of the sum, one per Feedback. No + #: Feedback knob position is among them. + CROSS_FEED_CELLS = ((0.9899999, 39.0 / 127.0, 50), + (0.9899999, 2.0 / 127.0, 50), + (0.9666666, 37.0 / 127.0, 15), + (0.9827586, 38.0 / 127.0, 29), + (0.9833333, 43.0 / 127.0, 30), + (0.9838709, 42.0 / 127.0, 31)) + + def _cross_feed_tail(self, cls, feedback, spread, k): + """Stereo, 48 kHz, Time 20 ms, Mix 2: a DC of 2k + 2 LSB in both + channels for four laps, then silence to `tail_samples` plus a lap. + Returns (declared, the last non-zero frame after the fill, the + largest |sample| past `tail_samples`).""" + options = {"time_ms": 20.0, "feedback": feedback, "mix": 2.0, + "spread": spread} + probe = PingPongDelay(silence_src(64), sample_rate=RATE, **options) + declared = probe.tail_samples + probe.deinit() + fill = 4 * 960 // BLOCK * BLOCK + BLOCK + values = [2 * k + 2] * fill + [0] * (declared + 960 + BLOCK) + source, _ = to_source(values) + if cls is not PingPongDelay: + # never on the class itself: the attribute would outlive this + # test and read as a public name to every later one + cls.held = k + effect = cls(source, sample_rate=RATE, **options) + out = pull(effect, len(values), 2).astype(np.int64) + effect.deinit() + after = out[fill:] + nonzero = np.flatnonzero(after.any(axis=1)) + last = 0 if nonzero.size == 0 else int(nonzero[-1]) + 1 + return declared, last, int(np.max(np.abs(after[declared:]))) + + def test_the_cross_feed_stall_cells_reach_zero(self): + # Re-audit 1's third item: this class hands Spread as `cross_feed`, + # and up to audiodsp v0.6.3rc2 these typed cells held k LSB on both + # lanes for ever. At v0.6.3rc3 the node ends them (#170): the tail + # is exact zero inside `tail_samples`. Planted: a tail that holds a + # DC of k once it gets there, red at every cell. + for feedback, spread, k in self.CROSS_FEED_CELLS: + key = (feedback, spread) + effect = PingPongDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, spread=spread) + self.assertEqual(float(np.float32(effect._feedback)), + float(np.float32(feedback)), key) + effect.deinit() + declared, last, past = self._cross_feed_tail( + PingPongDelay, feedback, spread, k) + self.assertGreater(last, 0, key) + self.assertLessEqual(last, declared, key) + self.assertEqual(past, 0, key) + declared, last, past = self._cross_feed_tail( + HeldFloorPingPong, feedback, spread, k) + self.assertGreater(last, declared, key) + self.assertEqual(past, k, key) + + def test_reset_returns_to_patch_0(self): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, patch=5) + effect.set_macro(SPREAD_I, 10) + effect.reset() + self.assertEqual(effect.patch_index, 0) + for index, midi in enumerate(PingPongDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), midi, delta=0.6) + + def test_construction_needs_audioecho(self): + saved = sys.modules.get("audioecho", False) + saved_attr = pp.audioecho + sys.modules["audioecho"] = None + pp.audioecho = None + try: + with self.assertRaises(ImportError): + PingPongDelay(silence_src(512), sample_rate=RATE) + finally: + pp.audioecho = saved_attr + if saved is False: + del sys.modules["audioecho"] + else: + sys.modules["audioecho"] = saved + + +# -------------------------------------------------------------------------- +# The docstring's claims + +#: Every claim the class docstring makes, word for word, and the tests that +#: assert it ("Class.test_name", in this file). +CLAIMS = ( + ("Two delay lines crossed into each other: the repeats bounce between " + "the speakers.", + ("T1RepeatsAlternate.test_the_three_feedbacks_at_three_rates",)), + ("Your dry signal passes untouched on both sides, and the repeats come " + "back one side and then the other, all the way down.", + ("T5DryPath.test_the_defaults_stereo_and_mono", + "T1RepeatsAlternate.test_the_three_feedbacks_at_three_rates")), + ("The first repeat comes back Time later on the side First Side names, " + "the next Time after that on the other side, and they keep bouncing, " + "each a Feedback's worth quieter than the last.", + ("T1RepeatsAlternate.test_the_three_feedbacks_at_three_rates", + "T1RepeatsAlternate.test_the_time_stops_and_the_patch_cells", + "T2OneDecayRatio.test_the_constructor_feedbacks_at_the_named_times", + "T4SpreadLaw.test_first_side_right_is_the_swap_at_every_position")), + ("Time runs from 20 to 1000 ms and Feedback from 0 to 0.99.", + ("TrialClaims.test_the_knob_spans", + "TheSurface.test_time_lands_on_a_whole_frame")), + ("Mix is the echo level: the dry stays at unity up to Mix 1, Mix 2 is " + "the repeats alone, and at Mix 0 the output is the input.", + ("T5DryPath.test_the_named_cells", + "TrialClaims.test_mix_2_is_the_repeats_alone", + "Tier1Fast.test_mix_zero_is_a_wire_on_the_full_scale_ramp")), + ("Spread moves between two plain delays with the same repeats on both " + "sides, at 0, and the full bounce, at 1.", + ("T4SpreadLaw.test_the_eleven_positions", + "T3MonoSum.test_spread_0_is_twice_the_reference")), + ("At Spread 1 each repeat is on one side only, and the other side is " + "exact zero.", + ("T1RepeatsAlternate.test_the_three_feedbacks_at_three_rates",)), + ("With Sync on, Time is Division of the host's beat, up to 1000 ms; " + "with no host tempo, Time stays where the knob is.", + ("TheSurface.test_sync_quantises_time_and_clamps",)), + ("The class reads the host's transport only while Sync is on, and then " + "only when a control moves or a patch loads, never while it plays.", + ("Tier1Fast.test_the_transport_is_read_only_with_sync_on", + "TheSurface.test_sync_patches_follow_the_beat")), + ("Repeat Tone is a low-pass and Repeat Cut a high-pass inside the " + "loop, so each bounce is a little darker or thinner than the last.", + ("T3MonoSum.test_the_loop_filters_read_exact",)), + ("Repeat Tone's top stop and Repeat Cut's bottom stop take them out, " + "and a filter taken out is out.", + ("TheSurface.test_the_filter_stops", + "Tier1Fast.test_a_filter_really_in_then_out_is_out")), + ("At 22.05 kHz the top positions of Repeat Tone sit on one clamp below " + "Nyquist and sound the same.", + ("TheSurface.test_repeat_tone_clamps_at_22k",)), + ("Turning Time walks the repeats to the new Time, bending their pitch, " + "instead of clicking.", + ("TheSurface.test_the_walk_bends_by_the_law_and_the_jump_does_not",)), + ("At Spread 1 the loop hears the average of the two input channels, so " + "what differs between them never repeats.", + ("T5DryPath.test_an_antiphase_source_does_not_repeat_at_spread_1",)), + ("The dry is always each channel's own signal, never swapped or " + "summed.", + ("T5DryPath.test_never_spread_on_independent_channels",)), + ("A one-channel source gets an ordinary feedback delay at the same " + "Time, Feedback and Mix, and Spread and First Side do nothing there.", + ("T3MonoSum.test_the_named_pairs_at_three_rates", + "TheSurface.test_spread_and_first_side_hand_the_pair")), + ("At 48 kHz every Time position lands on the nearest whole frame.", + ("TheSurface.test_where_the_node_lands_the_handed_frame",)), + ("At 44.1 and 22.05 kHz the node lands some positions a fraction of a " + "frame off, and a sliver of each repeat falls on the frame beside it.", + ("TheSurface.test_where_the_node_lands_the_handed_frame", + "TheSurface.test_an_off_frame_time_leaks_into_the_next_frame")), + ("The dry sits at unity and the repeats add to it, so a hot input can " + "reach the int16 rail.", + ("InputCeiling.test_the_stated_ceiling_is_clean_and_1_db_over_is_not",)), + ("With Repeat Cut out and Mix below 1, an input that peaks at or below " + "floor(32767 (1 - Mix)) - 1 cannot reach the rail, at any Time, " + "Feedback or Spread.", + ("InputCeiling.test_the_any_material_bound",)), + ("Repeat Cut's high-pass overshoots, so with it in leave more room.", + ("InputCeiling.test_repeat_cut_needs_more_room", + "InputCeiling.test_the_cut_in_ceiling")), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + ("TrialClaims.test_a_jump_steps_and_small_steps_do_not",)), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", + ("TrialClaims.test_the_tail_waits_for_the_source",)), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", + ("TrialClaims.test_the_tail_waits_for_the_source",)), + ("A click comes out on the frame it went in: there is no latency.", + ("Tier1Fast.test_click_delay_is_zero", + "TheSurface.test_macros_patches_tier_latency")), + ("`tail_samples` is an upper bound on how many frames the output takes " + "to reach exact zero, counted from when your input stops or from when " + "you read it if that is later, for the settings as they stand when " + "you read it.", + ("TrialClaims.test_tail_samples_holds_for_the_settings_as_they_stand", + "TrialClaims.test_the_cross_feed_stall_cells_reach_zero", + "Tier1Fast.test_the_tail_reaches_exact_zero_inside_tail_samples", + "Tier1Fast.test_a_full_scale_fill_at_every_spread", + "Tier1Fast.test_the_stall_cell_reaches_zero_at_the_feedback_set", + "Tier1Fast.test_the_tail_after_cut_in_then_out_is_inside_the_bound", + "Tier1Fast.test_a_falling_walk_keeps_the_old_time_in_the_tail")), + ("With Repeat Cut in circuit `tail_samples` is `None`: the class gives " + "no bound there.", + ("TheSurface.test_tail_samples_follows_time_feedback_and_tone", + "TheSurface.test_the_filter_stops")), + ("Pass a lower `max_time_ms` for a shorter line: Time then stops at " + "that ceiling, and `get_macro(0)` shows where it stopped.", + ("TheSurface.test_a_lowered_ceiling_clamps_visibly",)), + ("A constructor value outside a knob's span clamps to the nearer stop, " + "a `tone_hz` or `cut_hz` of 0 or less is that filter out, and NaN " + "takes the option's default.", + ("TheSurface.test_constructor_clamps_and_nan",)), + ("`reset()` empties the line and returns to patch 0.", + ("Tier1Fast.test_reset_silences_a_full_line", + "TrialClaims.test_reset_returns_to_patch_0")), + ("The class needs audiodsp's `audioecho`, and on a board without it " + "construction raises `ImportError`.", + ("TrialClaims.test_construction_needs_audioecho",)), +) + +FAMILY_HEADING = "**Limits shared by the family.**" + + +def _flat(text): + return " ".join(text.split()) + + +def claim_problems(doc, claims=CLAIMS): + """What is wrong between a docstring and `claims`: a sentence missing, a + named test that does not exist, a figure outside every claim.""" + doc = _flat(doc) + problems = [] + rest = doc + for sentence, tests in claims: + if sentence not in doc: + problems.append("missing: %s" % sentence) + rest = rest.replace(sentence, " ") + for name in tests: + owner, _, test = name.partition(".") + if not hasattr(globals().get(owner), test): + problems.append("no test %s" % name) + for word in rest.split(): + if any(c.isdigit() for c in word): + problems.append("figure outside a claim: %s" % word) + return problems + + +class Claims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + self.assertEqual(claim_problems(PingPongDelay.__doc__), []) + self.assertEqual(claim_problems(pp.__doc__, ()), []) + self.assertIn(FAMILY_HEADING, _flat(PingPongDelay.__doc__)) + # The checker can fail: a figure outside a claim, a claim the + # docstring does not carry, a test that does not exist. + self.assertTrue(claim_problems(PingPongDelay.__doc__ + + " It reads 12 ms.")) + self.assertTrue(claim_problems(_flat(PingPongDelay.__doc__).replace( + "there is no latency", "there is little latency"))) + self.assertTrue(claim_problems(PingPongDelay.__doc__, CLAIMS + ( + ("A click comes out on the frame it went in: there is no " + "latency.", ("Tier1Fast.test_nothing_here",)),))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_racks.py b/tests/test_cpython_effects_racks.py index b09ce91..b711b96 100644 --- a/tests/test_cpython_effects_racks.py +++ b/tests/test_cpython_effects_racks.py @@ -19,6 +19,12 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) from effects_measure import SAMPLE_RATE, peak, source # noqa: E402 +# `reverb.Reverb` is the old `_core.Effect` class, the one with `preset`, +# which `audioeffects.Reverb` served until the Phase 5 rebuild's adoption on +# 2026-09-29. A chain entry may name a class in place of a NAME, which is +# how `ShimmerHall` and `AirSpace` keep it; these tests do the same. +from audioeffects import reverb # noqa: E402 + class RackTest(unittest.TestCase): """The rack kind: one component whose graph is several effects. @@ -31,7 +37,7 @@ class RackTest(unittest.TestCase): """ CHAIN = (("Overdrive", {"drive": 0.3, "mix": 0.4}), - ("Reverb", {"preset": "plate", "mix": 0.25})) + (reverb.Reverb, {"preset": "plate", "mix": 0.25})) def test_a_chain_spec_builds_children_in_order_and_renders(self): rack = audioeffects.create("Rack", source(), SAMPLE_RATE, @@ -67,7 +73,7 @@ def test_racks_nest(self): # "Racks may contain and be used by other racks" - both directions. inner = ("Rack", {"chain": (("Saturation", {"drive_db": 6.0}),)}) outer = audioeffects.Rack(source(), chain=( - inner, ("Reverb", {"preset": "room", "mix": 0.2}))) + inner, (reverb.Reverb, {"preset": "room", "mix": 0.2}))) self.assertEqual(type(outer.effects[0]).__name__, "Rack") self.assertGreater(peak(outer.output, 8), 0.001) diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py new file mode 100644 index 0000000..e84a87e --- /dev/null +++ b/tests/test_cpython_effects_reverb.py @@ -0,0 +1,2009 @@ +"""`Reverb`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/Reverb.md`; its +demonstrated Tier 2 rows are T1-T3 and T7-T11 (T4-T6 are the spring's and +park with it). Each row here is the measurement at one of its claimed +cells, the same measurement shown red on a fault of the same kind at the +constructor defaults, that fault shown unreachable from every macro +position and shipped patch, and the measurement shown red on the class +built as a wire. The measurements are the dossier's (App. E as Station A +restated them); the grids over Size, rate and channel count live in the +evidence pack, not in this file. +""" + +import math +import os +import re +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.rebuilt import reverb as rv # noqa: E402 + +VENDOR = "PyDevices" + +Reverb = rv.Reverb +RATE = 48000 +SEEDS = tuple(range(7, 15)) +#: The eight seed sets every claim on noise is held to (the trial brief: +#: eight sets, a margin larger than the spread between them). +SEED_SETS = tuple(tuple(range(7 + 8 * k, 15 + 8 * k)) for k in range(8)) +BURST_S = 2.0 +NOISE_RMS = 8000.0 + +#: Every shipped patch in engineering units, the dossier's section 6 table. +PATCH_SETTINGS = ( + ("Steel Plate", dict(character="plate", decay=2.4, size=1.0, + predelay_ms=0.0, diffusion=0.75, damping_hz=1000.0, + bandwidth_hz=12000.0, low_cut_hz=40.0, + mod_depth_ms=0.27, mod_rate_hz=1.0, width=1.0, + tone_db=0.0, mix=0.35)), + ("Short Plate", dict(character="plate", decay=1.2, size=1.0, + predelay_ms=0.0, diffusion=0.75, damping_hz=1500.0, + bandwidth_hz=14000.0, low_cut_hz=60.0, + mod_depth_ms=0.2, mod_rate_hz=1.2, width=0.9, + tone_db=2.0, mix=0.30)), + ("Damped Plate", dict(character="plate", decay=1.0, size=1.25, + predelay_ms=0.0, diffusion=0.75, damping_hz=700.0, + bandwidth_hz=8000.0, low_cut_hz=80.0, + mod_depth_ms=0.2, mod_rate_hz=1.0, width=0.8, + tone_db=-3.0, mix=0.30)), + ("Bass-Free Plate", dict(character="plate", decay=1.8, size=1.0, + predelay_ms=0.0, diffusion=0.75, + damping_hz=1000.0, bandwidth_hz=10000.0, + low_cut_hz=360.0, mod_depth_ms=0.27, + mod_rate_hz=1.0, width=0.8, tone_db=2.0, + mix=0.40)), + ("Small Room", dict(character="room", decay=0.45, size=0.7, + predelay_ms=2.0, diffusion=0.6, damping_hz=6000.0, + bandwidth_hz=10000.0, low_cut_hz=60.0, + mod_depth_ms=0.2, mod_rate_hz=0.8, width=0.8, + tone_db=0.0, mix=0.25)), + ("Live Room", dict(character="room", decay=1.0, size=1.25, + predelay_ms=4.0, diffusion=0.6, damping_hz=8000.0, + bandwidth_hz=12000.0, low_cut_hz=50.0, + mod_depth_ms=0.3, mod_rate_hz=0.7, width=0.9, + tone_db=2.0, mix=0.30)), + ("Concert Hall", dict(character="hall", decay=3.2, size=1.25, + predelay_ms=25.0, diffusion=0.7, damping_hz=5000.0, + bandwidth_hz=9000.0, low_cut_hz=45.0, + mod_depth_ms=0.5, mod_rate_hz=0.6, width=1.0, + tone_db=0.0, mix=0.35)), + ("Dark Chamber", dict(character="chamber", decay=1.8, size=1.0, + predelay_ms=8.0, diffusion=0.7, damping_hz=2500.0, + bandwidth_hz=6000.0, low_cut_hz=70.0, + mod_depth_ms=0.3, mod_rate_hz=0.9, width=0.8, + tone_db=-4.0, mix=0.32)), + ("Bright Chamber", dict(character="chamber", decay=1.6, size=1.0, + predelay_ms=8.0, diffusion=0.7, + damping_hz=12000.0, bandwidth_hz=16000.0, + low_cut_hz=90.0, mod_depth_ms=0.3, + mod_rate_hz=0.9, width=0.8, tone_db=4.0, + mix=0.32)), + ("Slow Bloom", dict(character="hall", decay=4.5, size=1.5, + predelay_ms=40.0, diffusion=0.8, damping_hz=4000.0, + bandwidth_hz=8000.0, low_cut_hz=45.0, + mod_depth_ms=1.0, mod_rate_hz=0.4, width=1.0, + tone_db=0.0, mix=0.45)), +) +SETTINGS = dict(PATCH_SETTINGS) + +#: Each character's reference patch (T11). +REFERENCE = {"plate": "Steel Plate", "room": "Live Room", + "chamber": "Dark Chamber", "hall": "Concert Hall"} + +#: Clean measurements shared between a row's test and its null build's +#: control, so the eight-seed rows render once. +_MEMO = {} + + +def holds(values, low=None, high=None): + """The trial brief's rule for a number read on eight seed sets: every + value inside [low, high], and its distance to the nearer bar larger + than the spread between the sets.""" + if values is None or None in values or len(values) < len(SEED_SETS): + return False + spread = max(values) - min(values) + margins = [] + if low is not None: + margins.append(min(values) - low) + if high is not None: + margins.append(high - max(values)) + return min(margins) > spread + + +# -- planted faults ---------------------------------------------------------- + +def _cut_with(sample_rate, index, size, ratios): + lines = rv.line_set(index, size, sample_rate, ratios) + return lines, rv.tap_table(index, size, sample_rate, ratios, lines) + + +class DattorroDiffuserPlate(Reverb): + """T1: the plate on Dattorro's published input diffusers (ratio 1.0, + not the class's 0.3).""" + + NAME = 'Reverb' + + def _cut(self, index, size): + if index != rv.PLATE: + return Reverb._cut(self, index, size) + ratios = (1.0, 1.0, 1.0, 1.0) + rv.RATIOS[rv.PLATE][4:] + return _cut_with(self._sample_rate, index, size, ratios) + + +class InvertedCut(Reverb): + """T2: every line and tap cut with the rate ratio upside down, + round(n x 29 761 / fs x Size x r).""" + + NAME = 'Reverb' + + def _cut(self, index, size): + ratios = rv.RATIOS[index] + k = rv.REF_RATE / self._sample_rate + lines = [max(rv.MIN_LINE, int(round(n * k * size * r))) + for n, r in zip(rv.DATTORRO_LINES, ratios)] + taps = [] + for ch, line, off, gain in rv.DATTORRO_TAPS: + o = min(int(round(off * k * size * ratios[line])), + lines[line] - 1) + taps.extend((ch, line, o, gain)) + return lines, taps + + +class FlatDamper(Reverb): + """T3: the plate's corner held at the Damping value at every Decay (the + damper law off; the Decay law still compensates 500 Hz).""" + + NAME = 'Reverb' + + def _loop_hz(self, index, decay_s, damping): + return self._hz(damping) + + +class LowCutRadians(Reverb): + """T7 wet: Low Cut handed divided by 2 pi (a rad/s slip).""" + + NAME = 'Reverb' + + def _low_cut_hz(self, value): + return self._hz(value / (2.0 * math.pi)) + + +class PlateDiffuserRooms(Reverb): + """T8: room, chamber and hall on the plate's input diffusers.""" + + NAME = 'Reverb' + + def _cut(self, index, size): + if index == rv.PLATE: + return Reverb._cut(self, index, size) + ratios = rv.RATIOS[rv.PLATE][:4] + rv.RATIOS[index][4:] + return _cut_with(self._sample_rate, index, size, ratios) + + +class HallOnRoomLines(Reverb): + """T9 (M8's own): the hall built on the room's lines and taps.""" + + NAME = 'Reverb' + + def _cut(self, index, size): + if index == rv.HALL: + return Reverb._cut(self, rv.ROOM, size) + return Reverb._cut(self, index, size) + + +class RateReciprocal(Reverb): + """T10 on-clause: Mod Rate handed as its reciprocal in ms.""" + + NAME = 'Reverb' + + def _mod_rate_hz(self, value): + return 1000.0 / value + + +class RebuildOnMove(Reverb): + """The class before audiodsp#169: a Character or Size move replaces the + Tank, and the source frames the old one held go with it.""" + + NAME = 'Reverb' + + def _recut(self, index, lines, taps, handed): + old = self._tank + self._output = self._source + for position in range(len(self._nodes) - 1, -1, -1): + if self._nodes[position] is old: + del self._nodes[position] + del self._resets[position] + del self._deinits[position] + self._tank = None + old.deinit() + self._build_tank(index, lines, taps, handed) + + +class ResetDropsHeldFrames(Reverb): + """`reset()` through `audiocore.reset_buffer` on the Tank, which drops + the source frames it holds, instead of `Tank.clear`.""" + + NAME = 'Reverb' + + def _build_tank(self, index, lines, taps, handed): + Reverb._build_tank(self, index, lines, taps, handed) + self._resets[self._nodes.index(self._tank)] = True + + +class ResetClearsNothing(Reverb): + """`reset()` that restores patch 0 and leaves the Tank's lines alone.""" + + NAME = 'Reverb' + + def _build_tank(self, index, lines, taps, handed): + Reverb._build_tank(self, index, lines, taps, handed) + self._resets[self._nodes.index(self._tank)] = False + + +def depth_ceiling_ms(lines, sample_rate): + """The node's own Mod Depth ceiling, half the shorter modulated line + less a frame (`audiodsp_tank.c:328-336`).""" + return min(lines[4] - 2, lines[8] - 2) * 0.5 * 1000.0 / sample_rate + + +class DepthMirrored(Reverb): + """T10 off-clause: Mod Depth handed mirrored about the node's ceiling.""" + + NAME = 'Reverb' + + def _mod_depth_ms(self, value, lines): + return depth_ceiling_ms(lines, self._sample_rate) - value + + +class OneMultiply(Reverb): + """T11: the law counting one decay multiply per half pass, not the + node's two (`audiodsp_tank.c:537-538`, `:552`).""" + + NAME = 'Reverb' + + def _decay(self, index, lines, decay_s, loop_hz): + d, capped, t_lf = Reverb._decay(self, index, lines, decay_s, loop_hz) + return d * d, capped, 0.5 * t_lf + + +def capped_law(kappa, lines, sample_rate, decay_s, loop_hz, cap): + """The dossier's section 4 Decay law with its low-frequency cap at + `cap` x Decay, written out here rather than read off the class.""" + a, b = rv.half_periods(lines) + p = 0.5 * (a + b) + fs = float(sample_rate) + mag = rv.one_pole_mag(loop_hz, 500.0, fs) + d = 10.0 ** (-3.0 * kappa * p / (2.0 * decay_s * fs)) / math.sqrt(mag) + ceiling = 10.0 ** (-3.0 * kappa * p / (2.0 * cap * decay_s * fs)) + capped = d > ceiling + d = min(d, ceiling, 0.999) + return d, capped, -3.0 * kappa * p / (2.0 * fs * math.log10(d)) + + +# -- probes and rendering ---------------------------------------------------- + +def interleave(mono, channels, frames): + """`mono` (int16) on every channel, padded with silence to `frames`: + the Tank advances only for frames that arrive.""" + x = np.zeros(frames, dtype=np.int16) + n = min(len(mono), frames) + x[:n] = np.asarray(mono[:n], dtype=np.int16) + return array("h", np.repeat(x, channels).tobytes()) + + +def make(cls, name=None, rate=RATE, channels=2, mono=(), frames=512, + **override): + settings = dict(SETTINGS[name]) if name else {} + settings.update(override) + src = probes.ArraySource(interleave(np.asarray(mono), channels, frames), + rate=rate, channels=channels, block=256) + return cls(src, sample_rate=rate, **settings) + + +def render(effect, frames): + channels = effect.channel_count + out = array("h") + while len(out) < frames * channels: + out.extend(memoryview(bytes( + audiocore.get_buffer(effect.output)[1])).cast("h")) + return np.array(out[:frames * channels], dtype=np.int32).reshape( + -1, channels) + + +def noise_burst(rate, seed=7, rms=NOISE_RMS, seconds=BURST_S): + rng = np.random.RandomState(seed) + n = int(seconds * rate) + return np.round(rng.uniform(-1, 1, n) * rms * math.sqrt(3)).astype( + np.int16) + + +def impulse(level=30000): + return np.array([level], dtype=np.int16) + + +def band(y, rate, centre, fraction=3): + """Zero-phase fractional-octave band (FFT domain, raised-cosine skirts + of a sixth of the band either side), the dossier's M3 filter.""" + n = len(y) + size = 1 << int(math.ceil(math.log2(n + 1))) + spec = np.fft.rfft(y.astype(np.float64), size) + f = np.fft.rfftfreq(size, 1.0 / rate) + lo = centre * 2 ** (-0.5 / fraction) + hi = centre * 2 ** (0.5 / fraction) + skirt = 2 ** (1.0 / (6 * fraction)) + g = np.zeros_like(f) + g[(f >= lo) & (f <= hi)] = 1.0 + lower = (f >= lo / skirt) & (f < lo) + g[lower] = 0.5 - 0.5 * np.cos(np.pi * np.log(f[lower] / (lo / skirt)) + / np.log(skirt)) + upper = (f > hi) & (f <= hi * skirt) + g[upper] = 0.5 + 0.5 * np.cos(np.pi * np.log(f[upper] / hi) + / np.log(skirt)) + return np.fft.irfft(spec * g, size)[:n] + + +def t60_slope(x, rate, hi_db=-5.0, lo_db=-35.0, win_ms=10.0): + """M3: least-squares slope of the 10 ms log envelope over -5...-35 dB + re its maximum, extrapolated to -60 dB; None where it cannot be fitted. + Never a -60 dB crossing.""" + w = int(round(win_ms * rate / 1000.0)) + m = len(x) // w + env = np.sqrt(np.mean(x[:m * w].reshape(m, w) ** 2, axis=1)) + env = 20.0 * np.log10(np.maximum(env, 1e-12)) + k = int(np.argmax(env)) + rel = env - env[k] + after = rel[k:] + below_hi = np.nonzero(after <= hi_db)[0] + above_lo = np.nonzero(after >= lo_db)[0] + if not len(below_hi) or not len(above_lo): + return None + s = k + int(below_hi[0]) + e = k + int(above_lo[-1]) + if e - s < 3: + return None + t = np.arange(s, e + 1) * w / rate + slope, _ = np.polyfit(t, rel[s:e + 1], 1) + return -60.0 / slope if slope < 0 else None + + +def band_t60s(cls, name, seed, centres, rate=RATE, channels=2, **override): + """T60 per band by M3 on interrupted noise: 2 s of uniform noise at + 8 000 LSB RMS then silence, the mono sum, Mix 2.""" + settings = dict(SETTINGS[name]) + settings.update(override) + t = settings["decay"] + frames = int((BURST_S + 1.3 * max(t, 1.0) + 0.8) * rate) + effect = make(cls, name, rate, channels, noise_burst(rate, seed), frames, + **dict(override, mix=2.0)) + try: + y = render(effect, frames) + finally: + effect.deinit() + m = y.astype(np.float64).sum(axis=1) + cut = int(BURST_S * rate) + return [t60_slope(band(m, rate, fc)[cut:], rate) for fc in centres] + + +def m1_profile(y, rate, onset, win_ms=10.0, floor_db=-40.0, upto_ms=260.0): + """M1: local maxima of |y| per 10 ms window above -40 dB of the + window's own RMS, windows from the channel's first non-zero sample.""" + a = np.abs(y.astype(np.float64)) + w = int(round(win_ms * rate / 1000.0)) + counts = [] + k = 0 + while True: + s = onset + k * w + e = s + w + if (k * win_ms) > upto_ms or e + 1 >= len(a): + break + seg = a[max(0, s - 1):e + 1] + body = seg[1:-1] + rms = math.sqrt(float(np.mean(body ** 2))) if len(body) else 0.0 + if rms == 0.0: + counts.append(0) + else: + thr = rms * 10 ** (floor_db / 20.0) + peaks = (body > seg[:-2]) & (body >= seg[2:]) & (body > thr) + counts.append(int(np.count_nonzero(peaks))) + k += 1 + return counts + + +def density(cls, name, rate=RATE, channels=2, **override): + """Per channel (count at 20 ms, count at 200 ms, lowest from 20 to + 200 ms) of M1 on an impulse of 30 000, Mix 2, 500 ms.""" + frames = int(0.5 * rate) + effect = make(cls, name, rate, channels, impulse(), frames, + **dict(override, mix=2.0)) + try: + y = render(effect, frames) + finally: + effect.deinit() + out = [] + for c in range(channels): + nz = np.nonzero(y[:, c])[0] + if not len(nz): + out.append((0, 0, 0)) + continue + counts = m1_profile(y[:, c], rate, int(nz[0])) + out.append((counts[2], counts[20], min(counts[2:21]))) + return out + + +def prominences(db): + n = len(db) + peaks = np.nonzero((db[1:-1] > db[:-2]) & (db[1:-1] >= db[2:]))[0] + 1 + out = {} + for p in peaks: + h = db[p] + i = p - 1 + low_l = h + while i >= 0 and db[i] <= h: + low_l = min(low_l, db[i]) + i -= 1 + j = p + 1 + low_r = h + while j < n and db[j] <= h: + low_r = min(low_r, db[j]) + j += 1 + out[p] = h - max(low_l, low_r) + return out + + +def modal_counts(cls, name="Steel Plate", rate=RATE, channels=2, + **override): + """M2 restated: Decay 10 s, Mod Depth 0, Damping 16 kHz, the mono sum's + 8 s segment from 0.2 s after a 2 s noise burst, one Hann FFT; >= 6 dB + prominence maxima per Hz in 200-400 Hz and 2-4 kHz.""" + frames = int((BURST_S + 8.3) * rate) + effect = make(cls, name, rate, channels, noise_burst(rate), frames, + **dict(dict(decay=10.0, mod_depth_ms=0.0, + damping_hz=16000.0), mix=2.0, **override)) + try: + y = render(effect, frames) + finally: + effect.deinit() + seg = y.astype(np.float64).sum(axis=1)[int((BURST_S + 0.2) * rate):] + n = 8 * rate + spec = np.abs(np.fft.rfft(seg[:n] * np.hanning(n), n)) + db = 20 * np.log10(np.maximum(spec, 1e-9)) + f = np.fft.rfftfreq(n, 1.0 / rate) + i0 = int(np.searchsorted(f, 150.0)) + i1 = int(np.searchsorted(f, 4200.0)) + sub, fsub = db[i0:i1], f[i0:i1] + prom = prominences(sub) + return [sum(1 for p, v in prom.items() if lo <= fsub[p] <= hi + and v >= 6.0) / (hi - lo) + for lo, hi in ((200.0, 400.0), (2000.0, 4000.0))] + + +def declared_density(character, size, rate=RATE): + """The class's declared modal density: lines 4-11 of the character's + own table at `size`, over fs (not the lines the Tank was handed).""" + lines = rv.line_set(rv.CHARACTERS.index(character), size, rate) + return sum(lines[4:12]) / float(rate) + + +def blackman_harris(n): + k = np.arange(n) / (n - 1.0) + return (0.35875 - 0.48829 * np.cos(2 * np.pi * k) + + 0.14128 * np.cos(4 * np.pi * k) + - 0.01168 * np.cos(6 * np.pi * k)) + + +def sidebands_at(cls, patch, hz, depth_position, rate_position, rate=RATE, + channels=2): + """`sidebands_db` on shipped patch `patch` as the player has it + (`program_change`), Mod Depth and Mod Rate set on the grid, Mix 2.""" + spans = Reverb._MACRO_RANGES + window_rate = _component.macro_value(spans[rv.MOD_RATE_I], + rate_position / 127.0) + length = max(6.0, 8.0 / window_rate) + n_total = int((4.0 + length) * rate) + x = np.round(8000 * np.sin(2 * np.pi * hz * np.arange(n_total) / rate)) + src = probes.ArraySource(interleave(x.astype(np.int16), channels, + n_total), + rate=rate, channels=channels, block=256) + effect = cls(src, sample_rate=rate) + effect.program_change(patch) + effect.set_macro(rv.MOD_DEPTH_I, depth_position) + effect.set_macro(rv.MOD_RATE_I, rate_position) + effect.set_macro(rv.MIX_I, 127) + try: + y = render(effect, n_total) + finally: + effect.deinit() + return _sideband_ratio(y, hz, length, rate) + + +def _sideband_ratio(y, hz, length, rate): + m = y.astype(np.float64).sum(axis=1)[len(y) - int(length * rate):] + p = np.abs(np.fft.rfft(m * blackman_harris(len(m)))) ** 2 + f = np.fft.rfftfreq(len(m), 1.0 / rate) + d = np.abs(f - hz) + edge = 4.0 / length + carrier = p[d <= edge].sum() + side = p[(d > edge) & (d <= 20.0)].sum() + return 10 * math.log10(side / carrier) if side > 0 else -300.0 + + +def sidebands_db(cls, name, hz, window_rate, rate=RATE, channels=2, + **override): + """M6 restated for T10: a sine at 8 000 LSB, Mix 2, the mono sum; 4 s + to settle, then L = max(6, 8 / rate) s under Blackman-Harris; the + energy 4/L-20 Hz either side over the energy within 4/L Hz, in dB.""" + length = max(6.0, 8.0 / window_rate) + n_total = int((4.0 + length) * rate) + x = np.round(8000 * np.sin(2 * np.pi * hz * np.arange(n_total) / rate)) + effect = make(cls, name, rate, channels, x.astype(np.int16), n_total, + **dict(override, mix=2.0)) + try: + y = render(effect, n_total) + finally: + effect.deinit() + return _sideband_ratio(y, hz, length, rate) + + +def twelfth_bands(lo=15.0, hi=4000.0): + edges = [] + f = lo + while f < hi: + edges.append(f) + f *= 2 ** (1.0 / 12) + return [(a, b, math.sqrt(a * b)) for a, b in zip(edges, edges[1:])] + + +def band_power(x, rate, bands): + n = 1 << int(math.ceil(math.log2(len(x)))) + p = np.abs(np.fft.rfft(x, n)) ** 2 + f = np.fft.rfftfreq(n, 1.0 / rate) + return np.array([p[(f >= a) & (f < b)].sum() for a, b, _ in bands]) + + +def at_hz(centres, db, f): + return float(np.interp(math.log(f), np.log(centres), db)) + + +def corner_hz(centres, db): + target = at_hz(centres, db, 2000.0) - 3.0 + for i in range(len(db) - 1, 0, -1): + if db[i - 1] < target <= db[i]: + a, b = math.log(centres[i - 1]), math.log(centres[i]) + t = (target - db[i - 1]) / (db[i] - db[i - 1]) + return math.exp(a + t * (b - a)) + return None + + +def wet_sum(cls, name, rate, channels, reference=False, **override): + frames = int(3.0 * rate) + effect = make(cls, name, rate, channels, noise_burst(rate), frames, + **dict(override, mix=2.0)) + if reference and hasattr(effect, "_tank") and effect._tank is not None: + # Not a class state (the macro stops at 20 Hz): the test sets the + # class's own Tank's low_cut_hz to 0, the only difference. + effect._tank.set(low_cut_hz=0.0) + try: + return render(effect, frames).astype(np.float64).sum(axis=1) + finally: + effect.deinit() + + +def low_cut_readings(cls, name="Bass-Free Plate", rate=RATE, channels=2, + **override): + """M7 restated: the wet at Low Cut over the wet with the Tank's Low Cut + at 0, 1/12-octave bands. (level at 362 Hz re 2 kHz, slope 90-180 Hz, + 1 kHz over 90 Hz, the -3 dB corner).""" + bands = twelfth_bands() + centres = np.array([c for _, _, c in bands]) + cut = band_power(wet_sum(cls, name, rate, channels, **override), rate, + bands) + ref = band_power(wet_sum(cls, name, rate, channels, reference=True, + **override), rate, bands) + db = 10 * np.log10(np.maximum(cut, 1e-30) / np.maximum(ref, 1e-30)) + return (at_hz(centres, db, 362.0) - at_hz(centres, db, 2000.0), + at_hz(centres, db, 180.0) - at_hz(centres, db, 90.0), + at_hz(centres, db, 1000.0) - at_hz(centres, db, 90.0), + corner_hz(centres, db)) + + +def low_cut_green(readings, low_cut): + level, slope, depth, corner = readings + return (-4.0 <= level <= -2.0 and 5.0 <= slope <= 7.0 and depth >= 11.0 + and corner is not None and abs(corner / low_cut - 1.0) <= 0.10) + + +def dry_deviation(name, mix, rate=RATE, channels=2, fault=False): + """T7's dry clause: the output less min(Mix, 1) x the wet-only render, + against the source x min(1, 2 - Mix), 1/12-octave bands over + 20 Hz-1 kHz, at 4 000 LSB RMS. `fault` moves the Low Cut into the dry + path, emulated as the same one-pole on the source (the seed's plant).""" + rng = np.random.RandomState(11) + n = 2 * rate + x = np.round(rng.uniform(-1, 1, n) * 4000.0 * math.sqrt(3)).astype( + np.int16) + effect = make(Reverb, name, rate, channels, x, n, mix=mix) + out = render(effect, n).astype(np.float64) + effect.deinit() + effect = make(Reverb, name, rate, channels, x, n, mix=2.0) + wet = render(effect, n).astype(np.float64) + effect.deinit() + dry = out - min(mix, 1.0) * wet + if fault: + a = 1.0 - math.exp(-2 * math.pi * SETTINGS[name]["low_cut_hz"] / rate) + lp = 0.0 + hp = np.empty(n) + for i in range(n): + lp += a * (float(x[i]) - lp) + hp[i] = float(x[i]) - lp + dry = np.repeat(hp, channels).reshape(-1, channels) + bands = twelfth_bands(18.0, 1100.0) + level = min(1.0, 2.0 - mix) + src = band_power(x.astype(np.float64) * level, rate, bands) + d = band_power(dry[:, 0], rate, bands) + dev = 10 * np.log10(d / src) + sel = [i for i, (_, _, c) in enumerate(bands) if 20.0 <= c <= 1000.0] + return float(np.max(np.abs(dev[sel]))) + + +def first_arrival_ms(cls, name, rate=RATE, channels=2): + """T9: the earlier channel's first non-zero wet sample of an impulse of + 30 000, less int(Predelay x fs / 1000) frames, in ms.""" + frames = int(0.2 * rate) + effect = make(cls, name, rate, channels, impulse(), frames, mix=2.0) + try: + y = render(effect, frames) + finally: + effect.deinit() + firsts = [np.nonzero(y[:, c])[0] for c in range(channels)] + firsts = [int(f[0]) for f in firsts if len(f)] + if not firsts: + return None + pre = int(SETTINGS[name]["predelay_ms"] * rate / 1000.0) + return (min(firsts) - pre) * 1000.0 / rate + + +def silent_build(cls, **options): + src = probes.ArraySource(array("h", [0] * 1024), rate=RATE, channels=2) + return cls(src, sample_rate=RATE, **options) + + +def reach(faulted, reading, **options): + return kit_faults.fault_reachability( + Reverb, faulted, reading, lambda cls: silent_build(cls, **options)) + + +#: 13 macros x 17 grid positions, plus the 10 patches. +WALK = 13 * 17 + 10 + + +def handed_cut(effect): + return (effect._index, tuple(effect._lines)) + + +# -- the surface ------------------------------------------------------------- + +class TheSurface(unittest.TestCase): + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects + self.assertIs(rebuilt.module_class("Reverb"), Reverb) + self.assertIn("Reverb", rebuilt.ADOPTED) + self.assertNotIn("Reverb", rebuilt.parked()) + self.assertIs(audioeffects.Reverb, Reverb) + served = audioeffects.create( + "Reverb", probes.ArraySource(interleave((), 2, 64), rate=RATE, + channels=2, block=64), RATE) + self.assertIsInstance(served, Reverb) + served.deinit() + + def test_macros_patches_tier_latency(self): + self.assertIs(rebuilt.module_class("Reverb"), Reverb) + self.assertEqual(Reverb.MACRO_LABELS, ( + "Character", "Decay", "Size", "Predelay", "Diffusion", + "Damping", "Bandwidth", "Low Cut", "Mod Depth", "Mod Rate", + "Width", "Tone", "Mix")) + self.assertEqual(Reverb.MACRO_MODES[rv.TONE_I], "BIPOLAR") + self.assertEqual(len(Reverb.PATCHES), 10) + self.assertEqual(Reverb.CAPABILITIES, ()) + self.assertEqual(Reverb.LATENCY_SAMPLES, 0) + self.assertEqual(Reverb.TIER, _component.AUDIODSP) + self.assertEqual(Reverb.REQUIRES, ("audioverb",)) + effect = silent_build(Reverb) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ()) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(rv.MIX_I, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(6) + self.assertEqual(effect.patch_index, 6) + + def test_patches_are_the_dossier_settings_on_the_grid(self): + spans = Reverb._MACRO_RANGES + keys = ("decay", "size", "predelay_ms", "diffusion", "damping_hz", + "bandwidth_hz", "low_cut_hz", "mod_depth_ms", "mod_rate_hz", + "width", "tone_db", "mix") + for index, (name, settings) in enumerate(PATCH_SETTINGS): + label, grid = Reverb.PATCHES[index] + self.assertEqual(label, name) + expected = [rv.CHARACTER_MIDI[ + rv.CHARACTERS.index(settings["character"])]] + for macro, key in enumerate(keys, start=1): + expected.append(_component.macro_of( + spans[macro], settings[key], Reverb.MACRO_MODES[macro])) + self.assertEqual(tuple(expected), grid, name) + + def test_patch_read_backs(self): + # dossier App. F: what the grid reads back. + effect = silent_build(Reverb) + effect.program_change(6) + self.assertEqual(effect._index, rv.HALL) + self.assertAlmostEqual(effect.macro(rv.DECAY_I), 3.224, places=3) + self.assertAlmostEqual(effect.macro(rv.SIZE_I), 1.248, places=3) + self.assertAlmostEqual(effect.macro(rv.PREDELAY_I), 25.2, places=1) + effect.program_change(0) + self.assertEqual(effect._index, rv.PLATE) + self.assertAlmostEqual(effect.macro(rv.DECAY_I), 2.379, places=3) + self.assertAlmostEqual(effect.macro(rv.DAMPING_I), 989.1, places=1) + self.assertEqual(effect.macro(rv.TONE_I), 0.0) + for index, zone in ((4, rv.ROOM), (7, rv.CHAMBER), (9, rv.HALL)): + effect.program_change(index) + self.assertEqual(effect._index, zone) + + def test_patch_0_is_the_constructor_grid(self): + effect = silent_build(Reverb) + for index, expected in enumerate(Reverb.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_character_zones(self): + effect = silent_build(Reverb) + for midi, zone in ((0, 0), (31, 0), (32, 1), (63, 1), (64, 2), + (95, 2), (96, 3), (127, 3)): + effect.set_macro(rv.CHARACTER_I, midi) + self.assertEqual(effect._index, zone, midi) + for name, zone in zip(rv.CHARACTERS, range(4)): + self.assertEqual(silent_build(Reverb, character=name)._index, + zone) + + def test_options_clamp_default_and_refuse(self): + with self.assertRaises(ValueError) as caught: + silent_build(Reverb, character="spring") + self.assertIn("parked", str(caught.exception)) + with self.assertRaises(ValueError): + silent_build(Reverb, character="cathedral") + nan = float("nan") + effect = silent_build(Reverb, decay=nan, size=nan, low_cut_hz=nan) + self.assertAlmostEqual(effect.macro(rv.DECAY_I), 2.4, places=9) + self.assertAlmostEqual(effect.macro(rv.SIZE_I), 1.0, places=9) + self.assertAlmostEqual(effect.macro(rv.LOW_CUT_I), 40.0, places=9) + effect = silent_build(Reverb, decay=50.0, size=0.1, low_cut_hz=-5.0, + mix=3.0, tone_db=-40.0) + self.assertAlmostEqual(effect.macro(rv.DECAY_I), 10.0, places=9) + self.assertAlmostEqual(effect.macro(rv.SIZE_I), 0.5, places=9) + self.assertAlmostEqual(effect.macro(rv.LOW_CUT_I), 20.0, places=9) + self.assertAlmostEqual(effect.macro(rv.MIX_I), 2.0, places=9) + self.assertAlmostEqual(effect.macro(rv.TONE_I), -12.0, places=9) + + def test_hz_clamps_below_nyquist_at_22050(self): + src = probes.ArraySource(array("h", [0] * 1024), rate=22050, + channels=2) + effect = Reverb(src, sample_rate=22050, bandwidth_hz=20000.0, + character="room", damping_hz=16000.0) + self.assertAlmostEqual(effect._handed["bandwidth_hz"], 10804.5) + self.assertAlmostEqual(effect._handed["damping_hz"], 10804.5) + + + def test_the_spans(self): + # the docstring's control spans, and where each end lands + spans = dict(zip(Reverb.MACRO_LABELS, Reverb._MACRO_RANGES)) + for label, low, high in ( + ("Decay", 0.3, 10.0), ("Size", 0.5, 1.5), + ("Predelay", 0.0, 200.0), ("Diffusion", 0.0, 0.9), + ("Damping", 500.0, 16000.0), ("Bandwidth", 500.0, 20000.0), + ("Low Cut", 20.0, 500.0), ("Mod Depth", 0.0, 2.0), + ("Mod Rate", 0.1, 5.0), ("Width", 0.0, 1.0), + ("Tone", -12.0, 12.0), ("Mix", 0.0, 2.0)): + self.assertEqual(tuple(spans[label][:2]), (low, high), label) + index = Reverb.MACRO_LABELS.index(label) + effect = silent_build(Reverb) + effect.set_macro(index, 0) + self.assertAlmostEqual(effect.macro(index), low, places=6) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect.macro(index), high, places=6) + + def test_without_audioverb_construction_raises_import_error(self): + saved = sys.modules.get("audioverb") + sys.modules["audioverb"] = None + try: + with self.assertRaises(ImportError): + silent_build(Reverb) + finally: + if saved is None: + del sys.modules["audioverb"] + else: + sys.modules["audioverb"] = saved + + +class TheCut(unittest.TestCase): + def test_the_plate_tank_is_dattorros_at_48k(self): + # dossier App. B: Dattorro's tank lines scaled to 48 kHz. + lines = rv.line_set(rv.PLATE, 1.0, 48000) + self.assertEqual(lines[4:], [1084, 7182, 2903, 6000, + 1464, 6801, 4284, 5101]) + self.assertEqual(lines[:4], [69, 52, 183, 134]) + + def test_the_shortest_line_clears_the_node_floor(self): + shortest = min(min(rv.line_set(i, 0.5, 22050)) for i in range(4)) + self.assertEqual(shortest, 12) + + def test_ram(self): + # dossier Tier 3: the lines plus 200 ms of predelay, int16. + def ram(index, size): + return 2 * (sum(rv.line_set(index, size, 48000)) + 9600) + self.assertEqual(ram(rv.PLATE, 1.0), 89714) + self.assertEqual(ram(rv.HALL, 1.5), 146914) + self.assertEqual(ram(rv.ROOM, 0.5), 38526) + # the hall at Size 1.5 is the most the class allocates + self.assertEqual(max(ram(i, 0.5 + k / 127.0) for i in range(4) + for k in range(128)), 146914) + + def test_the_law_never_reaches_the_node_clamp(self): + largest = 0.0 + spans = Reverb._MACRO_RANGES + for index in range(4): + for size in (0.5, 1.0, 1.5): + lines = rv.line_set(index, size, RATE) + for dm in range(0, 128, 8): + damping = _component.macro_value(spans[rv.DAMPING_I], + dm / 127.0) + for de in list(range(0, 128, 8)) + [127]: + t = _component.macro_value(spans[rv.DECAY_I], + de / 127.0) + loop = (rv.damper_hz(t, damping, RATE) + if index == rv.PLATE else damping) + d = rv.decay_law(rv.KAPPA[index], lines, RATE, t, + min(loop, 0.49 * RATE))[0] + largest = max(largest, d) + self.assertLess(largest, 0.9829) + self.assertGreater(largest, 0.98) + + def test_the_damper_law(self): + # the plate's loop corner: open (0.98 x Nyquist) at Decay 8 s and + # above, the Damping setting at 1 s and below; the others hand + # Damping at every Decay + for decay, want in ((8.0, 0.49 * RATE), (10.0, 0.49 * RATE), + (1.0, 1000.0), (0.3, 1000.0)): + effect = silent_build(Reverb, decay=decay, damping_hz=1000.0) + self.assertAlmostEqual(effect._handed["damping_hz"], want, + delta=1e-6 * want) + room = silent_build(Reverb, character="room", decay=decay, + damping_hz=1000.0) + self.assertAlmostEqual(room._handed["damping_hz"], 1000.0) + mid = silent_build(Reverb, decay=2.8, damping_hz=1000.0) + self.assertTrue(1000.0 < mid._handed["damping_hz"] < 0.49 * RATE) + + def test_tail_samples_at_the_patches(self): + # dossier section 6: the bound at each patch's settings, 48 kHz. + for name, frames in (("Steel Plate", 222868), ("Small Room", 80736), + ("Slow Bloom", 419791), + ("Concert Hall", 297320)): + self.assertEqual(make(Reverb, name).tail_samples, frames, name) + + +class Recuts(unittest.TestCase): + def test_a_character_or_size_move_recuts_the_one_tank(self): + effect = silent_build(Reverb) + port = effect.output + tank = effect._tank + lines = handed_cut(effect) + effect.set_macro(rv.CHARACTER_I, 20) # same zone: no re-cut + self.assertEqual(handed_cut(effect), lines) + effect.set_macro(rv.CHARACTER_I, 42) # room + self.assertEqual(effect._index, rv.ROOM) + effect.set_macro(rv.SIZE_I, 100) + self.assertNotEqual(handed_cut(effect)[1], lines[1]) + self.assertIs(effect._tank, tank) + self.assertIs(effect.output, port) + self.assertEqual(effect._nodes, [tank]) + + def test_a_patch_recuts_once(self): + class Counting(Reverb): + NAME = 'Reverb' + count = 0 + + def _recut(self, *arguments): + type(self).count += 1 + Reverb._recut(self, *arguments) + + effect = silent_build(Counting) + Counting.count = 0 + effect.program_change(9) # Character and Size move + self.assertEqual(Counting.count, 1) + + def _wire_across_a_move(self, block, move, cls=Reverb): + """Mix 0 over a ramp served `block` frames at a time, one move + 1536 frames in: (frames that differ from the source, how far the + output runs ahead of it after the move).""" + frames = 12288 + ramp = np.array([((i * 7) % 20001) - 10000 for i in range(frames)], + dtype=np.int16) + src = probes.ArraySource(interleave(ramp, 2, frames), rate=RATE, + channels=2, block=block) + effect = cls(src, sample_rate=RATE, mix=0.0) + head = render(effect, 1536) + effect.set_macro(*move) + out = np.concatenate([head, render(effect, frames - 2048)])[:, 0] + differ = int(np.sum(out != ramp[:len(out)])) + ahead = None + if differ: + for k in range(1, 4096): + if np.array_equal(out[1536:1600], ramp[1536 + k:1600 + k]): + ahead = k + break + return differ, ahead + + def test_a_recut_keeps_every_frame_of_the_dry(self): + # audiodsp#169: the frames the Tank holds stay across the re-cut, + # on source buffers shorter and longer than its 256-frame block + for block in (128, 256, 1024, 2048): + for move in ((rv.CHARACTER_I, 42), (rv.SIZE_I, 70), + (rv.DECAY_I, 90)): + self.assertEqual(self._wire_across_a_move(block, move), + (0, None), (block, move)) + + def test_the_old_rebuild_is_red(self): + # a new Tank per move loses the old one's unplayed 512 frames on a + # 1024- or 2048-frame source, and nothing on 128 or 256 + for block in (1024, 2048): + for move in ((rv.CHARACTER_I, 42), (rv.SIZE_I, 70)): + differ, ahead = self._wire_across_a_move(block, move, + RebuildOnMove) + self.assertEqual(ahead, 512, (block, move)) + self.assertGreater(differ, 0, (block, move)) + for block in (128, 256): + self.assertEqual(self._wire_across_a_move( + block, (rv.CHARACTER_I, 42), RebuildOnMove), (0, None)) + + def test_a_recut_cuts_the_tail(self): + frames = RATE + effect = make(Reverb, "Steel Plate", mono=noise_burst(RATE, + seconds=0.25), + frames=frames, mix=2.0) + render(effect, int(0.5 * RATE)) + self.assertGreater(int(np.max(np.abs(render(effect, 256)))), 0) + effect.set_macro(rv.CHARACTER_I, 127) + after = render(effect, int(0.25 * RATE)) + self.assertEqual(int(np.max(np.abs(after))), 0) + + +# -- Tier 1 ------------------------------------------------------------------ + +class Tier1(unittest.TestCase): + def test_mix_zero_is_a_wire_at_every_patch(self): + frames = 4096 + ramp = [((i * 37) % 65536) - 32768 for i in range(frames)] + for channels in (2, 1): + for index in range(10): + data = array("h", [v for v in ramp for _ in range(channels)]) + src = probes.ArraySource(data, rate=RATE, channels=channels) + effect = Reverb(src, sample_rate=RATE) + effect.program_change(index) + effect.set_macro(rv.MIX_I, 0) + out = render(effect, frames) + self.assertTrue(np.array_equal( + out.reshape(-1), np.array(data, dtype=np.int32)), + (channels, index)) + + def test_silence_stays_silence(self): + for channels in (2, 1): + effect = make(Reverb, frames=RATE, channels=channels, mix=2.0) + for index in range(10): + effect.program_change(index) + self.assertEqual(int(np.max(np.abs(render(effect, 4096)))), + 0, index) + + def _tone_route(self, cls, channels): + """Short Plate (Tone 74) over full-scale noise, Steel Plate (Tone + 64, the same lines, so no rebuild) for its last block, 6 s of + silence, then Short Plate again: (peak over the last second before + the move, peak over the 0.1 s after it), Mix 2.""" + noise = RATE // 2 + rng = np.random.RandomState(5) + burst = np.round(rng.uniform(-1, 1, noise) * 32767).astype(np.int16) + effect = make(cls, channels=channels, mono=burst, + frames=noise + 7 * RATE) + effect.program_change(1) + effect.set_macro(rv.MIX_I, 127) + render(effect, noise - 256) + first = effect._tank + effect.program_change(0) + effect.set_macro(rv.MIX_I, 127) + self.assertIs(effect._tank, first) + quiet = render(effect, 256 + 6 * RATE) + effect.program_change(1) + effect.set_macro(rv.MIX_I, 127) + self.assertIs(effect._tank, first) + after = render(effect, RATE // 10) + return (int(np.max(np.abs(quiet[-RATE:]))), + int(np.max(np.abs(after)))) + + def test_a_tone_move_out_of_silence_stays_silent(self): + for channels in (2, 1): + self.assertEqual(self._tone_route(Reverb, channels), (0, 0), + channels) + + def test_click_delay_is_zero(self): + for name in ("Steel Plate", "Concert Hall"): + frames = 2048 + mono = np.zeros(frames, dtype=np.int16) + mono[1000] = 20000 + effect = make(Reverb, name, mono=mono, frames=frames, mix=1.0) + out = render(effect, frames) + self.assertEqual(int(out[1000, 0]), 20000, name) + self.assertEqual(int(np.max(np.abs(out[:1000]))), 0, name) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + for name, channels, seed in [(n, c, k) for n, c in ( + ("Steel Plate", 2), ("Small Room", 1)) for k in range(3, 11)]: + rng = np.random.RandomState(seed) + burst = np.round(rng.uniform(-1, 1, RATE) * 32767).astype( + np.int16) + probe = make(Reverb, name, channels=channels) + bound = probe.tail_samples + frames = RATE + bound + RATE // 2 + effect = make(Reverb, name, channels=channels, mono=burst, + frames=frames, mix=2.0) + y = render(effect, frames) + nz = np.nonzero(np.any(y != 0, axis=1))[0] + last = int(nz[-1]) - RATE + self.assertLess(last, bound, (name, seed)) + self.assertGreater(last, bound // 4, (name, seed)) + + def _reset_route(self, cls, patch): + """Patch `patch` rings on a 0.25 s burst; `reset()` 0.5 s in: (peak + of the block before the reset, peak of the 4096 frames after).""" + effect = make(cls, mono=np.concatenate([ + np.zeros(4096, dtype=np.int16), + noise_burst(RATE, seconds=0.25)]), frames=2 * RATE) + effect.program_change(patch) + effect.set_macro(rv.MIX_I, 127) + render(effect, RATE // 2 - 256) + before = int(np.max(np.abs(render(effect, 256)))) + effect.reset() + self.assertEqual(effect.patch_index, 0) + # the borrowed source is not reset: it is past its burst, so what + # comes out is the emptied tank and a silent dry + return before, int(np.max(np.abs(render(effect, 4096)))) + + def test_reset_clears_the_tail_and_restores_patch_0(self): + # from patch 0 the reset re-cuts nothing, so the clear is its own + for patch in (0, 6): + before, after = self._reset_route(Reverb, patch) + self.assertGreater(before, 0, patch) + self.assertEqual(after, 0, patch) + + def test_a_reset_that_clears_nothing_is_red_from_patch_0(self): + before, after = self._reset_route(ResetClearsNothing, 0) + self.assertGreater(after, 100) + # from Concert Hall patch 0's re-cut empties the lines anyway, + # which is what hid it + self.assertEqual(self._reset_route(ResetClearsNothing, 6)[1], 0) + + def _dry_across_a_reset(self, cls, block): + """Mix 0 over a ramp served `block` frames at a time; `reset()` 1536 + frames in and Mix back to 0: frames of the output that are not the + source.""" + frames = 8192 + ramp = np.array([((i * 7) % 20001) - 10000 for i in range(frames)], + dtype=np.int16) + src = probes.ArraySource(interleave(ramp, 2, frames), rate=RATE, + channels=2, block=block) + effect = cls(src, sample_rate=RATE, mix=0.0) + head = render(effect, 1536) + effect.reset() + effect.set_macro(rv.MIX_I, 0) + out = np.concatenate([head, render(effect, frames - 2048)])[:, 0] + return int(np.sum(out != ramp[:len(out)])) + + def test_reset_keeps_the_dry_the_tank_holds(self): + for block in (100, 256, 1024): + self.assertEqual(self._dry_across_a_reset(Reverb, block), 0, + block) + + def test_a_reset_through_reset_buffer_is_red(self): + for block in (100, 1024): + self.assertGreater( + self._dry_across_a_reset(ResetDropsHeldFrames, block), 0, + block) + + def test_deinit_releases_the_tank_and_leaves_the_source(self): + src = probes.ArraySource(array("h", [9000] * 4096), rate=RATE, + channels=2) + effect = Reverb(src, sample_rate=RATE) + tank = effect._tank + effect.set_macro(rv.CHARACTER_I, 127) + render(effect, 256) + effect.deinit() + with self.assertRaises(Exception): + tank.set(decay=0.5) + data = memoryview(bytes(audiocore.get_buffer(src)[1])).cast("h") + self.assertEqual(max(abs(int(v)) for v in data), 9000) + + def test_a_sparse_quiet_click_rings_out_early(self): + # one click at 1 000 LSB on an 8 s plate is exactly silent within + # 2 s (the lines truncate toward zero); at 30 000 it is not + frames = 4 * RATE + for level, silent in ((1000, True), (30000, False)): + effect = make(Reverb, "Steel Plate", mono=impulse(level), + frames=frames, mix=2.0, decay=8.0) + y = render(effect, frames) + effect.deinit() + self.assertEqual(not np.any(y[2 * RATE:]), silent, level) + self.assertTrue(np.any(y[RATE // 10:]), level) + + def test_a_control_that_jumps_steps_the_output(self): + # the family limit: Mix 0 to 2 in one move over a steady tone steps + # the output at the block the move lands on + frames = 16384 + x = np.round(8000 * np.sin(2 * np.pi * 200.0 * np.arange(frames) + / RATE)).astype(np.int16) + effect = make(Reverb, mono=x, frames=frames, mix=0.0) + head = render(effect, 8192) + effect.set_macro(rv.MIX_I, 127) + y = np.concatenate([head, render(effect, 256)])[:, 0] + step = int(np.max(np.abs(np.diff(y[8190:8200])))) + own = int(np.max(np.abs(np.diff(x.astype(np.int32))))) + self.assertGreater(step, 3 * own, (step, own)) + + def test_the_tail_rings_only_while_the_source_feeds(self): + # the family limit: a source that stops handing frames stops the + # tail where it is, and it carries on when the source comes back + class Gated: + def __init__(self, burst): + self.sample_rate = RATE + self.channel_count = 2 + self.bits_per_sample = 16 + self.samples_signed = True + self.data = interleave(burst, 2, len(burst)).tobytes() + self.open = True + + def _reset_buffer(self, *args): + pass + + def _get_buffer(self, *args): + if not self.open: + return 0, memoryview(b"") + chunk, self.data = self.data[:1024], self.data[1024:] + return 1, memoryview(chunk or bytes(1024)) + + src = Gated(noise_burst(RATE, seconds=0.25)) + effect = Reverb(src, sample_rate=RATE, mix=2.0) + render(effect, RATE // 2) + self.assertGreater(int(np.max(np.abs(render(effect, 256)))), 0) + src.open = False + render(effect, 1024) # what the Tank holds + self.assertEqual(int(np.max(np.abs(render(effect, 2 * RATE)))), 0) + src.open = True + self.assertGreater(int(np.max(np.abs(render(effect, 256)))), 0) + + def test_lower_rates_build_and_ring(self): + for rate in (44100, 22050): + for index in (0, 6): + src = probes.ArraySource( + interleave(impulse(), 2, rate // 2), rate=rate, + channels=2) + effect = Reverb(src, sample_rate=rate) + effect.program_change(index) + self.assertGreater(int(np.max(np.abs( + render(effect, rate // 2)[rate // 10:]))), 0) + + +# -- Tier 2 ------------------------------------------------------------------ + +def t1_verdict(profile): + """T1: on every channel the 200 ms window is not empty and no window + from 20 to 200 ms reads more than 20 % below it.""" + return all(c200 > 0 and low >= 0.80 * c200 + for _, c200, low in profile) + + +class T1PlateDensity(unittest.TestCase): + def test_steel_plate_is_dense_from_20_ms(self): + profile = density(Reverb, "Steel Plate") + self.assertTrue(t1_verdict(profile), profile) + for _, c200, low in profile: + self.assertGreaterEqual(low / float(c200), 0.81) + + def test_dattorros_published_diffusers_are_red(self): + profile = density(DattorroDiffuserPlate, "Steel Plate") + self.assertFalse(t1_verdict(profile), profile) + for _, c200, low in profile: + self.assertLess(low / float(c200), 0.6) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(DattorroDiffuserPlate, handed_cut) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t1_verdict( + density(cls, "Steel Plate"))}, label="Reverb T1") + + +def t2_verdict(counts, declared): + lo, hi = counts + if lo <= 0.0 or hi <= 0.0: + return False + return (abs(lo / declared - 1.0) <= 0.10 + and abs(hi / declared - 1.0) <= 0.10 and hi / lo <= 2.0) + + +class T2ModalDensity(unittest.TestCase): + def test_resolved_modes_count_the_declared_table(self): + declared = declared_density("plate", 1.0) + self.assertAlmostEqual(declared, 0.725, places=3) + counts = modal_counts(Reverb) + self.assertTrue(t2_verdict(counts, declared), counts) + + def test_the_inverted_cut_is_red(self): + declared = declared_density("plate", 1.0) + counts = modal_counts(InvertedCut) + self.assertFalse(t2_verdict(counts, declared), counts) + self.assertLess(max(counts) / declared, 0.75) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(InvertedCut, handed_cut) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + declared = declared_density("plate", 1.0) + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t2_verdict(modal_counts(cls), + declared)}, + label="Reverb T2") + + +T3_STOPS = (8.0, 4.0, 2.0, 1.0) + + +def t3_ratios(cls, seed_sets=SEED_SETS): + """R = T60(500 Hz) / T60(4 kHz) at Decay 8 / 4 / 2 / 1 s on Steel Plate + as patched: per seed set, the mean over its eight seeds at each stop; + None where a fit fails.""" + key = ("T3", cls, seed_sets) + if key not in _MEMO: + out = [] + for seeds in seed_sets: + rs = [] + for t in T3_STOPS: + vals = [band_t60s(cls, "Steel Plate", seed, (500, 4000), + decay=t) for seed in seeds] + if any(a is None or b is None for a, b in vals): + rs = None + break + rs.append(float(np.mean([a / b for a, b in vals]))) + if rs is None: + out = None + break + out.append(rs) + _MEMO[key] = out + return _MEMO[key] + + +def t3_verdict(sets): + """Each step down in Decay raises R on every seed set, by a margin + larger than the spread of that step between the sets.""" + if sets is None: + return False + return all(holds([rs[i + 1] - rs[i] for rs in sets], low=0.0) + for i in range(len(T3_STOPS) - 1)) + + +class T3Damper(unittest.TestCase): + def test_the_upper_band_shortens_more_as_decay_shortens(self): + sets = t3_ratios(Reverb) + self.assertTrue(t3_verdict(sets), sets) + + def test_a_frequency_flat_damper_is_red(self): + sets = t3_ratios(FlatDamper, SEED_SETS[:1]) + self.assertFalse(t3_verdict(sets * 8), sets) + self.assertLess(sets[0][-1] / sets[0][0] - 1.0, -0.5) + + def test_the_fault_is_not_on_the_surface(self): + # The character is part of the reading: the room, chamber and hall + # hand the Damping value flat by design, and the fault is the + # plate doing it. + result = reach(FlatDamper, lambda e: ( + e._index, round(e.macro(rv.DECAY_I), 9), + e._handed["damping_hz"])) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t3_verdict( + t3_ratios(cls, SEED_SETS[:1]) and t3_ratios(cls))}, + label="Reverb T3") + + +class T7LowCut(unittest.TestCase): + def test_the_tank_sees_no_bass_on_bass_free_plate(self): + readings = low_cut_readings(Reverb) + self.assertTrue(low_cut_green(readings, 360.0), readings) + + def test_the_corner_follows_low_cut(self): + for low_cut in (100.0, 500.0): + readings = low_cut_readings(Reverb, low_cut_hz=low_cut) + self.assertLessEqual(abs(readings[3] / low_cut - 1.0), 0.10, + (low_cut, readings)) + + def test_the_dry_is_flat(self): + for name in ("Steel Plate", "Concert Hall"): + for mix in (0.35, 1.0, 1.5, 1.9): + self.assertLessEqual(dry_deviation(name, mix), 0.1, + (name, mix)) + + def test_low_cut_in_the_dry_is_red(self): + self.assertGreater(dry_deviation("Bass-Free Plate", 0.4, fault=True), + 10.0) + + def test_low_cut_in_radians_is_red(self): + readings = low_cut_readings(LowCutRadians) + self.assertFalse(low_cut_green(readings, 360.0), readings) + self.assertLess(readings[3], 70.0) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(LowCutRadians, lambda e: e._handed["low_cut_hz"]) + self.assertAlmostEqual(result["target"], 40.0 / (2.0 * math.pi)) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": low_cut_green( + low_cut_readings(cls), 360.0)}, label="Reverb T7") + + +def t8_verdict(cls, name): + return all(c200 >= 1.5 * max(c20, 1) + for c20, c200, _ in density(cls, name)) + + +T8_PATCHES = ("Small Room", "Concert Hall", "Dark Chamber") + + +class T8TheyBuild(unittest.TestCase): + def test_room_hall_and_chamber_build(self): + for name in T8_PATCHES: + profile = density(Reverb, name) + self.assertTrue(t8_verdict(Reverb, name), (name, profile)) + + def test_on_the_plates_diffusers_they_do_not(self): + for name in T8_PATCHES: + self.assertFalse(t8_verdict(PlateDiffuserRooms, name), name) + + def test_the_contrast_the_plate_does_not_build(self): + self.assertFalse(t8_verdict(Reverb, "Steel Plate")) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(PlateDiffuserRooms, handed_cut, character="room") + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t8_verdict(cls, "Small Room")}, + label="Reverb T8") + + +def t60_1k(cls, name, seed_sets=SEED_SETS): + """Per seed set, the mean T60 at 1 kHz over its eight seeds.""" + key = ("T9", cls, name, seed_sets) + if key not in _MEMO: + out = [] + for seeds in seed_sets: + vals = [band_t60s(cls, name, seed, (1000,))[0] for seed in seeds] + if None in vals: + out = None + break + out.append(float(np.mean(vals))) + _MEMO[key] = out + return _MEMO[key] + + +def t9_verdict(cls, seed_sets=SEED_SETS): + arrivals = [first_arrival_ms(cls, n) for n in + ("Concert Hall", "Live Room", "Small Room")] + if None in arrivals or not (arrivals[0] >= 10.0 and arrivals[1] <= 6.0 + and arrivals[2] <= 6.0): + return False + hall, live, small = (t60_1k(cls, n, seed_sets) for n in + ("Concert Hall", "Live Room", "Small Room")) + if None in (hall, live, small): + return False + if len(seed_sets) < len(SEED_SETS): + return all(h >= 2.0 * lv and h >= 4.0 * sm + for h, lv, sm in zip(hall, live, small)) + return (holds([h / lv for h, lv in zip(hall, live)], low=2.0) + and holds([h / sm for h, sm in zip(hall, small)], low=4.0)) + + +def construction_pairs(cls, rate=RATE): + """(Character index, line set) at every Size grid position, read off + built instances' handed state.""" + pairs = set() + effect = silent_build(cls) + for midi in rv.CHARACTER_MIDI: + effect.set_macro(rv.CHARACTER_I, midi) + for size in range(128): + effect.set_macro(rv.SIZE_I, size) + pairs.add(handed_cut(effect)) + effect.deinit() + return pairs + + +class T9ThreeTunings(unittest.TestCase): + def test_the_patches_are_three_tunings(self): + self.assertTrue(t9_verdict(Reverb)) + self.assertGreaterEqual(first_arrival_ms(Reverb, "Concert Hall"), + 12.9) + + def test_no_two_characters_share_a_line_set(self): + for rate in (48000, 44100, 22050): + owners = {} + for index in range(4): + for midi in range(128): + lines = tuple(rv.line_set(index, 0.5 + midi / 127.0, + rate)) + owners.setdefault(lines, set()).add(index) + self.assertEqual([s for s in owners.values() if len(s) > 1], [], + rate) + + def test_the_hall_on_the_rooms_lines_is_red(self): + self.assertFalse(t9_verdict(HallOnRoomLines)) + self.assertLess(first_arrival_ms(HallOnRoomLines, "Concert Hall"), + 10.0) + planted = silent_build(HallOnRoomLines, character="hall", + size=0.5 + 95 / 127.0) + self.assertNotIn(handed_cut(planted), construction_pairs(Reverb)) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(HallOnRoomLines, handed_cut, character="hall") + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t9_verdict(cls)}, + label="Reverb T9") + + +T10_PATCHES = (0, 6) # Steel Plate, Concert Hall + + +def t10_on(cls): + """At each patch's own Mod Depth and Mod Rate, a 1 kHz tone's sidebands + within 20 dB of it.""" + return all(sidebands_at(cls, index, 1000.0, + Reverb.PATCHES[index][1][rv.MOD_DEPTH_I], + Reverb.PATCHES[index][1][rv.MOD_RATE_I]) + >= -20.0 for index in T10_PATCHES) + + +def t10_off(cls): + """Mod Depth at 0 on each patch: a 1 kHz tone's sidebands more than + 60 dB under it.""" + return all(sidebands_at(cls, index, 1000.0, 0, + Reverb.PATCHES[index][1][rv.MOD_RATE_I]) + <= -60.0 for index in T10_PATCHES) + + +#: T10's claimed rectangle on the 0-127 grid, both ends in: Mod Depth +#: positions 17-64 (0.268-1.008 ms), Mod Rate positions 45-81 (0.400-1.212 +#: Hz); and one cell outside it where Steel Plate's 3 kHz tone fails (the +#: round-3 audit's). +T10_DEPTHS = (17, 64) +T10_RATES = (45, 81) +T10_HOLE = (81, 121) + + +def t10_cell(depth_position, rate_position): + spans = Reverb._MACRO_RANGES + return (_component.macro_value(spans[rv.MOD_DEPTH_I], + depth_position / 127.0), + _component.macro_value(spans[rv.MOD_RATE_I], + rate_position / 127.0)) + + +class T10Modulation(unittest.TestCase): + def test_a_still_tank_is_one_line(self): + self.assertTrue(t10_off(Reverb)) + + def test_the_patches_modulation_spreads_it(self): + self.assertTrue(t10_on(Reverb)) + + def test_0_1_ms_is_not_claimed_it_follows_the_line_set(self): + # dossier section 8, R2: at Mod Depth 0.1 ms on Steel Plate the + # reading depends on the exact lines; green at Size 1.0 (-18.4 dB), + # red on patch 0's grid Size 64 = 1.0039 (-27.2 dB) + at_1 = sidebands_db(Reverb, "Steel Plate", 1000.0, 1.0, + mod_depth_ms=0.1, mod_rate_hz=1.0) + at_grid = sidebands_db(Reverb, "Steel Plate", 1000.0, 1.0, + mod_depth_ms=0.1, mod_rate_hz=1.0, + size=0.5 + 64 / 127.0) + self.assertGreaterEqual(at_1, -20.0) + self.assertLess(at_grid, -20.0) + + def test_the_claimed_rectangle(self): + # every grid point, stepped by 1, is REVERB_T10_WALK=1 (about half + # an hour a patch; its printout is in the anchor's trial folder); + # the default walks the corners and every eighth position + full = bool(os.environ.get("REVERB_T10_WALK")) + step = 1 if full else 8 + depths = sorted(set(list(range(T10_DEPTHS[0], T10_DEPTHS[1] + 1, + step)) + [T10_DEPTHS[1]])) + rates = sorted(set(list(range(T10_RATES[0], T10_RATES[1] + 1, + step)) + [T10_RATES[1]])) + for index in T10_PATCHES: + for hz in (300.0, 1000.0, 3000.0): + for dp in depths: + for rp in rates: + v = sidebands_at(Reverb, index, hz, dp, rp) + self.assertGreaterEqual(v, -20.0, + (index, hz, dp, rp, v)) + + def test_where_it_does_not_hold(self): + # outside the rectangle: Steel Plate, a 3 kHz tone, Mod Depth at + # position 81, Mod Rate at position 121 + v = sidebands_at(Reverb, 0, 3000.0, T10_HOLE[0], T10_HOLE[1]) + self.assertLess(v, -20.0, v) + + def test_mod_rate_as_its_reciprocal_is_red(self): + self.assertFalse(t10_on(RateReciprocal)) + + def test_mod_depth_mirrored_is_red(self): + self.assertFalse(t10_off(DepthMirrored)) + + def test_the_faults_are_not_on_the_surface(self): + result = reach(RateReciprocal, lambda e: e._handed["mod_rate_hz"]) + self.assertAlmostEqual(result["target"], 1000.0, places=6) + self.assertEqual(result["checked"], WALK) + result = reach(DepthMirrored, lambda e: e._handed["mod_depth_ms"]) + self.assertGreater(result["target"], 2.0) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t10_on(cls)}, label="Reverb T10") + + +T11_STOPS = (2.0, 3.0, 4.0, 6.0, 8.0, 10.0) + + +def t11_errors(cls, character, stops=T11_STOPS, seed_sets=SEED_SETS): + """Per stop, per seed set, (T60 at 500 Hz / Decay - 1), the set's mean + over its eight seeds, at the character's reference patch with Size + 1.0; None where a fit fails.""" + key = ("T11", cls, character, stops, seed_sets) + if key not in _MEMO: + errors = [] + for t in stops: + row = [] + for seeds in seed_sets: + vals = [band_t60s(cls, REFERENCE[character], seed, (500,), + decay=t, size=1.0)[0] for seed in seeds] + if None in vals: + row = None + break + row.append(float(np.mean(vals)) / t - 1.0) + if row is None: + errors = None + break + errors.append(row) + _MEMO[key] = errors + return _MEMO[key] + + +def t11_verdict(errors): + """Every stop inside +/-12 % on every seed set, by a margin larger than + the spread between the sets.""" + return errors is not None and all(holds(row, -0.12, 0.12) + for row in errors) + + +#: The stops the docstring claims per character. The hall at 2 and 3 s +#: reads inside +/-12 % on every set but by a margin under the spread, so +#: it is not claimed there. +T11_CLAIMED = {"plate": T11_STOPS, "room": T11_STOPS, "chamber": T11_STOPS, + "hall": (4.0, 6.0, 8.0, 10.0)} + + +class T11DecayIsT60(unittest.TestCase): + def test_every_character_lands_its_label(self): + for character in rv.CHARACTERS: + errors = t11_errors(Reverb, character, T11_CLAIMED[character]) + self.assertTrue(t11_verdict(errors), (character, errors)) + + def test_one_multiply_per_half_pass_is_red(self): + errors = t11_errors(OneMultiply, "plate", seed_sets=SEED_SETS[:1]) + self.assertFalse(t11_verdict(errors), errors) + self.assertTrue(all(row[0] < -0.35 for row in errors), errors) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(OneMultiply, lambda e: ( + round(e.macro(rv.DECAY_I), 9), e._handed["decay"])) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t11_verdict( + t11_errors(cls, "plate", seed_sets=SEED_SETS[:1]) and + t11_errors(cls, "plate"))}, label="Reverb T11") + + +def patch_t500(name, seed_sets=SEED_SETS): + """Per seed set, the patch's mean T60 at 500 Hz over its eight seeds, + over its Decay.""" + key = ("patch", name, seed_sets) + if key not in _MEMO: + decay = SETTINGS[name]["decay"] + _MEMO[key] = [float(np.mean([band_t60s(Reverb, name, seed, + (500,))[0] + for seed in seeds])) / decay + for seeds in seed_sets] + return _MEMO[key] + + +#: The patches whose tail outlasts their Decay at 500 Hz on every seed set. +LONG_PATCHES = ("Damped Plate", "Small Room", "Live Room") + + +class PatchDecays(unittest.TestCase): + def test_three_patches_ring_longer_than_their_decay(self): + for name in LONG_PATCHES: + ratios = patch_t500(name) + self.assertTrue(holds(ratios, low=1.0), (name, ratios)) + + +# -- the ceiling (dossier section 8.9) ----------------------------------------- + +def t500_mean(build, decay, seeds=SEEDS, rate=RATE, hint=None): + """M3 at 500 Hz on interrupted noise (2 s at 8 000 LSB RMS, then + silence), the mono sum, Mix 2, the mean over `seeds`; `build(src)` + makes the instance. The render runs 1.3 x max(`hint` or Decay, 1 s) + past the burst, which reaches past -35 dB at every cell here.""" + t = decay if hint is None else hint + frames = int((BURST_S + 1.3 * max(t, 1.0) + 0.8) * rate) + vals = [] + for seed in seeds: + src = probes.ArraySource(interleave(noise_burst(rate, seed), 2, + frames), + rate=rate, channels=2, block=256) + effect = build(src) + try: + y = render(effect, frames) + finally: + effect.deinit() + m = y.astype(np.float64).sum(axis=1) + vals.append(t60_slope(band(m, rate, 500)[int(BURST_S * rate):], + rate)) + return None if None in vals else float(np.mean(vals)) + + +def ceiling_t500(cls, character, size, decay, seed_sets=SEED_SETS): + """Per seed set, T60 at 500 Hz at the constructor defaults (Damping + 1 kHz) on `character` at `size` and `decay`, Mix 2.""" + key = ("ceiling", cls, character, size, decay, seed_sets) + if key not in _MEMO: + hint = 2.0 * decay + _MEMO[key] = [t500_mean( + lambda src: cls(src, sample_rate=RATE, character=character, + size=size, decay=decay, mix=2.0), + decay, seeds, hint=hint) for seeds in seed_sets] + return _MEMO[key] + + +#: Where the docstring says the tail lands more than 12 % short: Damping +#: 1 kHz, Size 0.5, Decay 8 and 10 s. +SHORT_CELLS = tuple((c, 0.5, t) for c in ("room", "chamber", "hall") + for t in (8.0, 10.0)) + + +class NoCeiling(Reverb): + """The Decay law without its low-frequency cap (x 1000 Decay).""" + + NAME = 'Reverb' + + def _decay(self, index, lines, decay_s, loop_hz): + return capped_law(rv.KAPPA[index], lines, self._sample_rate, decay_s, + loop_hz, 1000.0) + + +class TheCeiling(unittest.TestCase): + def test_small_long_rooms_land_short_of_the_label(self): + for character, size, t in SHORT_CELLS: + got = ceiling_t500(Reverb, character, size, t) + short = [1.0 - g / t for g in got] + self.assertTrue(holds(short, low=0.12), (character, t, short)) + + def test_without_the_cap_they_do_not(self): + # one seed set: the chamber lands long and the hall within 2 % + for character, t in (("chamber", 8.0), ("hall", 10.0)): + got = ceiling_t500(NoCeiling, character, 0.5, t, + SEED_SETS[:1])[0] + self.assertLess(1.0 - got / t, 0.05, (character, t, got)) + + def test_the_cap_is_not_on_the_surface(self): + result = reach(NoCeiling, lambda e: ( + e._index, tuple(e._lines), round(e.macro(rv.DECAY_I), 9), + e._handed["decay"]), character="room", size=0.5, decay=10.0) + self.assertEqual(result["checked"], WALK) + + +# -- the input ceiling (audit-3 rulings (m) and (o)) ------------------------- + +def rail_peaks(name, rms, rate=RATE, channels=2, mix=1.0, seeds=None, + **override): + """The output's peak over 2 s of uniform noise at `rms` LSB RMS and + 0.5 s after, at Mix 1 by default, one per seed (11-18).""" + peaks = [] + for seed in (range(11, 19) if seeds is None else seeds): + rng = np.random.RandomState(seed) + n = 2 * rate + x = np.round(np.clip(rng.uniform(-1, 1, n) * rms * math.sqrt(3), + -32768, 32767)).astype(np.int16) + frames = n + rate // 2 + effect = make(Reverb, name, rate, channels, x, frames, mix=mix, + **override) + try: + y = render(effect, frames) + finally: + effect.deinit() + peaks.append(int(np.max(np.abs(y)))) + return peaks + + +def sine_wet_gain(hz, rms, rate=RATE): + """The wet's carrier gain on a steady sine at `rms` LSB RMS at the + constructor defaults, Mix 2: the last 2 s of 6 s, one Hann FFT.""" + n = 6 * rate + x = np.round(rms * math.sqrt(2) * np.sin( + 2 * np.pi * hz * np.arange(n) / rate)).astype(np.int16) + effect = make(Reverb, None, rate, 2, x, n, mix=2.0) + try: + y = render(effect, n) + finally: + effect.deinit() + seg = y.astype(np.float64).sum(axis=1)[-2 * rate:] / 2.0 + xin = x.astype(np.float64)[-2 * rate:] + win = np.hanning(len(seg)) + f = np.fft.rfftfreq(len(seg), 1.0 / rate) + k = int(np.argmin(np.abs(f - hz))) + a = np.abs(np.fft.rfft(seg * win))[k - 2:k + 3].max() + b = np.abs(np.fft.rfft(xin * win))[k - 2:k + 3].max() + return 20 * math.log10(a / b) + + +#: Bright Chamber at 44.1 kHz peaks 5 232 LSB under the rail at the most, +#: with 5 302 LSB between its eight seeds: not claimed. +RAIL_UNCLAIMED = (("Bright Chamber", 44100),) + + +class InputCeiling(unittest.TestCase): + def test_no_patch_reaches_the_rail_at_4000_lsb_rms(self): + # on eight noise seeds, at three rates, each patch's peak stays + # under the rail by more than the spread of its peaks + for name, _ in PATCH_SETTINGS: + for rate in (48000, 44100, 22050): + if (name, rate) in RAIL_UNCLAIMED: + continue + peaks = rail_peaks(name, 4000.0, rate) + self.assertTrue(holds(peaks, high=32767), (name, rate, + peaks)) + + def test_6_db_more_reaches_it(self): + # the same reading with the input planted 6 dB hotter: Bright + # Chamber reaches the rail on every seed + self.assertTrue(all(p >= 32767 for p in rail_peaks( + "Bright Chamber", 8000.0))) + + def test_the_tanks_lines_compress_from_8000_lsb_rms(self): + # the docstring: flat to 4 000 LSB RMS, about 1.3 dB down at 8 000 + # on a 362 Hz sine at the defaults, with the output under the rail + g1, g4, g8 = (sine_wet_gain(362.0, r) for r in (1000.0, 4000.0, + 8000.0)) + self.assertLessEqual(abs(g4 - g1), 0.1, (g1, g4)) + self.assertLess(g8 - g4, -1.0, (g4, g8)) + + +# -- the claims --------------------------------------------------------------- + +#: Every sentence of the docstrings that makes a claim, word for word, and +#: the tests that assert it (the trial brief). A sentence that is not here +#: may hold no number. +CLAIMS = ( + ("Your dry signal passes untouched and a reverb tail rises behind it.", + ("T7LowCut.test_the_dry_is_flat",)), + ("Character picks the machine: `plate` is dense from the first " + "milliseconds, the way the EMT 140's steel sheet is, while `room`, " + "`chamber` and `hall` start sparse and build.", + ("T1PlateDensity.test_steel_plate_is_dense_from_20_ms", + "T8TheyBuild.test_room_hall_and_chamber_build")), + ("Decay (0.3 to 10 s) sets how long the tail rings, Size (0.5 to 1.5) " + "stretches every line of the network, and Predelay (0 to 200 ms) holds " + "the tail back from the dry.", + ("TheSurface.test_the_spans",)), + ("Diffusion (0 to 0.9) smears the early echoes, Damping (500 Hz to " + "16 kHz) darkens the tail as it rings, and Bandwidth (500 Hz to 20 kHz) " + "darkens what goes in.", + ("TheSurface.test_the_spans",)), + ("Low Cut (20 to 500 Hz) keeps the bass out of the tank while the dry " + "keeps it.", + ("TheSurface.test_the_spans", + "T7LowCut.test_the_tank_sees_no_bass_on_bass_free_plate", + "T7LowCut.test_the_dry_is_flat")), + ("Mod Depth (0 to 2 ms) and Mod Rate (0.1 to 5 Hz) wobble two lines " + "inside the tank, Width (0 to 1) sets the stereo spread, and Tone (-12 " + "to +12 dB) tilts the tail.", + ("TheSurface.test_the_spans", + "T10Modulation.test_the_patches_modulation_spreads_it")), + ("Mix (0 to 2) is `audiodelays.Echo`'s: the dry at unity until 1, the " + "tail alone at 2.", + ("TheSurface.test_the_spans", "T7LowCut.test_the_dry_is_flat")), + ("Mix 0 is a byte-exact wire while the tank keeps ringing behind it.", + ("Tier1.test_mix_zero_is_a_wire_at_every_patch",)), + ("On the plate, Decay also moves the tail's loss corner, the way the EMT " + "140's damping panel does: open at Decay 8 s and above, at the Damping " + "setting at 1 s and below.", + ("TheCut.test_the_damper_law",)), + ("A Character or Size move re-cuts the tank: the tail drops to nothing " + "at the move, and the dry carries on without losing a frame.", + ("Recuts.test_a_recut_cuts_the_tail", + "Recuts.test_a_recut_keeps_every_frame_of_the_dry")), + ("`reset()` empties the tank the same way, keeps the dry, and restores " + "patch 0.", + ("Tier1.test_reset_clears_the_tail_and_restores_patch_0", + "Tier1.test_reset_keeps_the_dry_the_tank_holds")), + ("At each character's reference patch (Steel Plate, Live Room, Dark " + "Chamber, Concert Hall) at Size 1.0, the tail falls 60 dB at 500 Hz " + "within 12 % of Decay at 2, 3, 4, 6, 8 and 10 s, and on the hall from " + "4 s.", + ("T11DecayIsT60.test_every_character_lands_its_label",)), + ("Shorter Decays, other Sizes and the other patches are not claimed: " + "Damped Plate, Small Room and Live Room ring longer than their Decay " + "reads.", + ("PatchDecays.test_three_patches_ring_longer_than_their_decay",)), + ("With Damping at 1 kHz and Size 0.5, the room, chamber and hall at " + "Decay 8 and 10 s ring more than 12 % short of it: the class holds the " + "bass to 1.5 x Decay.", + ("TheCeiling.test_small_long_rooms_land_short_of_the_label", + "TheCeiling.test_without_the_cap_they_do_not")), + ("With Mod Depth at 0, a 1 kHz tone on Steel Plate or Concert Hall comes " + "out as one line, its sidebands more than 60 dB under it.", + ("T10Modulation.test_a_still_tank_is_one_line",)), + ("On those two patches as shipped, at every grid position from 17 to " + "64 of Mod " + "Depth (about 0.27 to 1 ms) and from 45 to 81 of Mod Rate (about 0.4 " + "to 1.2 Hz), tones at 300 Hz, 1 kHz and 3 kHz spread into sidebands " + "within 20 dB of the tone.", + ("T10Modulation.test_the_claimed_rectangle",)), + ("Outside that it is not claimed: on Steel Plate a 3 kHz tone at Mod " + "Depth position 81 (about 1.28 ms) and Mod Rate position 121 (about " + "4.16 Hz) reads more than 20 dB under.", + ("T10Modulation.test_where_it_does_not_hold",)), + ("There is no input gain, and the tank's lines clamp at the rail on " + "every write whatever Mix is: a steady 362 Hz tone at 8 000 LSB RMS " + "comes back more than 1 dB quieter in the tail than at 4 000.", + ("InputCeiling.test_the_tanks_lines_compress_from_8000_lsb_rms",)), + ("On 2 s of uniform noise at 4 000 LSB RMS at Mix 1, no shipped patch " + "reaches the rail at 48, 44.1 or 22.05 kHz; Bright Chamber at 44.1 kHz " + "is not claimed.", + ("InputCeiling.test_no_patch_reaches_the_rail_at_4000_lsb_rms",)), + ("**The patches:** Steel Plate (the defaults), Short Plate, Damped " + "Plate, Bass-Free Plate, Small Room, Live Room, Concert Hall, Dark " + "Chamber, Bright Chamber, Slow Bloom.", + ("TheSurface.test_patches_are_the_dossier_settings_on_the_grid", + "TheSurface.test_patch_0_is_the_constructor_grid")), + ("`tail_samples` bounds the frames until the output is exactly zero " + "once your input stops: 222 868 frames at the defaults at 48 kHz.", + ("Tier1.test_the_tail_reaches_exact_zero_inside_tail_samples", + "TheCut.test_tail_samples_at_the_patches")), + ("One int16 allocation holds the lines and 200 ms of predelay: 89 714 B " + "for Steel Plate at 48 kHz, and 146 914 B for the hall at Size 1.5, the " + "most it takes.", + ("TheCut.test_ram",)), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + ("Tier1.test_a_control_that_jumps_steps_the_output",)), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out. A tail cut short by a source that stopped carries on " + "when the source comes back.", + ("Tier1.test_the_tail_rings_only_while_the_source_feeds",)), + ("Asking for `character=\"spring\"` says it is parked: the tank has no " + "dispersive chain yet.", + ("TheSurface.test_options_clamp_default_and_refuse",)), + ("A value outside a macro's span clamps to the nearer stop, and NaN " + "takes the option's default.", + ("TheSurface.test_options_clamp_default_and_refuse",)), + ("On a board without `audioverb`, construction raises `ImportError`.", + ("TheSurface.test_without_audioverb_construction_raises_import_error",)), + ("Latency is zero: nothing looks ahead, and Predelay delays only the " + "tail.", + ("Tier1.test_click_delay_is_zero",)), +) + + +def _words(text): + return " ".join(text.split()) + + +def claims_problems(doc, claims, module): + """What is wrong between `doc` and `claims`: a sentence missing from the + docstring, a named test that does not exist, a number outside every + claimed sentence.""" + problems = [] + text = _words(doc) + for sentence, tests in claims: + words = _words(sentence) + if words not in text: + problems.append("not in the docstring: %r" % sentence) + text = text.replace(words, " ") + for name in tests: + owner, _, method = name.partition(".") + if not hasattr(getattr(module, owner, None), method): + problems.append("no test %s" % name) + stray = re.findall(r"[^\s]*\d[^\s]*", text) + if stray: + problems.append("numbers outside CLAIMS: %r" % stray) + return problems + + +def docstrings(): + return rv.__doc__ + "\n" + Reverb.__doc__ + + +class Claims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + self.assertEqual(claims_problems(docstrings(), CLAIMS, + sys.modules[__name__]), []) + + def test_the_check_can_fail(self): + module = sys.modules[__name__] + planted = docstrings() + "\nIt rings for 3 s." + self.assertEqual(len(claims_problems(planted, CLAIMS, module)), 1) + missing = CLAIMS + (("A sentence nobody wrote.", ()),) + self.assertEqual(len(claims_problems(docstrings(), missing, + module)), 1) + untested = CLAIMS[:-1] + ((CLAIMS[-1][0], ("Claims.test_nothing",)),) + self.assertEqual(len(claims_problems(docstrings(), untested, + module)), 1) + + def test_the_family_limits_are_word_for_word(self): + text = _words(rv.__doc__) + self.assertIn("**Limits shared by the family**", text) + for sentence in FAMILY: + self.assertIn(_words(sentence), text) + + +#: The two family sentences, as Brad ruled them on 2026-09-28. +FAMILY = ( + "A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "The tail rings only while the source keeps feeding: feed silence to " + "let it ring out. A tail cut short by a source that stopped carries on " + "when the source comes back.", +) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_saturation.py b/tests/test_cpython_effects_saturation.py index 5283d04..73626ac 100644 --- a/tests/test_cpython_effects_saturation.py +++ b/tests/test_cpython_effects_saturation.py @@ -1603,5 +1603,163 @@ def test_program_change_onto_digital_silence_stays_silent(self): % (patch, peak, self.RESIDUAL_LSB)) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + Off-centre Bias (patches 4 and 6) the plate pole is charged again, and + the charge leaves at most 1 LSB behind it, as it does at construction. + + """ + + CLS = rebuilt.Saturation + MIX = 2 + + #: Patches where the class answers silence with a fixed residue of its + #: own after any move (see the class docstring), and the bound it holds. + BOUNDED = {4: 1, 6: 1} + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + +class DrainlessSaturation(rebuilt.Saturation): + """Planted: the class before the fix. Bias back to the centre charges + nothing, so the pole bleeds the old offset out as a thump.""" + + def _charge_coupling(self, rewire=False): + if not self._bias: + self._charged = False + return rebuilt.Saturation._charge_coupling(self, rewire) + + +class UnchargedHysteresis(rebuilt.Saturation): + """Planted, a wrong cure: Hysteresis back in clears the shapers and + charges nothing, so off centre the half-band's climb back to the offset + passes the charged pole as a thump.""" + + def _apply_macro(self, index, position): + if index != 7: + return rebuilt.Saturation._apply_macro(self, index, position) + value = rebuilt._component.macro_value(self._MACRO_RANGES[7], + position) + if value > 0.0 and self._hysteresis <= 0.0 \ + and not self._constructing: + self._clear_nodes(only=self._shapers()) + self._hysteresis = value + for shaper in self._shapers(): + shaper.set(hysteresis=value) + + +class LevellingSaturation(rebuilt.Saturation): + """Planted: the class before the fix. A Bias re-charge pulled a block + off the dry tap "to level the split", which a `Waveshaper.play()` never + unlevelled: the dry leg ran a block ahead of the wet one.""" + + def _charge_coupling(self, rewire=False): + done = rebuilt.Saturation._charge_coupling(self, rewire) + if rewire: + rebuilt.audiocore.get_buffer(self._dry) + return done + + +class TheOperatingPointComesBackClean(unittest.TestCase): + """Bias, Hysteresis and the gain macros moved and moved back play + nothing out of silence (audiocomponents#113). Off centre the plate pole + holds `curve(bias) * post_gain`; Bias back to the centre left it to + bleed out (29 058 LSB), Headroom and Hysteresis moved it without a + re-charge (4 885, 14), and Hysteresis back in stepped from where its + play operator had frozen (314). And the Bias re-charge no longer pulls a + block off the dry tap, which put the dry leg a block ahead of the wet + and left the wet a block behind in the ring (13 071 at patch 7).""" + + CLS = rebuilt.Saturation + + def quiet(self, index, a, b, patch=None, cls=None): + before, after = stale.blip(cls or self.CLS, index, a, b, + patch=patch) + return after if before == 0 else None + + def test_bias_moves_play_nothing(self): + for patch in [None] + sorted(self.CLS.PATCHES): + for b in (0, 127): + _before, after = stale.blip(self.CLS, 4, 64, b, patch=patch) + self.assertLessEqual(after, max(1, stale.twin( + self.CLS, 4, 64, patch=patch)), (patch, b)) + + def test_hysteresis_and_headroom_moves_play_nothing(self): + for patch in [None, 0, 4, 6]: + bound = 1 if patch in (4, 6) else 0 + for index, a, b in ((7, 127, 0), (7, 0, 127), (3, 64, 0), + (0, 64, 0), (1, 64, 0)): + _before, after = stale.blip(self.CLS, index, a, b, + patch=patch) + self.assertLessEqual(after, max(bound, stale.twin( + self.CLS, index, a, patch=patch)), (patch, index, a, b)) + + def test_the_dry_leg_stays_in_line_through_bias_moves(self): + self.assertLessEqual(stale.in_step(self.CLS, 4, 64, 0, patch=7), 3) + + def test_the_old_class_and_a_wrong_cure_are_red(self): + self.assertGreater(stale.blip(DrainlessSaturation, 4, 64, 0)[1], + 10000) + self.assertGreater(stale.blip(UnchargedHysteresis, 7, 127, 0, + patch=4)[1], 1000) + self.assertGreater(stale.in_step(LevellingSaturation, 4, 64, 0, + patch=7), 1000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py new file mode 100644 index 0000000..80dea6a --- /dev/null +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -0,0 +1,2252 @@ +"""`SlapbackDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/SlapbackDelay.md` +(frozen at anchor 7a5a4cb, the Station A critique's re-freeze); its Tier 2 +rows are T1-T5. Each row here is the measurement at a few of the row's +cells and the same measurement shown red on a planted fault of the same +kind, at the constructor defaults (or, for a clause the defaults do not +reach, at the row's own cell, said where it is). Every fault is shown +unreachable from every macro position and shipped patch, and every row's +measurement is shown red on the class built as a wire. The full spans, the +three interpreters and the rates live in the evidence pack, not in this +file. + +Every law a measurement checks against is written out here from the +dossier, never taken from the class: the whole-frame landing, the Wow map, +the Time span. + +The class is reached by `rebuilt.module_class("SlapbackDelay")`, which is also what +`audioeffects.SlapbackDelay` serves since its adoption on 2026-09-28. + +The pin's move to audiodsp v0.6.3rc1 (2026-09-28) took out the tracking +Tone stop and the stall-window stepping: Tone out hands exactly 0 and is +byte-identical to no filter (planted: the retired tracking stop), Repeats +0.5 with Tone in reaches zero as set (planted: the retired stepping), and a +Wow move no longer steps (planted: the read head moved by the whole change +at once). Two surface tests pin the Times the node lands off the whole +frame at 44.1 and 22.05 kHz. +""" + +import math +import os +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import audiomixer # noqa: E402 +import audioroute # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import slapbackdelay as sd # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls as dd_clear_of_stalls) +from tools.effect_measurements import instantaneous_hz # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +BLOCK = 256 +RATES = (48000, 44100, 22050) +TIME_I, LEVEL_I, SATURATION_I, TONE_I, WOW_I, REPEATS_I = range(6) + +SlapbackDelay = rebuilt.module_class("SlapbackDelay") + + +# -------------------------------------------------------------------------- +# The dossier's laws, written out independently of the class + + +def law_frames(time_ms, rate): + """Section 6: the nearest whole frame, floor(ms fs / 1000 + 0.5).""" + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +def law_time_ms(midi): + """Section 6: Time is 40-250 ms, log, on the 0-127 grid.""" + return 40.0 * 6.25 ** (midi / 127.0) + + +def law_wow_ms(cents): + """A4: the rising side of a 0.7 Hz wow reaches `cents` at this depth.""" + cents = min(max(cents, 0.0), 3.5) + return (2.0 ** (cents / 1200.0) - 1.0) / (2.0 * math.pi * 0.7) * 1000.0 + + +def law_wow_frames(cents, rate): + return int(math.ceil(law_wow_ms(cents) * rate / 1000.0)) + + +#: Section 6's patch table, in engineering units, which `macro_of` puts on +#: the grid: (Time ms, Level, Saturation, Tone Hz, Wow cents, Repeats). +DOSSIER_PATCHES = ( + ("Single Slap", (135.0, 0.35, 0.15, 20000.0, 1.0, 0.0)), + ("Short Slap", (85.0, 0.35, 0.15, 20000.0, 1.0, 0.0)), + ("Doubling", (40.0, 0.5, 0.15, 20000.0, 2.0, 0.0)), + ("Hot Return", (135.0, 0.35, 0.7, 20000.0, 1.0, 0.0)), + ("Two Repeats", (135.0, 0.35, 0.15, 20000.0, 1.0, 0.35)), + ("Dark Slap", (135.0, 0.35, 0.15, 5000.0, 1.0, 0.0)), +) +DOSSIER_SPANS = ((40.0, 250.0, "log"), (0.0, 2.0), (0.0, 1.0), + (2000.0, 20000.0, "log"), (0.0, 3.5), (0.0, 0.6)) + + +# -------------------------------------------------------------------------- +# Planted faults, one or more per Tier 2 row, each of the row's own kind + + +class Frames441Slapback(SlapbackDelay): + """Time landed with 44.1 kHz frames at every rate. **Off the surface at + 22.05 kHz only** (fix round 1): there it lands 5 954 frames, past Time's + 250 ms top, and the node clamps it at the line (250.9 ms). At 48 kHz + the same 5 954 frames are what the clean class plays at `time_ms` + 124.041667 (the gate audit's dial: 0 samples differ), and at 44.1 kHz + it is the right count. So it is T2's 22.05 kHz fault and nothing + else; `ShortHalfFrameSlapback` is the delay fault at every rate.""" + + NAME = 'SlapbackDelay' + + def _node_time_ms(self, frames): + del frames + return law_frames(self._time_ms(), 44100) * 1000.0 / self._sample_rate + + +class ShortHalfFrameSlapback(SlapbackDelay): + """T1's delay clause and T2 (fix round 1): Time landed 8 % short and + half a frame over, `floor(0.92 T) + 0.5` frames. The clean class lands + every Time on a whole frame, so no Time position or constructor value + hands the node a fraction; at the defaults the repeat comes 124.2 ms + after the dry at every rate.""" + + NAME = 'SlapbackDelay' + + def _node_time_ms(self, frames): + return ((int(0.92 * frames) + 0.5) * 1000.0 / self._sample_rate) + + +class FloorSlapback(SlapbackDelay): + """**Retired at fix round 1.** A floor on the loop gain, Repeats 0 + handing the node 0.05. The clean class renders the same bytes at + Repeats 0.05 (`set_macro(5, 10.5833)`), so it is a surface state, not + a fault; `TheRetiredFaultsAreDialable` keeps that as a test.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + if self._feedback <= 0.0: + self._delay.set(feedback=0.05) + + +#: The second node's mix: at Level 0.35 the 2T copy is 0.35 x 0.0614 of the +#: click against a first repeat of 0.35 + 0.0614, about -26 dB. +SECOND_MIX = 0.0614 + + +class HalfFrameSlapback(SlapbackDelay): + """The whole-frame landing's surface test: Time handed half a frame + over the whole frame, so the repeat splits across two frames.""" + + NAME = 'SlapbackDelay' + + def _node_time_ms(self, frames): + return (frames + 0.5) * 1000.0 / self._sample_rate + + +class SecondRepeatSlapback(SlapbackDelay): + """T1's no-second-repeat clause (fix round 1): a second node after the + first, at the same whole-frame Time, dry at unity and 0.0614 wet. It + puts a copy of the first repeat at 2T, about 26 dB down, and nothing at + 3T or 4T. Every Repeats setting above 0 puts energy at 3T as well, so + no surface state renders a lone 2T repeat.""" + + NAME = 'SlapbackDelay' + + def _build(self, **options): + self._second = None + SlapbackDelay._build(self, **options) + second = audioecho_node(self._sample_rate, self._channel_count) + second.play(self._delay) + self._own(second, reset=second.clear) + self._second = second + self._output = second + self._refresh() + + def _refresh(self): + SlapbackDelay._refresh(self) + if getattr(self, "_second", None) is not None: + self._second.set(delay_slew=sd.SLEW, delay_ms=self._node_ms, + feedback=0.0, mix=SECOND_MIX, loop_drive=0.0, + damping_hz=0.0, cut_hz=0.0, wow_hz=sd.WOW_HZ, + wow_depth_ms=0.0) + + +#: The split build's crossover and its offset, as a fraction of T. +SPLIT_HZ = 4000.0 +SPLIT_SHIFT = 0.004 + + +class SplitSlapback(SlapbackDelay): + """T2's agreement clause (Station A's split build, run on the class at + fix round 1): the source split in two, one node reading 0.4 % early + through the loop low-pass at 4 kHz with the dry, and a second, wet only, + reading 0.4 % late through the loop high-pass at the same coefficient. + The low-pass and high-pass sum to the identity, so each band's comb is + a clean 1/T', and the two bands' spacings differ by 0.8 %. The late + node reads on a whole frame (Wow 0): with the class's wow on it too, + the split reads -0.03 % between the fits at 22.05 kHz at click 100, + green, and -0.75 to -0.84 % at other wow phases. The class builds one + node, so no surface state hands two delays.""" + + NAME = 'SlapbackDelay' + + def _build(self, **options): + self._high = None + SlapbackDelay._build(self, **options) + rate, channels = self._sample_rate, self._channel_count + split = audioroute.Splitter(self._source, taps=2) + low_in, high_in = split.tap(0), split.tap(1) + self._delay.play(low_in) + high = audioecho_node(rate, channels) + high.play(high_in) + mixer = audiomixer.Mixer(voice_count=2, **self._pcm(1024)) + mixer.voice[0].play(self._delay, loop=True) + mixer.voice[1].play(high, loop=True) + mixer.voice[0].level = 1.0 + self._own(mixer, reset=False) + self._own(high, reset=high.clear) + self._own(low_in, reset=False) + self._own(high_in, reset=False) + self._own(split, reset=False) + self._high, self._mixer = high, mixer + self._output = mixer + self._refresh() + + def _refresh(self): + SlapbackDelay._refresh(self) + if getattr(self, "_high", None) is None: + return + rate = self._sample_rate + shift = int(round(SPLIT_SHIFT * self._frames)) + corner = nominal_damping_hz(self._hz(SPLIT_HZ), rate) + self._delay.set(delay_ms=(self._frames - shift) * 1000.0 / rate, + damping_hz=corner) + self._high.set(delay_slew=sd.SLEW, + delay_ms=(self._frames + shift) * 1000.0 / rate, + feedback=0.0, mix=2.0, + loop_drive=self._value(SATURATION_I), damping_hz=0.0, + cut_hz=corner, wow_hz=sd.WOW_HZ, wow_depth_ms=0.0) + self._mixer.voice[1].level = min(self._value(LEVEL_I), 1.0) + + +def audioecho_node(rate, channels): + """A bare node on the class's line, for the faults that add one.""" + return sd.audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=sd.LINE_MS, + delay_ms=135.0, feedback=0.0, mix=0.0, damping_hz=0.0, cut_hz=0.0, + delay_slew=0.0) + + +class PannedSlapback(SlapbackDelay): + """T3: the node handed `input_pan` -0.2, so the repeat leans left.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + self._delay.set(input_pan=-0.2) + + +class OpenTopSlapback(SlapbackDelay): + """**Retired as T4's walked fault at fix round 1.** The top stop hands + the node the pre-warped 20 kHz (13 095.11 Hz at 48 kHz) instead of + exactly 0. No macro position reaches it at 48 and 44.1 kHz, but the + constructor's `tone_hz=19999.999` renders it byte for byte at three + rates, and at 22.05 kHz grid 94-126 hand the same 6 183.68. It stays + as a red on the out clause; `RawTopSlapback` is the walked fault.""" + + NAME = 'SlapbackDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return nominal_damping_hz(self._hz(20000.0), self._sample_rate) + return SlapbackDelay._tone_damping(self, position) + + +class RawTopSlapback(SlapbackDelay): + """T4's out clause (fix round 1): the top stop hands the node a raw + `damping_hz` of 20 000, not 0. Every in-circuit position and + constructor value hands a pre-warped corner, at most 13 095.11 / + 12 266.31 / 6 183.68 Hz at 48 / 44.1 / 22.05 kHz, so nothing on the + surface hands 20 000 or renders its coefficient.""" + + NAME = 'SlapbackDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return 20000.0 + return SlapbackDelay._tone_damping(self, position) + + +class RawToneSlapback(SlapbackDelay): + """T4's corner clauses: Tone handed raw, not pre-warped. Silent at the + defaults (Tone out hands 0 either way). Read at the 15 kHz cell, where + the one-pole then has no half-power point below Nyquist. On the shipped + patches it stays inside the bar at 48 and 44.1 kHz (patch 5's raw + 5 042 Hz lands +3.87 / +4.63 % off), and at 22.05 kHz patch 5 reads + +24.92 %, red: the corners have a fault red at a shipped patch there + (`test_a_raw_tone_is_red_at_patch_5_at_22k`).""" + + NAME = 'SlapbackDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[TONE_I], position) + return self._hz(corner) + + +class NoTwoPiSlapback(SlapbackDelay): + """T5: the depth map without its 2 pi, a 6.3x deeper wobble.""" + + NAME = 'SlapbackDelay' + + def _wow_depth_ms(self, cents): + return sd.wow_depth_ms(cents) * 2.0 * math.pi + + +class NoWowTailSlapback(SlapbackDelay): + """Tier 1's tail: the bound without the wow's depth, which the repeat + of a burst outlives by the frames the wow moves it late.""" + + NAME = 'SlapbackDelay' + + @property + def tail_samples(self): + memory, excess = sd.tone_excess(self._damping, self._sample_rate) + laps = sd.laps_to_zero(self._feedback, excess) + return int(laps * (self._reach + 1 + memory)) + + +class SteppedSlapback(SlapbackDelay): + """The workaround retired at audiodsp v0.6.3rc1: with Tone in, Repeats + handed to the node at the nearer edge of the stall window at 0.5 + (`clear_of_stalls`), a Feedback nobody set.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + if self._damping > 0.0 and self._feedback > 0.0: + excess = sd.tone_excess(self._damping, self._sample_rate)[1] + self._feedback = dd_clear_of_stalls(self._feedback, excess) + self._delay.set(feedback=self._feedback) + + +class FrozenToneSlapback(SlapbackDelay): + """Tier 1's silence clause after a Tone move: the out stop leaves the + low-pass in at 0.001 Hz, where its float32 coefficient is one step + above 0 and its state cannot move, so it holds what it held, as the + node's out stop did up to v0.6.2 (the review's stale-state defect), + and plays it out of silence.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + if self._macros[TONE_I] >= 1.0: + self._delay.set(damping_hz=0.001) + + +class TrackingSlapback(SlapbackDelay): + """The workaround retired at audiodsp v0.6.3rc1: once Tone has been in, + the out stop hands `damping_hz` at 32 x the rate instead of 0.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + if self._damping > 0.0: + self._was_in = True + elif getattr(self, "_was_in", False): + self._delay.set(damping_hz=32.0 * self._sample_rate) + + def _clear(self): + SlapbackDelay._clear(self) + self._was_in = False + + +class JumpWowSlapback(SlapbackDelay): + """Tier 1's click-free Wow: a Wow move that moves the read head by the + whole change in depth at once, as the node did at the wow's crest up to + v0.6.2 (it added depth x wow with no ramp).""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + old = self._wow_ms + SlapbackDelay._refresh(self) + if not self._seeding and not self._deferred and self._wow_ms != old: + self._delay.set(delay_slew=0.0, + delay_ms=self._node_ms + self._wow_ms - old) + + +class TargetOnlyTailSlapback(SlapbackDelay): + """Tier 1's tail after a falling move: the bound from the target Time + alone, while the read head is still walking down from the old one.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + self._reach = self._frames + + +class EchoForgetsTimeSlapback(SlapbackDelay): + """Time's readback (fix round 1): any Time move drops the constructor's + exact Time, as the class did before, so a host echoing `get_macro(0)` + back moves the 44.1 kHz default from 5 954 frames to 5 953.""" + + NAME = 'SlapbackDelay' + + def _apply_macro(self, index, position): + if index == TIME_I and not self._seeding: + self._time_exact = None + if not self._deferred: + self._refresh() + + +class JumpTimeSlapback(SlapbackDelay): + """A Time move that jumps: the node's walk turned off, so the read head + lands on the new Time at once (trial, 2026-09-29).""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + self._delay.set(delay_slew=0.0) + + +# -------------------------------------------------------------------------- +# Sources and pulls + + +def to_source(values, channels=2, rate=RATE): + """A mono sequence, copied to every channel.""" + x = np.clip(np.round(np.asarray(values, dtype=np.float64)), + -32768, 32767).astype(np.int16) + data = array("h", np.repeat(x[:, None], channels, axis=1) + .reshape(-1).tobytes()) + return probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), data + + +def silence_src(frames, channels=2, rate=RATE): + return probes.ArraySource(array("h", [0] * (frames * channels)), + rate=rate, channels=channels, block=BLOCK) + + +def pull(effect, frames, channels=None, on_block=None): + """(frames, channels) int16; `on_block(frame)` runs before each block.""" + channels = channels or effect.channel_count + out = array("h") + while len(out) < frames * channels: + if on_block is not None: + on_block(len(out) // channels) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + out.extend([0] * (frames * channels - len(out))) + break + out.extend(memoryview(data).cast("h")) + return np.array(out[:frames * channels], + dtype=np.int16).reshape(-1, channels) + + +def render(cls, values, rate=RATE, channels=2, macros=None, **options): + """`values` through `cls` built with `options`, then `macros` + ({index: MIDI}) set, so a macro can be held over a patch.""" + source, _ = to_source(values, channels, rate) + effect = cls(source, sample_rate=rate, **options) + for index in sorted(macros or {}): + effect.set_macro(index, macros[index]) + return pull(effect, len(values), channels) + + +def cell_time_and_wow(options): + """The cell's Time and Wow by the dossier's laws: a constructor value + as given, a patch at its grid values.""" + if "patch" in options: + midi = SlapbackDelay.PATCHES[options["patch"]][1] + return law_time_ms(midi[TIME_I]), 3.5 * midi[WOW_I] / 127.0 + return options.get("time_ms", 135.0), options.get("wow_cents", 1.0) + + +# -------------------------------------------------------------------------- +# T1: one repeat, and no second one + + +def t1_read(cls, rate=RATE, channels=2, click_lsb=32767, **options): + """The click response at frame 0: energy in the windows + +-k (ceil(wow) + 2) frames around kT (+64 with Tone in), k = 1..4, and + the delay in ms by the centroid around the first non-zero sample after + the click, wherever it lands.""" + time_ms, wow = cell_time_and_wow(options) + T = law_frames(time_ms, rate) + w = law_wow_frames(wow, rate) + tone_in = options.get("tone_hz", 20000.0) < 20000.0 + tail = 64 if tone_in else 0 + n = int(4.6 * T) + 200 + x = np.zeros(n) + x[0] = click_lsb + y = render(cls, x, rate, channels, **options)[:, 0] + energies = [] + for k in (1, 2, 3, 4): + lo = max(0, k * T - k * (w + 2)) + hi = k * T + k * (w + 2) + tail + 1 + energies.append(float((y[lo:hi].astype(float) ** 2).sum())) + nonzero = np.nonzero(y[1:])[0] + if not len(nonzero): + return energies, float("nan") + lo = int(nonzero[0]) + 1 + seg = np.abs(y[lo:lo + 2 * (w + 2) + tail + 1].astype(float)) + centroid = lo + float((np.arange(len(seg)) * seg).sum() / seg.sum()) + return energies, centroid * 1000.0 / rate + + +def t1_measure(cls, rate=RATE, channels=2, click_lsb=32767, delay_bar=True, + **options): + """Repeats 0: a first repeat, exact zero in the 2T-4T windows, and (at + the defaults) the delay in 130-140 ms.""" + energies, delay_ms = t1_read(cls, rate, channels, click_lsb, **options) + red = [] + if energies[0] <= 0.0: + red.append("no first repeat") + if any(e > 0.0 for e in energies[1:]): + red.append("a 2T-4T window is not exact zero") + if delay_bar and not 130.0 <= delay_ms <= 140.0: + red.append("delay %.3f ms" % delay_ms) + second = (10.0 * math.log10(energies[1] / energies[0]) + if energies[0] > 0.0 and energies[1] > 0.0 else None) + return {"passed": not red, "red": red, "delay_ms": delay_ms, + "second_db": second} + + +def t1_control(cls, rate=RATE, **options): + """At Repeats above 0 the 2T window carries more than -20 dB re the + first.""" + energies, _ = t1_read(cls, rate, **options) + if energies[0] <= 0.0 or energies[1] <= 0.0: + return {"passed": False, "second_db": None} + second = 10.0 * math.log10(energies[1] / energies[0]) + return {"passed": second > -20.0, "second_db": second} + + +# -------------------------------------------------------------------------- +# T2: the comb's teeth are 1/T + + +PAD_S = 20.0 + + +#: The first tooth: the first local maximum of the autocorrelation at or +#: above this fraction of its tallest (fix round 1). +FIRST_TOOTH = 0.5 + + +def comb_guess(mag, bin_hz, minimum_hz=1.0): + """The spacing's first guess: an autocorrelation along frequency, by + FFT, 1 Hz floor, taking its **first** tooth. It takes no law. + + Fix round 1: the tallest tooth is not always the first. Where 1/T + falls near half a bin of the 20 s pad, the first tooth is sampled off + its top and the second outranks it (Time grid 117, 120 and 124 at 48 + and 44.1 kHz), and the notches were then numbered two to a gap. Every + tooth of a comb's autocorrelation stands at nearly the same height, so + the first local maximum at or above half the tallest is the first + tooth.""" + m = mag - mag.mean() + size = 1 << int(math.ceil(math.log(2 * len(m), 2))) + spec = np.fft.rfft(m, n=size) + corr = np.fft.irfft(spec * np.conj(spec), n=size)[:len(m)] + low = max(1, int(minimum_hz / bin_hz)) + c = corr[low:] + tall = FIRST_TOOTH * float(c.max()) + tops = np.nonzero((c[1:-1] > c[:-2]) & (c[1:-1] >= c[2:]) + & (c[1:-1] >= tall))[0] + peak = low + (int(tops[0]) + 1 if len(tops) else int(np.argmax(c))) + y0, y1, y2 = corr[peak - 1], corr[peak], corr[peak + 1] + den = y0 - 2 * y1 + y2 + off = 0.5 * (y0 - y2) / den if den != 0 else 0.0 + return (peak + off) * bin_hz + + +def old_comb_guess(mag, bin_hz, minimum_hz=1.0): + """The guess before fix round 1, kept as a planted fault: the + autocorrelation's tallest tooth, which is sometimes the second.""" + m = mag - mag.mean() + size = 1 << int(math.ceil(math.log(2 * len(m), 2))) + spec = np.fft.rfft(m, n=size) + corr = np.fft.irfft(spec * np.conj(spec), n=size)[:len(m)] + low = max(1, int(minimum_hz / bin_hz)) + peak = low + int(np.argmax(corr[low:])) + y0, y1, y2 = corr[peak - 1], corr[peak], corr[peak + 1] + den = y0 - 2 * y1 + y2 + off = 0.5 * (y0 - y2) / den if den != 0 else 0.0 + return (peak + off) * bin_hz + + +def notch_spacing(mag, bin_hz, lo, hi, guess): + """Notches as local minima (parabolic sub-bin), numbered by consecutive + gaps, spacing by least squares. (nan, count) under three notches.""" + a, b = int(lo / bin_hz), int(hi / bin_hz) + half = max(2, int(0.4 * guess / bin_hz)) + freqs = [] + k = a + half + while k < b - half: + seg = mag[k - half:k + half + 1] + j = int(np.argmin(seg)) + if j == half and seg.max() - seg.min() > 1e-9 * max(seg.max(), 1): + y0, y1, y2 = mag[k - 1], mag[k], mag[k + 1] + den = y0 - 2 * y1 + y2 + off = 0.5 * (y0 - y2) / den if den != 0 else 0.0 + freqs.append((k + off) * bin_hz) + k += half + else: + k += 1 + if len(freqs) < 3: + return float("nan"), len(freqs) + freqs = np.array(freqs) + index = np.concatenate([[0.0], np.cumsum(np.round(np.diff(freqs) + / guess))]) + return float(np.polyfit(index, freqs, 1)[0]), len(freqs) + + +def t2_measure(cls, rate=RATE, click_lsb=32767, click_at=100, **options): + """The click response at Level 0.35, zero-padded to 20 s: the notch + spacing over 200 Hz-2 kHz and 8-10 kHz, each within 0.5 % of 1/T and + within 0.5 % of each other.""" + time_ms, _ = cell_time_and_wow(options) + T = law_frames(time_ms, rate) + law = rate / float(T) + n = click_at + T + 400 + x = np.zeros(n) + x[click_at] = click_lsb + y = render(cls, x, rate, 2, **options)[:, 0].astype(float) + size = int(PAD_S * rate) + mag = np.abs(np.fft.rfft(y, n=size)) + bin_hz = rate / float(size) + return t2_read(mag, bin_hz, law) + + +def t2_read(mag, bin_hz, law): + """The fits on a magnitude spectrum. A fit that raises (a `LinAlgError` + from `polyfit`) reads red and is reported, never skipped.""" + guess = comb_guess(mag, bin_hz) + try: + low, low_n = notch_spacing(mag, bin_hz, 200.0, 2000.0, guess) + high, high_n = notch_spacing(mag, bin_hz, 8000.0, 10000.0, guess) + except Exception as exc: # noqa: BLE001 - a crash is a red cell + nan = float("nan") + return {"passed": False, "deviations": (nan, nan, nan), + "notches": (0, 0), "guess": guess, + "crash": type(exc).__name__} + deviations = (100.0 * (low - law) / law, 100.0 * (high - law) / law, + 100.0 * (high - low) / low) + passed = all(abs(d) <= 0.5 for d in deviations) # NaN reads red + return {"passed": passed, "deviations": deviations, + "notches": (low_n, high_n), "guess": guess, "crash": None} + + +# -------------------------------------------------------------------------- +# T3: mono, the dry's position, and present + + +def t3_measure(cls, rate=RATE, **options): + """On the channel-identical full-scale ramp: max |L - R| is 0 LSB, the + one-channel render is the stereo render's left channel sample for + sample, and at Level 2 the output is exact zero before T - (ceil(wow) + + 2) frames and carries the probe's energy, shifted by T, within + 4 dB.""" + time_ms, wow = cell_time_and_wow(options) + T = law_frames(time_ms, rate) + w = law_wow_frames(wow, rate) + 2 + frames = T + 4096 + ramp = probes.ramp_fs(frames=frames, channels=1) + stereo = render(cls, ramp, rate, 2, **options) + mono = render(cls, ramp, rate, 1, **options)[:, 0] + lr = int(np.abs(stereo[:, 0].astype(np.int32) + - stereo[:, 1].astype(np.int32)).max()) + mono_diff = int(np.count_nonzero(mono != stereo[:, 0])) + wet = render(cls, ramp, rate, 2, macros={LEVEL_I: 127}, **options) + head = int(np.count_nonzero(wet[:T - w])) + y = wet[T - w:, 0].astype(float) + s = np.array(ramp, dtype=np.float64)[:frames - T + w] + energy_db = 10.0 * math.log10(max((y ** 2).sum(), 1e-12) + / (s ** 2).sum()) + wet_lr = int(np.abs(wet[:, 0].astype(np.int32) + - wet[:, 1].astype(np.int32)).max()) + passed = (lr == 0 and wet_lr == 0 and mono_diff == 0 and head == 0 + and abs(energy_db) <= 4.0) + return {"passed": passed, "lr": max(lr, wet_lr), "mono_diff": mono_diff, + "head": head, "energy_db": energy_db} + + +#: T3's presence on steady tones, as measured (re-audit fix round 1, +#: dossier revision). No band is claimed: two sentences that claimed one +#: were each disconfirmed (fix rounds 1 and 2). The 4 dB bar is the frozen +#: row's, on `ramp_fs` and `sweep_log`. On a steady tone the repeat's level +#: depends on the frequency (the wow's fractional read, section 8.3, takes +#: the top of the band down) and on the level (Saturation's cubic takes a +#: full-scale tone down), and this table is what the class reads: Level 2, +#: `presence_db`, a 2 s tone, head 0 in every cell. Keys are (cell, +#: options, dBFS, fraction of the rate); values are dB at 48 / 44.1 / +#: 22.05 kHz. Any reading more than PRESENCE_PIN_DB from its entry is red, +#: so the words cannot drift from the class either way. +PRESENCE_TABLE = ( + ("defaults", {}, -20.0, 1.0 / 3.0, (-2.90, -3.29, -3.01)), + ("Saturation 1", {"saturation": 1.0}, -20.0, 1.0 / 3.0, + (-2.91, -3.30, -3.02)), + ("patch 3", {"patch": 3}, -20.0, 1.0 / 3.0, (-2.81, -3.37, -3.11)), + ("defaults", {}, 0.0, 1.0 / 3.0, (-3.09, -3.47, -3.20)), + ("Saturation 1", {"saturation": 1.0}, 0.0, 1.0 / 3.0, + (-4.29, -4.57, -4.35)), + ("patch 3", {"patch": 3}, 0.0, 1.0 / 3.0, (-3.78, -4.24, -4.01)), + ("Saturation 1", {"saturation": 1.0}, 0.0, 0.30, (-3.87, -4.08, -3.92)), + ("Saturation 1", {"saturation": 1.0}, -3.0, 1.0 / 3.0, + (-3.57, -3.92, -3.66)), +) +PRESENCE_PIN_DB = 0.02 + + +def dbfs_amp(dbfs): + return 32767.0 * 10.0 ** (dbfs / 20.0) + + +def presence_db(cls, values, rate=RATE, **options): + """Level 2: (non-zero frames before T - (ceil(wow) + 2), the energy + after that against the source's over the same number of frames).""" + time_ms, wow = cell_time_and_wow(options) + T = law_frames(time_ms, rate) + w = law_wow_frames(wow, rate) + 2 + y = render(cls, values, rate, 2, macros={LEVEL_I: 127}, + **options)[:, 0].astype(float) + wet = y[T - w:] + s = np.asarray(values, dtype=np.float64)[:len(wet)] + return (int(np.count_nonzero(y[:T - w])), + 10.0 * math.log10(max((wet ** 2).sum(), 1e-12) + / (s ** 2).sum())) + + +def tone(hz, rate, seconds=2.0, amp=3277): + n = int(seconds * rate) + return np.round(amp * np.sin(2.0 * math.pi * hz * np.arange(n) / rate)) + + +# -------------------------------------------------------------------------- +# T4: Tone's number is the corner it achieves, and out is out + + +#: T4's out clause material since fix round 1: 0 dBFS `noise_det`, 4 s. The +#: full-scale ramp reads 0 differing in every Tone-out state, so it could +#: not see the 1 LSB the coefficient-1 stop leaves on a fractional tap. +OUT_SECONDS = 4.0 + + +def node_pull(rate, channels, data, **options): + """`data` through a bare node on the class's 251 ms line.""" + node = sd.audioecho.FeedbackDelay(sample_rate=rate, + channel_count=channels, + max_delay_ms=251.0, **options) + node.play(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK)) + out = array("h") + while len(out) < len(data): + chunk = bytes(audiocore.get_buffer(node)[1]) + if not chunk: + break + out.extend(memoryview(chunk).cast("h")) + return np.array(out[:len(data)], dtype=np.int16) + + +def t4_out_differing(cls, rate=RATE, channels=2, seconds=OUT_SECONDS): + """The class at its defaults (Tone out) against a node given no + `damping_hz`, at the dossier's settings for the defaults, on 0 dBFS + `noise_det`: the samples that differ.""" + frames = int(seconds * rate) + data = probes.noise_det(frames=frames, dbfs=0.0, channels=channels) + effect = cls(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), sample_rate=rate) + out = pull(effect, frames, channels).reshape(-1) + ref = node_pull(rate, channels, data, + delay_ms=law_frames(135.0, rate) * 1000.0 / rate, + feedback=0.0, mix=0.35, loop_drive=0.15, wow_hz=0.7, + wow_depth_ms=law_wow_ms(1.0), delay_slew=0.1875) + return int(np.count_nonzero(out != ref)) + + +def t4_out_history(cls, rate=RATE, channels=2, seconds=OUT_SECONDS, + steps=(), **ctor): + """The restated out clause: build with `ctor`, apply `steps` (patch + indices, or (macro, MIDI) pairs) before the first pull, and compare + with a node given every option the class handed except `damping_hz`, + on 0 dBFS `noise_det`. (differing, max |difference|, handed + `damping_hz`).""" + frames = int(seconds * rate) + data = probes.noise_det(frames=frames, dbfs=0.0, channels=channels) + with NodeSpy(): + effect = cls(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), sample_rate=rate, + **ctor) + for step in steps: + if isinstance(step, tuple): + effect.set_macro(*step) + else: + effect.program_change(step) + handed = dict(effect._delay._handed) + out = pull(effect, frames, channels).reshape(-1) + damping = handed.pop("damping_hz") + ref = node_pull(rate, channels, data, **handed) + d = np.abs(out.astype(np.int32) - ref.astype(np.int32)) + return int(np.count_nonzero(d)), int(d.max()), damping + + +def t4_ratio(cls, tone_hz, rate=RATE): + """(freqs, dB): the click response at 8 192 LSB with Tone at `tone_hz` + over the same class's Tone-out response, one pass, wet only, + Saturation 0, Wow 0, Time 135 ms, a 65 536-point FFT.""" + T = law_frames(135.0, rate) + size = 65536 + n = T + size + x = np.zeros(n) + x[0] = 8192 + common = {"time_ms": 135.0, "level": 2.0, "saturation": 0.0, + "wow_cents": 0.0, "repeats": 0.0} + y_in = render(cls, x, rate, 2, tone_hz=tone_hz, **common)[:, 0] + y_out = render(cls, x, rate, 2, tone_hz=20000.0, **common)[:, 0] + a = np.abs(np.fft.rfft(y_in[T - 32:T - 32 + size].astype(float))) + b = np.abs(np.fft.rfft(y_out[T - 32:T - 32 + size].astype(float))) + freqs = np.fft.rfftfreq(size, 1.0 / rate) + return freqs, 20.0 * np.log10(np.maximum(a, 1e-12) / np.maximum(b, 1e-12)) + + +def half_power_hz(freqs, db): + """The first -3.01 dB crossing, interpolated; `None` below Nyquist + never.""" + below = np.nonzero(db[1:] < -3.0103)[0] + if not len(below): + return None + i = int(below[0]) + 1 + f0, f1, d0, d1 = freqs[i - 1], freqs[i], db[i - 1], db[i] + return float(f0 + (f1 - f0) * (-3.0103 - d0) / (d1 - d0)) + + +def t4_corner(cls, tone_hz, label, rate=RATE, passband=False): + """The half-power point within +-10 % of the cell's `label`; with + `passband`, 30 Hz within 0.05 dB, 1 kHz within 0.1 dB and nothing more + than 3.1 dB down in 30 Hz-15 kHz.""" + freqs, db = t4_ratio(cls, tone_hz, rate) + corner = half_power_hz(freqs, db) + passed = corner is not None and abs(corner / label - 1.0) <= 0.10 + values = {"corner": corner} + if passband: + at = lambda hz: float(db[int(np.argmin(np.abs(freqs - hz)))]) # noqa: E731 + band = (freqs >= 30.0) & (freqs <= 15000.0) + values.update(at30=at(30.0), at1k=at(1000.0), + worst=float(db[band].min())) + passed = (passed and abs(values["at30"]) <= 0.05 + and abs(values["at1k"]) <= 0.1 and values["worst"] >= -3.1) + values["passed"] = passed + return values + + +def t4_measure(cls, rate=RATE): + """The out stop byte-identical to no `damping_hz`, and the 15 kHz and + 2 kHz corners.""" + differing = t4_out_differing(cls, rate) + top = t4_corner(cls, 15000.0, 15000.0, rate, passband=True) + bottom = t4_corner(cls, 2000.0, 2000.0, rate) + return {"passed": differing == 0 and top["passed"] and bottom["passed"], + "differing": differing, "top": top, "bottom": bottom} + + +# -------------------------------------------------------------------------- +# T5: Wow's cents are real cents + + +def t5_cents(cls, rate=RATE, amp=12000, saturation=0.15, **options): + """The repeat's pitch deviation at the 0.7 Hz wow rate, rising side, in + cents: 440 Hz, Level 2, Repeats 0, Tone out, Time 135 ms, 2 s from the + first repeat; the kit's `instantaneous_hz`, 5 % trimmed at each end, + least squares on a constant plus a cosine and a sine at 0.7 Hz.""" + T = law_frames(135.0, rate) + n = T + 2 * rate + x = amp * np.sin(2.0 * math.pi * 440.0 * np.arange(n) / rate) + options.setdefault("time_ms", 135.0) + y = render(cls, x, rate, 2, macros={LEVEL_I: 127}, repeats=0.0, + saturation=saturation, **options)[:, 0].astype(float) + f = instantaneous_hz(y[T + 64:], rate) + edge = int(0.05 * len(f)) + f = f[edge:len(f) - edge] + t = (np.arange(len(f)) + edge) / float(rate) + angle = 2.0 * math.pi * 0.7 * t + design = np.vstack([np.ones(len(f)), np.cos(angle), np.sin(angle)]).T + coef = np.linalg.lstsq(design, f, rcond=None)[0] + return 1200.0 * math.log(1.0 + math.hypot(coef[1], coef[2]) / coef[0], + 2.0) + + +def t5_measure(cls, rate=RATE): + """1.0 +- 0.25 cents at the default, 3.5 +- 0.5 at the top, under 0.05 + at zero.""" + default = t5_cents(cls, rate) + top = t5_cents(cls, rate, wow_cents=3.5) + zero = t5_cents(cls, rate, wow_cents=0.0) + passed = (abs(default - 1.0) <= 0.25 and abs(top - 3.5) <= 0.5 + and zero < 0.05) + return {"passed": passed, "default": default, "top": top, "zero": zero} + + +# -------------------------------------------------------------------------- +# Tier 1 helpers + + +def tail_measure(cls, rate=RATE, burst_ms=50.0, burst_lsb=32767, + **options): + """A full-scale DC burst, then silence: the frames from the burst's end + to the output's last non-zero frame, against the declared + `tail_samples`.""" + probe = cls(silence_src(64, 2, rate), sample_rate=rate, **options) + declared = probe.tail_samples + probe.deinit() + burst = int(burst_ms * rate / 1000.0) + n = burst + declared + rate // 2 + x = np.zeros(n) + x[:burst] = burst_lsb + y = render(cls, x, rate, 2, **options)[:, 0] + nonzero = np.nonzero(y[burst:])[0] + last = int(nonzero[-1]) + 1 if len(nonzero) else 0 + return {"passed": 0 < last <= declared, "declared": declared, + "last": last, "held": int(np.abs(y[-rate // 4:]).max())} + + +def railed_samples(cls, dbfs, seconds=4.0, rate=RATE, **options): + """`noise_det` at `dbfs` peak, 48 kHz stereo: output samples on the + int16 rail that are not on it in the source.""" + frames = int(seconds * rate) + data = probes.noise_det(frames=frames, dbfs=dbfs, channels=2) + effect = cls(probes.ArraySource(data, rate=rate, channels=2, + block=BLOCK), sample_rate=rate, **options) + out = pull(effect, frames, 2).reshape(-1).astype(np.int64) + src = np.array(data, dtype=np.int64) + rail = (out >= 32767) | (out <= -32768) + return int(np.count_nonzero(rail & ~((src >= 32767) | (src <= -32768)))) + + +# -------------------------------------------------------------------------- +# Reachability: what the node is handed at the position walked + + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node as `_handed` (the latest value of each).""" + + def __enter__(self): + node_class = sd.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._handed.update(options) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +# Every reading below is a value the node was handed, and nothing else: no +# knob position, no label, no law (fix round 1; the gate audit found +# `read_landing_error` 0 by construction and `read_floor` carrying the +# label "Repeats at 0"). + + +def delay_nodes(effect): + return [node for node in effect._nodes + if isinstance(node, sd.audioecho.FeedbackDelay)] + + +def read_fraction(effect): + """How far the handed delay sits from a whole frame at the running + rate, to 1e-6 of a frame.""" + rate = effect._sample_rate + frames = float(effect._delay._handed["delay_ms"]) * rate / 1000.0 + return round(abs(frames - math.floor(frames + 0.5)), 6) + + +def read_delays(effect): + """The handed delay of every node the class owns, in frames to 1e-3: + one entry on the clean class at every position.""" + rate = effect._sample_rate + return tuple(sorted(round(float(node._handed["delay_ms"]) * rate + / 1000.0, 3) for node in delay_nodes(effect))) + + +def read_frames(effect): + """The handed delay in frames, to 1e-3.""" + return round(float(effect._delay._handed["delay_ms"]) + * effect._sample_rate / 1000.0, 3) + + +def read_feedback(effect): + return round(float(effect._delay._handed["feedback"]), 4) + + +def read_pan(effect): + return float(effect._delay._handed.get("input_pan", 0.0)) + + +def read_damping(effect): + return round(float(effect._delay._handed["damping_hz"]), 2) + + +def read_wow_depth(effect): + return round(float(effect._delay._handed["wow_depth_ms"]), 5) + + +#: The fine grid every walk also runs on (fix round 1): quarter steps, +#: 509 positions per macro. +FINE = tuple(i / 4.0 for i in range(509)) + +#: (name, fault, reading, constructor options for both builds). Every walk +#: runs at 48, 44.1 and 22.05 kHz, on the kit's grid and on `FINE`. +REACH_WALKS = ( + ("ShortHalfFrameSlapback", ShortHalfFrameSlapback, read_fraction, {}), + ("SecondRepeatSlapback", SecondRepeatSlapback, read_delays, {}), + ("SplitSlapback", SplitSlapback, read_delays, {}), + ("PannedSlapback", PannedSlapback, read_pan, {}), + ("RawTopSlapback", RawTopSlapback, read_damping, {}), + ("RawToneSlapback", RawToneSlapback, read_damping, + {"tone_hz": 15000.0}), + ("NoTwoPiSlapback", NoTwoPiSlapback, read_wow_depth, {}), +) + + +def reach(faulted, reading, rate, ctor, grid=None): + def build(cls): + return cls(silence_src(512, 2, rate), sample_rate=rate, **ctor) + + with NodeSpy(): + return kit_faults.fault_reachability(SlapbackDelay, faulted, reading, + build, grid=grid) + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + cls = SlapbackDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Level", "Saturation", "Tone", "Wow", + "Repeats")) + self.assertEqual(len(cls.PATCHES), 6) + self.assertEqual(cls.CAPABILITIES, ()) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho",)) + effect = cls(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ()) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-28, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects + self.assertIn("SlapbackDelay", rebuilt.ADOPTED) + self.assertNotIn("SlapbackDelay", rebuilt.parked()) + self.assertIs(audioeffects.SlapbackDelay, SlapbackDelay) + served = audioeffects.create("SlapbackDelay", silence_src(64), RATE) + self.assertIsInstance(served, SlapbackDelay) + served.deinit() + + def test_patches_are_the_dossier_settings_on_the_grid(self): + for index, (name, values) in enumerate(DOSSIER_PATCHES): + label, midi = SlapbackDelay.PATCHES[index] + self.assertEqual(label, name) + self.assertEqual(midi, tuple(_component.macro_of(span, value) + for span, value in + zip(DOSSIER_SPANS, values))) + + def test_patch_0_is_the_constructor_grid(self): + effect = SlapbackDelay(silence_src(512), sample_rate=RATE) + grid = SlapbackDelay.PATCHES[0][1] + for index, expected in enumerate(grid): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_time_lands_on_a_whole_frame(self): + # 135 ms is 6 480 / 5 954 / 2 977 frames (5 953.5 at 44.1 kHz lands + # up); the stops 1 920 / 1 764 / 882 and 12 000 / 11 025 / 5 513. + for rate, frames in ((48000, (6480, 1920, 12000)), + (44100, (5954, 1764, 11025)), + (22050, (2977, 882, 5513))): + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + got = [effect._frames] + for midi in (0, 127): + effect.set_macro(TIME_I, midi) + got.append(effect._frames) + self.assertEqual(tuple(got), frames, rate) + for midi in range(128): + effect.set_macro(TIME_I, midi) + self.assertEqual(effect._frames, + law_frames(law_time_ms(midi), rate)) + self.assertEqual(effect._node_ms, + effect._frames * 1000.0 / rate) + + def test_where_the_node_lands_the_handed_frame(self): + # The node turns the handed ms back into frames in float32 + # (`audiodsp_feedback_delay.c:148`). At 48 kHz every Time position + # lands exactly; at 44.1 and 22.05 kHz these land one float32 step + # off, which the class cannot avoid (the node ask is drafted). + # Goes red when the node lands every whole frame. + off_frame = { + 48000: [], + 44100: [4, 8, 9, 10, 11, 38, 39, 40, 41, 49, 50, 53, 55, 60, 83, + 86, 91, 93, 96, 99, 102], + 22050: [8, 10, 34, 38, 39, 40, 41, 45, 53, 60, 81, 83, 86, 91, + 93, 96, 97, 98, 99, 102], + } + f32 = np.float32 + for rate, worst in ((48000, 0.0), (44100, 2.0 ** -11), + (22050, 2.0 ** -12)): + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + missed = [] + for midi in range(128): + effect.set_macro(TIME_I, midi) + ms = f32(effect._node_ms) + frames = float((ms * f32(rate)) / f32(1000.0)) + if frames != effect._frames: + self.assertLessEqual(abs(frames - effect._frames), worst, + (rate, midi)) + missed.append(midi) + self.assertEqual(missed, off_frame[rate], rate) + + def test_an_off_frame_time_leaks_into_the_next_frame(self): + # MIDI 60 at 44.1 kHz is 4 193 frames, landed 1/2048 of a frame + # late: a 20 000 click's repeat (Wow 0, Level 2) reads 19 618 and + # 10 in the frame after; at 48 kHz the same position reads 19 627 alone + # (the default Saturation's loss). At 22.05 kHz it reads 5 in the + # frame before and 19 623. A half frame, planted, leaks. + def window(cls, rate): + probe = cls(silence_src(64, 2, rate), sample_rate=rate) + probe.set_macro(TIME_I, 60) + frames = probe._frames + values = np.zeros(frames + 64) + values[0] = 20000 + y = render(cls, values, rate, macros={TIME_I: 60}, level=2.0, + wow_cents=0.0)[:, 0] + return [int(v) for v in y[frames - 1:frames + 2]] + + self.assertEqual(window(SlapbackDelay, 44100), [0, 19618, 10]) + self.assertEqual(window(SlapbackDelay, 22050), [5, 19623, 0]) + self.assertEqual(window(SlapbackDelay, 48000), [0, 19627, 0]) + self.assertNotEqual(window(HalfFrameSlapback, 48000), + [0, 19627, 0]) + + def test_a_host_echoing_time_keeps_the_frame(self): + # Fix round 1: set_macro(0, get_macro(0)) on the constructor's + # 135.0 ms keeps 5 954 frames at 44.1 kHz; the old behaviour, + # planted, drops to 5 953. Any other position still moves it. + for rate, frames in ((48000, 6480), (44100, 5954), (22050, 2977)): + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + effect.set_macro(TIME_I, effect.get_macro(TIME_I)) + self.assertEqual(effect._frames, frames, rate) + effect.set_macro(TIME_I, effect.get_macro(TIME_I)) + self.assertEqual(effect._frames, frames, rate) + old = EchoForgetsTimeSlapback(silence_src(64, 2, 44100), + sample_rate=44100) + old.set_macro(TIME_I, old.get_macro(TIME_I)) + self.assertEqual(old._frames, 5953) + effect = SlapbackDelay(silence_src(64, 2, 44100), sample_rate=44100) + effect.set_macro(TIME_I, effect.get_macro(TIME_I) + 0.01) + self.assertIsNone(effect._time_exact) + effect = SlapbackDelay(silence_src(64, 2, 44100), sample_rate=44100) + effect.program_change(0) + self.assertEqual(effect._frames, law_frames(law_time_ms(84), 44100)) + + def test_the_wow_map_and_its_ceiling(self): + self.assertAlmostEqual(sd.wow_depth_ms(1.0), 0.13137, places=5) + self.assertAlmostEqual(sd.wow_depth_ms(3.5), 0.46012, places=5) + self.assertEqual(sd.wow_depth_ms(0.0), 0.0) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + wow_cents=10.0) + self.assertAlmostEqual(effect._wow_ms, law_wow_ms(3.5), places=12) + for midi in range(128): + effect.set_macro(WOW_I, midi) + self.assertAlmostEqual(effect._wow_ms, + law_wow_ms(3.5 * midi / 127.0), places=12) + + def test_tone_stops_and_the_22k_clamp(self): + for rate in RATES: + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + dampings = [effect._tone_damping(m / 127.0) for m in range(128)] + self.assertEqual(dampings[127], 0.0) + if rate == 22050: + self.assertLess(dampings[93], dampings[94]) + self.assertEqual(len(set(dampings[94:127])), 1) + self.assertAlmostEqual(dampings[94], 6183.68, places=2) + else: + self.assertTrue(all(b > a for a, b in + zip(dampings[:126], dampings[1:127]))) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + tone_hz=15000.0) + self.assertAlmostEqual(effect._damping, 11566.93, places=2) + + def test_tail_samples_follows_time_tone_wow_and_repeats(self): + # laps x (reach + ceil(wow) + 1 + memory); one lap at Repeats 0. + for rate, declared in ((48000, 6488), (44100, 5961), (22050, 2981)): + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + self.assertEqual(effect.tail_samples, declared, rate) + self.assertEqual(declared, law_frames(135.0, rate) + + law_wow_frames(1.0, rate) + 1) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE) + self.assertEqual(sd.laps_to_zero(0.35), 11) + self.assertEqual(sd.laps_to_zero(0.6), 21) + effect.set_macro(REPEATS_I, 127) + self.assertEqual(effect.tail_samples, 21 * 6488) + # A falling move walks from the old Time, so the old Time stays in + # the bound; a reset lands the head on patch 0. + effect.set_macro(TIME_I, 0) + self.assertEqual(effect.tail_samples, 21 * 6488) + effect.set_macro(TIME_I, 127) + self.assertEqual(effect.tail_samples, 21 * (12000 + 7 + 1)) + effect.reset() + self.assertEqual(effect.tail_samples, + law_frames(law_time_ms(84), RATE) + + law_wow_frames(3.5 * 36 / 127.0, RATE) + 1) + # Tone in at Repeats 0.5, a stall centre: since audiodsp v0.6.3rc1 + # the node is handed 0.5 itself, and the bound takes its landing + # lap there (111 758 frames; 105 184 with the retired stepping). + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + tone_hz=2000.0, repeats=0.5) + self.assertEqual(effect._feedback, 0.5) + self.assertAlmostEqual(effect.get_macro(REPEATS_I), 127 * 0.5 / 0.6) + self.assertEqual(effect.tail_samples, 111758) + + def test_constructor_clamps_and_nan(self): + nan = float("nan") + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + time_ms=nan, level=nan, saturation=nan, + tone_hz=nan, wow_cents=nan, repeats=nan) + self.assertEqual([effect.macro(i) for i in (1, 2, 5)], + [0.35, 0.15, 0.0]) + self.assertEqual(effect._frames, 6480) + self.assertEqual(effect._damping, 0.0) + self.assertAlmostEqual(effect._wow_ms, law_wow_ms(1.0)) + for options, frames in (({"time_ms": 0.0, "tone_hz": 0.0}, 1920), + ({"time_ms": -5.0, "tone_hz": -1.0}, 1920), + ({"time_ms": 900.0, "tone_hz": 1e6}, 12000)): + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + **options) + self.assertEqual(effect._frames, frames, options) + self.assertEqual(effect._damping, 0.0, options) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + tone_hz=100.0, repeats=2.0, level=5.0) + self.assertEqual(effect.get_macro(TONE_I), 0.0) + self.assertEqual(effect.get_macro(REPEATS_I), 127.0) + self.assertEqual(effect.get_macro(LEVEL_I), 127.0) + + +# -------------------------------------------------------------------------- +# Tier 2 rows + + +class T1OneRepeat(unittest.TestCase): + def test_defaults_three_rates_stereo_and_mono(self): + for rate in RATES: + for channels in (2, 1): + result = t1_measure(SlapbackDelay, rate, channels) + self.assertTrue(result["passed"], (rate, channels, result)) + result = t1_measure(SlapbackDelay, RATE, 2, click_lsb=328) + self.assertTrue(result["passed"], result) + result = t1_measure(SlapbackDelay, RATE, patch=0) + self.assertTrue(result["passed"], result) + + def test_no_second_repeat_at_the_stops_and_patches(self): + for options in ({"time_ms": 40.0}, {"time_ms": 250.0}, + {"saturation": 1.0}, {"tone_hz": 2000.0}, + {"wow_cents": 3.5}, {"wow_cents": 0.0}, + {"level": 2.0}, {"patch": 1}, {"patch": 2}, + {"patch": 3}, {"patch": 5}): + result = t1_measure(SlapbackDelay, RATE, delay_bar=False, + **options) + self.assertTrue(result["passed"], (options, result)) + + def test_the_control_moves(self): + for options in ({"repeats": 0.35}, {"patch": 4}, {"repeats": 0.6}): + result = t1_control(SlapbackDelay, RATE, **options) + self.assertTrue(result["passed"], (options, result)) + + def test_a_short_fractional_landing_is_red(self): + # Fix round 1: floor(0.92 T) + 0.5 frames, off the whole-frame + # surface, red on the delay bar at every rate. + for rate in RATES: + result = t1_measure(ShortHalfFrameSlapback, rate) + self.assertFalse(result["passed"], rate) + self.assertIn("delay", " ".join(result["red"])) + self.assertLess(result["delay_ms"], 130.0) + + def test_a_lone_second_repeat_is_red(self): + # Fix round 1: a second node puts the first repeat again at 2T, + # about 26 dB down, under the control's -20 dB, and nothing at 3T. + for rate in RATES: + energies, _ = t1_read(SecondRepeatSlapback, rate) + self.assertEqual(energies[2], 0.0, rate) + self.assertEqual(energies[3], 0.0, rate) + result = t1_measure(SecondRepeatSlapback, rate) + self.assertFalse(result["passed"], rate) + self.assertEqual(result["red"], + ["a 2T-4T window is not exact zero"]) + self.assertLess(result["second_db"], -20.0) + self.assertGreater(result["second_db"], -30.0) + + def test_the_level_span(self): + # (m): the clauses hold down to a 3 LSB click (-80.8 dBFS). + for rate in RATES: + result = t1_measure(SlapbackDelay, rate, click_lsb=3) + self.assertTrue(result["passed"], (rate, result)) + + +class T2Comb(unittest.TestCase): + def test_the_defaults_and_the_stops(self): + for rate in RATES: + result = t2_measure(SlapbackDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + for options in ({"time_ms": 40.0, "wow_cents": 0.0}, + {"time_ms": 250.0, "wow_cents": 0.0}, + {"time_ms": 137.0, "wow_cents": 0.0}, {"patch": 0}): + result = t2_measure(SlapbackDelay, RATE, **options) + self.assertTrue(result["passed"], (options, result)) + result = t2_measure(SlapbackDelay, RATE, click_lsb=3277) + self.assertTrue(result["passed"], result) + + def test_the_second_tooth_cells(self): + # Fix round 1: the gate audit's red cells, Wow 0, where 1/T falls + # near half a bin of the pad and the tallest tooth is the second. + for rate in (48000, 44100): + for midi in (117, 120, 124): + result = t2_measure(SlapbackDelay, rate, + time_ms=law_time_ms(midi), + wow_cents=0.0) + self.assertTrue(result["passed"], (rate, midi, result)) + + def test_the_old_first_guess_is_red_there(self): + # The old guess, the autocorrelation's argmax, planted: at grid 124 + # (48 kHz, Wow 0) it takes 2/T and the low fit reads +47 %. + rate = 48000 + T = law_frames(law_time_ms(124), rate) + x = np.zeros(100 + T + 400) + x[100] = 32767 + y = render(SlapbackDelay, x, rate, 2, time_ms=law_time_ms(124), + wow_cents=0.0)[:, 0].astype(float) + size = int(PAD_S * rate) + mag = np.abs(np.fft.rfft(y, n=size)) + bin_hz = rate / float(size) + law = rate / float(T) + old = old_comb_guess(mag, bin_hz) + self.assertAlmostEqual(old / law, 2.0, delta=0.01) + low, _ = notch_spacing(mag, bin_hz, 200.0, 2000.0, old) + self.assertGreater(abs(low / law - 1.0), 0.005) + new = comb_guess(mag, bin_hz) + self.assertAlmostEqual(new / law, 1.0, delta=0.01) + + def test_a_short_fractional_landing_is_red(self): + for rate in RATES: + result = t2_measure(ShortHalfFrameSlapback, rate) + self.assertFalse(result["passed"], rate) + self.assertGreater(result["deviations"][0], 8.0) + + def test_frames_at_44k_are_red_at_22k(self): + result = t2_measure(Frames441Slapback, 22050) + self.assertFalse(result["passed"], result) + + def test_the_split_build_is_red_on_agreement_alone(self): + # Station A's split, on the class: each fit inside 0.5 %, the two + # fits more than 0.5 % apart. + for rate in RATES: + low, high, between = t2_measure(SplitSlapback, + rate)["deviations"] + self.assertLessEqual(abs(low), 0.5, rate) + self.assertLessEqual(abs(high), 0.5, rate) + self.assertGreater(abs(between), 0.5, rate) + + +class T3Mono(unittest.TestCase): + def test_the_defaults_the_corner_and_the_patches(self): + for rate in RATES: + result = t3_measure(SlapbackDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + for options in ({"repeats": 0.6, "saturation": 1.0, + "tone_hz": 2000.0, "wow_cents": 3.5}, + {"patch": 2}, {"patch": 4}, {"patch": 5}, + {"time_ms": 40.0}, {"time_ms": 250.0}): + result = t3_measure(SlapbackDelay, RATE, **options) + self.assertTrue(result["passed"], (options, result)) + + def test_a_panned_repeat_is_red(self): + result = t3_measure(PannedSlapback, RATE) + self.assertFalse(result["passed"]) + self.assertGreater(result["lr"], 0) + + def test_presence_on_tones_reads_the_measured_table(self): + # Re-audit fix round 1: the dossier reports this table instead of + # a band. Each cell must read within PRESENCE_PIN_DB of its entry. + for label, options, dbfs, fraction, table in PRESENCE_TABLE: + for rate, want in zip(RATES, table): + head, energy = presence_db( + SlapbackDelay, tone(fraction * rate, rate, + amp=dbfs_amp(dbfs)), + rate, **options) + cell = (label, dbfs, fraction, rate, round(energy, 3)) + self.assertEqual(head, 0, cell) + self.assertLessEqual(abs(energy - want), PRESENCE_PIN_DB, + cell) + + def test_a_full_scale_tone_at_saturation_1_reads_past_4_db(self): + # Round 3 disconfirmed "with Tone out and Repeats 0, the 4 dB bar + # holds on material below a third of the running rate" here: Tone + # out, Repeats 0, Saturation 1, a 0 dBFS tone at fs/3, at every + # rate. If this comes back inside 4 dB, a band sentence could be + # written again, and it would have to be measured first. + for rate in RATES: + head, energy = presence_db( + SlapbackDelay, tone(rate / 3.0, rate, amp=dbfs_amp(0.0)), + rate, saturation=1.0) + self.assertEqual(head, 0, rate) + self.assertLess(energy, -4.0, (rate, energy)) + + def test_tone_in_and_repeats_take_a_tone_past_4_db(self): + # Fix round 2: Tone at its 2 kHz stop takes a 3 kHz tone more than + # 4 dB down, and Repeats at its 0.6 stop stacks a 1 kHz tone more + # than 4 dB up, at every rate (-20 dBFS). The low-pass and the + # repeats doing their jobs, not a defect. + for rate in RATES: + head, energy = presence_db(SlapbackDelay, tone(3000.0, rate), + rate, tone_hz=2000.0) + self.assertEqual(head, 0, rate) + self.assertLess(energy, -4.0, (rate, energy)) + head, energy = presence_db(SlapbackDelay, tone(1000.0, rate), + rate, repeats=0.6) + self.assertEqual(head, 0, rate) + self.assertGreater(energy, 4.0, (rate, energy)) + + def test_presence_at_nyquist_is_the_disclosed_wow_loss(self): + # At Nyquist the defaults read outside 4 dB, and at Wow 0 inside + # 0.5 dB: the loss is the wow's fractional read, not the class + # dropping the repeat. If this moves, the dossier's words move too. + for rate in RATES: + alt = probes.alt_fs(frames=rate, channels=1) + _, energy = presence_db(SlapbackDelay, alt, rate) + self.assertLess(energy, -4.0, rate) + _, still = presence_db(SlapbackDelay, alt, rate, wow_cents=0.0) + self.assertLess(abs(still), 0.5, rate) + + +class T4Tone(unittest.TestCase): + def test_out_and_the_corners_at_48k_and_44k(self): + for rate in (48000, 44100): + result = t4_measure(SlapbackDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + + def test_the_grid_cells(self): + for rate in (48000, 44100): + for midi in (51, 111, 126): + label = 2000.0 * 10.0 ** (midi / 127.0) + result = t4_corner(SlapbackDelay, label, label, rate) + self.assertTrue(result["passed"], (rate, midi, result)) + + def test_the_22k_cells(self): + self.assertEqual(t4_out_differing(SlapbackDelay, 22050), 0) + for label in (2000.0, 2000.0 * 10.0 ** (51 / 127.0)): + result = t4_corner(SlapbackDelay, label, label, 22050) + self.assertTrue(result["passed"], (label, result)) + + def test_an_open_top_stop_is_red_at_the_defaults(self): + for rate in RATES: + self.assertGreater(t4_out_differing(RawTopSlapback, rate), 0) + self.assertGreater(t4_out_differing(OpenTopSlapback, rate), 0) + for rate in (48000, 44100): + self.assertFalse(t4_measure(RawTopSlapback, rate)["passed"]) + self.assertFalse(t4_measure(OpenTopSlapback, rate)["passed"]) + + def test_out_after_tone_has_been_in_is_the_filter_out(self): + # Since audiodsp v0.6.3rc1 the node keeps an out low-pass on the + # tap (#158), so the out stop hands exactly 0 whatever came before + # (the constructor and a patch count as Tone in) and the output is + # byte-identical to the node given no `damping_hz`: with Wow on, + # and at Wow 0 on a Time whose float32 landing leaves a fraction + # (44.1 kHz grid 8, 1 980 frames asked) as on one it lands whole + # (grid 84). The tracking cure this replaced moved samples by 1 LSB + # on those cells; planted, it is red on every one. + for rate in RATES: + for channels in (2, 1): + for ctor in ({"tone_hz": 5000.0}, {"patch": 5}): + differing, peak, damping = t4_out_history( + SlapbackDelay, rate, channels, steps=(0,), **ctor) + self.assertEqual((differing, peak, damping), + (0, 0, 0.0), (rate, channels, ctor)) + differing, _peak, damping = t4_out_history( + TrackingSlapback, rate, channels, steps=(0,), + **ctor) + self.assertEqual(damping, 32.0 * rate) + self.assertGreater(differing, 0, (rate, channels, ctor)) + for channels in (2, 1): + for midi in (8, 84): + steps = (0, (WOW_I, 0), (TIME_I, midi)) + differing, peak, _ = t4_out_history( + SlapbackDelay, 44100, channels, steps=steps, + tone_hz=5000.0) + self.assertEqual((differing, peak), (0, 0), (channels, midi)) + differing, _peak, _ = t4_out_history( + TrackingSlapback, 44100, channels, + steps=(0, (WOW_I, 0), (TIME_I, 8)), tone_hz=5000.0) + self.assertGreater(differing, 0, channels) + + def test_out_on_a_fresh_history_is_byte_identical(self): + for rate in RATES: + for channels in (2, 1): + self.assertEqual(t4_out_differing(SlapbackDelay, rate, + channels), 0) + differing, _, damping = t4_out_history( + SlapbackDelay, rate, channels, steps=(0,)) + self.assertEqual((differing, damping), (0, 0.0)) + # A Tone-in constructor with a patch 0 on top never put + # Tone in on the node after the constructor finished, but + # the constructor counts: this one is the 1 LSB case. + differing, peak, damping = t4_out_history( + SlapbackDelay, rate, channels, tone_hz=5000.0, patch=0) + self.assertLessEqual(peak, 1) + + def test_the_bound_goes_red(self): + # The restated clause's own planted faults: an open top after + # Tone has been in is far outside 1 LSB. + for rate in RATES: + for cls in (RawTopSlapback, OpenTopSlapback): + _, peak, _ = t4_out_history(cls, rate, 2, steps=(0,), + patch=5) + self.assertGreater(peak, 1, (rate, cls.__name__)) + + def test_a_raw_tone_is_red_at_the_15k_cell(self): + for rate in (48000, 44100): + result = t4_corner(RawToneSlapback, 15000.0, 15000.0, rate, + passband=True) + self.assertFalse(result["passed"]) + self.assertIsNone(result["corner"]) + + def test_a_raw_tone_is_red_at_patch_5_at_22k(self): + label = 2000.0 * 10.0 ** (51 / 127.0) + result = t4_corner(RawToneSlapback, label, label, 22050) + self.assertFalse(result["passed"], result) + self.assertGreater(result["corner"] / label - 1.0, 0.2) + + +class T5Wow(unittest.TestCase): + def test_zero_default_and_top(self): + for rate in RATES: + result = t5_measure(SlapbackDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + for saturation in (0.0, 0.15): + self.assertLess(abs(t5_cents(SlapbackDelay, RATE, amp=328, + saturation=saturation) - 1.0), 0.25) + cents = t5_cents(SlapbackDelay, RATE, patch=0) + self.assertLess(abs(cents - 1.0), 0.25) + + def test_the_level_span(self): + # (m): the clauses hold on a 10 LSB sine (-70.3 dBFS). + for rate in RATES: + zero = t5_cents(SlapbackDelay, rate, amp=10, wow_cents=0.0) + default = t5_cents(SlapbackDelay, rate, amp=10) + top = t5_cents(SlapbackDelay, rate, amp=10, wow_cents=3.5) + self.assertLess(zero, 0.05, rate) + self.assertLess(abs(default - 1.0), 0.25, rate) + self.assertLess(abs(top - 3.5), 0.5, rate) + + def test_the_ceiling(self): + cents = t5_cents(SlapbackDelay, RATE, wow_cents=10.0) + self.assertLess(cents, 4.0) + self.assertLess(abs(cents - 3.5), 0.5) + + def test_a_map_without_its_two_pi_is_red(self): + result = t5_measure(NoTwoPiSlapback, RATE) + self.assertFalse(result["passed"]) + self.assertGreater(result["default"], 5.0) + + +# -------------------------------------------------------------------------- +# Tier 1, the fast half + + +class Tier1Fast(unittest.TestCase): + def test_level_zero_is_a_wire_on_the_full_scale_ramp(self): + corner = {"repeats": 0.6, "saturation": 1.0, "tone_hz": 2000.0, + "wow_cents": 3.5, "level": 0.0} + for rate in RATES: + for channels in (2, 1): + for time_ms in (40.0, 250.0): + frames = law_frames(time_ms, rate) * 3 + ramp = probes.ramp_fs(frames=frames, channels=channels) + effect = SlapbackDelay( + probes.ArraySource(ramp, rate=rate, + channels=channels, block=BLOCK), + sample_rate=rate, time_ms=time_ms, **corner) + out = pull(effect, frames, channels).reshape(-1) + self.assertTrue(np.array_equal( + out, np.array(ramp, dtype=np.int16)), + (rate, channels, time_ms)) + + def test_the_dry_is_unity_until_the_repeat(self): + # Level 1.0 and grid 63 (0.992): the first T - 24 frames are the + # source, byte for byte, with the wow at its top. + for level in (0.0, 0.35, 0.7, 1.0, 2.0 * 63 / 127.0): + T = law_frames(40.0, RATE) + ramp = probes.ramp_fs(frames=T * 2, channels=2) + effect = SlapbackDelay( + probes.ArraySource(ramp, rate=RATE, channels=2, block=BLOCK), + sample_rate=RATE, time_ms=40.0, level=level, wow_cents=3.5, + repeats=0.6, saturation=1.0) + out = pull(effect, T * 2, 2).reshape(-1) + window = (T - 24) * 2 + self.assertTrue(np.array_equal( + out[:window], np.array(ramp, dtype=np.int16)[:window])) + + def test_silence_stays_silence(self): + effect = SlapbackDelay(silence_src(RATE), sample_rate=RATE, + repeats=0.6, level=2.0, tone_hz=2000.0, + saturation=1.0, wow_cents=3.5) + self.assertEqual(int(np.abs(pull(effect, RATE)).max()), 0) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + for rate in RATES: + for level in (0.35, 2.0): + result = tail_measure(SlapbackDelay, rate, level=level) + self.assertTrue(result["passed"], (rate, level, result)) + for options in ({"repeats": 0.35, "level": 2.0}, + {"repeats": 0.6, "level": 2.0}, + {"repeats": 0.6, "level": 2.0, "saturation": 1.0, + "tone_hz": 2000.0, "wow_cents": 3.5}, + {"time_ms": 250.0, "wow_cents": 3.5, + "repeats": 0.6, "level": 2.0}): + result = tail_measure(SlapbackDelay, RATE, **options) + self.assertTrue(result["passed"], (options, result)) + + def test_a_tail_without_the_wow_is_red(self): + result = tail_measure(NoWowTailSlapback, RATE, level=2.0) + self.assertFalse(result["passed"], result) + self.assertGreater(result["last"], result["declared"]) + + def _stall(self, cls): + """Repeats 0.5, Tone 2 kHz, Level 2, Saturation 0: a 2 LSB DC for + 1 s, then 3 s of silence.""" + values = np.zeros(4 * RATE) + values[:RATE] = 2.0 + source, _ = to_source(values) + effect = cls(source, sample_rate=RATE, repeats=0.5, tone_hz=2000.0, + level=2.0, saturation=0.0) + declared = effect.tail_samples + y = pull(effect, len(values))[:, 0] + nonzero = np.nonzero(y[RATE:])[0] + last = int(nonzero[-1]) + 1 if len(nonzero) else 0 + return declared, last, int(np.abs(y[-RATE // 4:]).max()) + + def test_the_stall_cell_reaches_zero_at_the_repeats_set(self): + # Repeats 0.5 with Tone in is a stall centre: up to audiodsp v0.6.2 + # the node held 1 LSB there for ever and the class moved Repeats + # clear of it. Since v0.6.3rc1 (#157) the node lands the stalled + # state, so 0.5 is handed as set and the tail ends inside the bound. + declared, last, held = self._stall(SlapbackDelay) + self.assertEqual(held, 0) + self.assertGreater(last, 0) + self.assertLessEqual(last, declared) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + repeats=0.5, tone_hz=2000.0) + self.assertEqual(effect._feedback, 0.5) + # Planted: the retired stepping hands a Repeats nobody set. + stepped = SteppedSlapback(silence_src(64), sample_rate=RATE, + repeats=0.5, tone_hz=2000.0) + self.assertNotEqual(stepped._feedback, 0.5) + self.assertLess(abs(stepped._feedback - 0.5), 2.5e-5) + + def _tone_back_in(self, cls, rate=RATE, channels=2, level=2.0): + """300 Hz at 30 000 LSB for 0.5 s with Tone 2 kHz, Tone out, 2 s of + silence, then Tone to MIDI 0: the output's peak after that move.""" + loud = (rate // 2) // BLOCK * BLOCK + back = (loud + 2 * rate) // BLOCK * BLOCK + values = np.zeros(back + rate // 4) + values[:loud] = 30000 * np.sin(2 * math.pi * 300.0 + * np.arange(loud) / rate) + source, _ = to_source(values, channels, rate) + effect = cls(source, sample_rate=rate, tone_hz=2000.0, level=level) + + def move(frame): + if frame == loud: + effect.set_macro(TONE_I, 127) + elif frame == back: + effect.set_macro(TONE_I, 0) + + y = pull(effect, len(values), channels, on_block=move) + return int(np.abs(y[back:]).max()) + + def test_tone_back_in_after_silence_stays_silent(self): + for rate in RATES: + for channels in (2, 1): + self.assertEqual(self._tone_back_in(SlapbackDelay, rate, + channels), 0, + (rate, channels)) + self.assertEqual(self._tone_back_in(SlapbackDelay, level=0.35), 0) + # Planted: a low-pass left in with a frozen state (0.001 Hz) plays + # what it held back out of silence. + self.assertGreater(self._tone_back_in(FrozenToneSlapback), 20000) + self.assertGreater(self._tone_back_in(FrozenToneSlapback, + level=0.35), 5000) + + def test_tone_out_hands_exactly_zero(self): + # After Tone has been in, after a patch with Tone in, and fresh. + # The retired tracking cure, planted, hands 32 x the rate. + with NodeSpy(): + for rate in RATES: + for cls, expected in ((SlapbackDelay, 0.0), + (TrackingSlapback, 32.0 * rate)): + effect = cls(silence_src(64, 2, rate), sample_rate=rate) + self.assertEqual(effect._delay._handed["damping_hz"], + 0.0) + effect.set_macro(TONE_I, 0) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._delay._handed["damping_hz"], + expected, (cls, rate)) + effect = cls(silence_src(64, 2, rate), sample_rate=rate, + patch=5) + effect.program_change(0) + self.assertEqual(effect._delay._handed["damping_hz"], + expected, (cls, rate)) + + def _wow_move(self, cls, start, target): + """(the tone's own largest step before the move, the largest step + over the 2 000 frames from the move) for a Wow move at frame + 15 616 on 997 Hz at 12 000 LSB, Level 2, 48 kHz.""" + at = 15616 + values = 12000 * np.sin(2 * math.pi * 997.0 * np.arange(RATE) / RATE) + source, _ = to_source(values) + effect = cls(source, sample_rate=RATE, level=2.0) + effect.set_macro(WOW_I, start) + + def move(frame): + if frame == at: + effect.set_macro(WOW_I, target) + + y = pull(effect, RATE, on_block=move)[:, 0].astype(int) + steady = int(np.abs(np.diff(y[at - 3000:at - 1])).max()) + return steady, int(np.abs(np.diff(y[at - 1:at + 2000])).max()) + + def test_a_wow_move_does_not_step(self): + # Since audiodsp v0.6.3rc1 the node ramps a new depth in over 20 ms + # (#160): no step larger than the tone's own (1 565 LSB) after a + # move 36 -> 73 or 0 -> 127, where v0.6.2 stepped 7 684 and 23 037. + for start, target in ((36, 73), (0, 127)): + steady, worst = self._wow_move(SlapbackDelay, start, target) + self.assertLessEqual(worst, steady, (start, target)) + # Planted: the read head moved by the whole change at once. + steady, worst = self._wow_move(JumpWowSlapback, start, target) + self.assertGreater(worst, 4 * steady, (start, target)) + + def _walk_tail(self, cls): + """250 ms of 997 Hz, then Time 250 -> 40 ms on the tone's last + block, Repeats 0, Level 2.""" + tone = int(0.25 * RATE) // BLOCK * BLOCK + values = np.zeros(tone + RATE) + values[:tone] = 12000 * np.sin(2 * math.pi * 997.0 + * np.arange(tone) / RATE) + source, _ = to_source(values) + effect = cls(source, sample_rate=RATE, time_ms=250.0, level=2.0) + seen = {} + + def move(frame): + if frame == tone: + effect.set_macro(TIME_I, 0) + seen["declared"] = effect.tail_samples + + y = pull(effect, len(values), on_block=move)[:, 0] + last = int(np.nonzero(y[tone:])[0][-1]) + 1 + return seen["declared"], last + + def test_a_falling_walk_keeps_the_old_time_in_the_tail(self): + declared, last = self._walk_tail(SlapbackDelay) + self.assertGreater(last, 4000) + self.assertLessEqual(last, declared) + declared, last = self._walk_tail(TargetOnlyTailSlapback) + self.assertGreater(last, declared) + + def test_reset_empties_the_line(self): + values = np.zeros(RATE) + values[256:2304] = 12000 * np.sin(2 * math.pi * 997.0 + * np.arange(2048) / RATE) + source, _ = to_source(values) + effect = SlapbackDelay(source, sample_rate=RATE, repeats=0.6, + level=2.0) + pull(effect, 2304) + effect.reset() + self.assertEqual(effect.patch_index, 0) + self.assertEqual(int(np.abs(pull(effect, RATE // 2)).max()), 0) + + def test_deinit_leaves_the_source(self): + source, _ = to_source(8000 * np.sin(2 * math.pi * 440.0 + * np.arange(1024) / RATE)) + effect = SlapbackDelay(source, sample_rate=RATE) + pull(effect, 256) + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + + def test_click_delay_is_zero(self): + for rate in (48000, 44100): + values = np.zeros(2048) + values[10] = 30000 + y = render(SlapbackDelay, values, rate)[:, 0] + self.assertEqual(int(np.argmax(np.abs(y))), 10) + self.assertEqual(int(y[10]), 30000) + + def test_the_transport_is_never_read(self): + reads = [] + + def transport(): + reads.append(1) + return (True, 0.0, 120.0, 4, 4) + effect = SlapbackDelay.create(silence_src(512), RATE, + transport=transport) + for index in range(6): + effect.set_macro(index, 127) + effect.program_change(4) + effect.reset() + pull(effect, 256) + self.assertEqual(reads, []) + + +class InputCeiling(unittest.TestCase): + """The docstring's ceiling on `noise_det`, 48 kHz stereo, 4 s: the + defaults clean at -2.5 dBFS peak and not at -2.4, patch 2 (Doubling, + the first shipped patch to rail) clean at -3.4 and not at -3.3.""" + + def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): + for options, ceiling, over in (({}, -2.5, -2.4), + ({"patch": 2}, -3.4, -3.3)): + self.assertEqual(railed_samples(SlapbackDelay, ceiling, + **options), 0, options) + self.assertGreater(railed_samples(SlapbackDelay, over, + **options), 0, options) + + +# -------------------------------------------------------------------------- +# The docstring's claims, each tied to the test that asserts it (the trial +# of the second process, 2026-09-29) + +#: (sentence, word for word as the class docstring has it, and the test +#: that asserts it). Every sentence in the docstring that makes a claim is +#: here; one that could not be tied to a test was struck. +CLAIMS = ( + ("By default the repeat comes 135 ms after the dry, once.", + "test_defaults_three_rates_stereo_and_mono"), + ("Time runs from 40 to 250 ms, Level from 0 to 2, Saturation from 0 to " + "1, Tone from 2 kHz to out at its top stop, Wow from 0 to 3.5 cents " + "and Repeats from 0 to 0.6.", "test_the_knob_spans"), + ("Level 0 is a wire.", "test_level_zero_is_a_wire_on_the_full_scale_ramp"), + ("Up to Level 1 the dry passes untouched until the repeat arrives, " + "however hard Saturation drives the repeat.", + "test_the_dry_is_unity_until_the_repeat"), + ("A hot input can reach the rail, since the repeat adds to a dry at " + "unity.", "test_the_stated_ceiling_is_clean_and_just_over_is_not"), + ("At Repeats 0 there is one repeat and no second.", + "test_no_second_repeat_at_the_stops_and_patches"), + ("Repeats above 0 sends the repeat round for more.", + "test_the_control_moves"), + ("Wow swings the repeat's pitch by the cents the knob reads, at a slow " + "fixed rate.", "test_zero_default_and_top"), + ("The default Wow takes the repeat's very top more than 4 dB down at " + "Nyquist, where Wow 0 leaves it within half a dB.", + "test_presence_at_nyquist_is_the_disclosed_wow_loss"), + ("At 22.05 kHz the last Tone positions below the top stop clamp below " + "Nyquist and all do the same thing.", + "test_tone_stops_and_the_22k_clamp"), + ("Every Time position lands on the nearest whole frame at 48 kHz.", + "test_where_the_node_lands_the_handed_frame"), + ("At 44.1 and 22.05 kHz the node lands some positions a fraction of a " + "frame off, and a sliver of the repeat falls on the frame beside it.", + "test_an_off_frame_time_leaks_into_the_next_frame"), + ("A host that writes back `get_macro(0)` keeps the constructor's exact " + "Time.", "test_a_host_echoing_time_keeps_the_frame"), + ("Turning Time walks the repeat to the new Time, bending its pitch, " + "instead of clicking.", "test_a_time_move_walks"), + ("A Wow move glides instead of stepping.", "test_a_wow_move_does_not_step"), + ("A source the same in both channels comes out the same in both " + "channels, and a one-channel source gets the stereo render's left " + "channel.", "test_the_defaults_the_corner_and_the_patches"), + ("A click comes out on the frame it went in: there is no latency.", + "test_click_delay_is_zero"), + ("`tail_samples` is an upper bound on how many frames the output takes " + "to reach exact zero, counted from when your input stops or from when " + "you read it if that is later, for the settings as they stand when you " + "read it.", "test_tail_samples_holds_for_the_settings_as_they_stand"), + ("`reset()` empties the line and returns to patch 0.", + "test_reset_empties_the_line"), + ("The class never reads the host's tempo.", + "test_the_transport_is_never_read"), + ("A constructor value outside a knob's span clamps to the nearer stop, a " + "`tone_hz` of 0 or less is Tone out, and NaN takes the option's " + "default.", "test_constructor_clamps_and_nan"), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "test_a_jumping_control_steps_the_output"), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", "test_a_tail_cut_short_carries_on"), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", "test_a_tail_cut_short_carries_on"), +) + + +def _flat(text): + return " ".join(text.split()) + + +def _cell(event, patch, channels=1, rate=RATE, cls=None): + """One lifecycle matrix cell on the class, as measured, with the class's + DECLARED rows lifted so the raw verdict shows.""" + import lifecycle + cls = cls or SlapbackDelay + ev = [e for e in lifecycle.events(cls, patch) if e.name == event][0] + saved = dict(lifecycle.DECLARED) + for key in list(lifecycle.DECLARED): + if key[0] == "SlapbackDelay": + del lifecycle.DECLARED[key] + controls = {} + try: + return lifecycle.run_cell(cls, ev, rate, channels, patch, {}, + controls) + finally: + lifecycle.DECLARED.clear() + lifecycle.DECLARED.update(saved) + for ctl in controls.values(): + ctl.close() + + +class NoWowSlapback(SlapbackDelay): + """The class with Wow at 0 in the constructor and every patch: the + control for the reset cells' declared P4 rows.""" + + NAME = 'SlapbackDelay' + PATCHES = dict((index, (name, values[:WOW_I] + (0,) + + values[WOW_I + 1:])) + for index, (name, values) in SlapbackDelay.PATCHES.items()) + + def _build(self, *args, **options): + options.setdefault("wow_cents", 0.0) + SlapbackDelay._build(self, *args, **options) + + +def _time_move(cls, target=52): + """(the tone's own largest step before the move, the largest step over + the 2 000 frames from it, zero crossings in 1 024 frames before and + after) for Time 135 ms -> `target` at frame 15 616 on 997 Hz at 12 000 + LSB, Level 2 (the repeat alone), Wow 0, 48 kHz.""" + at = 15616 + values = 12000 * np.sin(2 * math.pi * 997.0 * np.arange(RATE) / RATE) + source, _ = to_source(values) + effect = cls(source, sample_rate=RATE, level=2.0, wow_cents=0.0) + + def move(frame): + if frame == at: + effect.set_macro(TIME_I, target) + + y = pull(effect, RATE, on_block=move)[:, 0].astype(int) + steady = int(np.abs(np.diff(y[at - 3000:at - 1])).max()) + worst = int(np.abs(np.diff(y[at - 1:at + 2000])).max()) + + def crossings(seg): + return int(np.count_nonzero(np.diff(np.sign(seg)) != 0)) + return (steady, worst, crossings(y[at - 1024:at]), + crossings(y[at + 256:at + 1280])) + + +def _midtail(ctor, moves, at=2048, rate=RATE): + """A full-scale DC burst of 50 ms at Level 2, then silence; `moves` made + `at` frames into the silence. (`tail_samples` read as the input stops, + `tail_samples` read just after the moves, frames from the moves to the + output's last non-zero frame).""" + burst = int(0.05 * rate) // BLOCK * BLOCK + move = burst + at + frames = move + 30 * rate + values = np.zeros(frames) + values[:burst] = 32767 + source, _ = to_source(values, 2, rate) + effect = SlapbackDelay(source, sample_rate=rate, level=2.0, **ctor) + seen = {} + + def on_block(frame): + if frame == burst: + seen["before"] = effect.tail_samples + if frame == move: + for index, value in moves: + effect.set_macro(index, value) + seen["after"] = effect.tail_samples + + y = pull(effect, frames, on_block=on_block)[:, 0] + nonzero = np.nonzero(y[move:])[0] + last = int(nonzero[-1]) + 1 if len(nonzero) else 0 + return seen["before"], seen["after"], last + + +class TheClaims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + doc = _flat(SlapbackDelay.__doc__) + tests = set() + for value in globals().values(): + if isinstance(value, type) and issubclass(value, + unittest.TestCase): + tests.update(n for n in dir(value) if n.startswith("test_")) + rest = doc + for sentence, test in CLAIMS: + self.assertIn(sentence, doc, sentence) + self.assertIn(test, tests, sentence) + rest = rest.replace(sentence, " ") + self.assertIn("**Limits shared by the family.**", doc) + numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] + self.assertEqual(numbers, []) + + def test_the_knob_spans(self): + effect = SlapbackDelay(silence_src(64), sample_rate=RATE) + for index, low, high in ((TIME_I, 40.0, 250.0), (LEVEL_I, 0.0, 2.0), + (SATURATION_I, 0.0, 1.0), + (WOW_I, 0.0, 3.5), (REPEATS_I, 0.0, 0.6)): + effect.set_macro(index, 0) + self.assertAlmostEqual(effect._value(index), low, places=9) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect._value(index), high, places=9) + effect.set_macro(TONE_I, 0) + self.assertAlmostEqual(effect._value(TONE_I), 2000.0, places=6) + self.assertEqual(effect._damping, + nominal_damping_hz(2000.0, RATE)) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._damping, 0.0) + + def test_a_time_move_walks(self): + # 135 -> 85 ms (grid 52) on the repeat alone: the pitch bends up + # (more crossings after the move), and no step is larger than the + # bent tone's own (+297.5 cents, x 1.19). Planted: the walk turned + # off, so the head jumps 50 ms and the output clicks. + steady, worst, before, after = _time_move(SlapbackDelay) + self.assertLessEqual(worst, 1.25 * steady) + self.assertGreater(after, before) + steady, worst, _, _ = _time_move(JumpTimeSlapback) + self.assertGreater(worst, 2 * steady) + + def test_tail_samples_holds_for_the_settings_as_they_stand(self): + # Re-audit 1 found the tail sentence named no clock. Each row moves + # a setting 2 048 frames into the silence (1 024 on a 40 ms Time, + # whose repeat is over by then) and reads `tail_samples` after it: + # the output is exact zero within that many frames of the move. + rows = (({}, [(REPEATS_I, 127)], 2048), + ({"repeats": 0.6}, [(REPEATS_I, 0)], 2048), + ({"time_ms": 250.0}, [(TIME_I, 0)], 2048), + ({"time_ms": 40.0}, [(TIME_I, 127)], 1024), + ({"wow_cents": 0.0}, [(WOW_I, 127)], 2048), + ({"wow_cents": 3.5, "repeats": 0.6}, [(WOW_I, 0)], 2048), + ({"time_ms": 40.0, "wow_cents": 3.5}, [(WOW_I, 0)], 1024), + ({"repeats": 0.5}, [(TONE_I, 0)], 2048), + ({"tone_hz": 2000.0, "repeats": 0.6}, [(TONE_I, 127)], 2048)) + for ctor, moves, at in rows: + before, after, last = _midtail(ctor, moves, at) + self.assertGreater(last, 0, (ctor, moves)) + self.assertLessEqual(last, after, (ctor, moves, before, after)) + # The value read before a Repeats move up does not hold after it. + before, after, last = _midtail({}, [(REPEATS_I, 127)]) + self.assertGreater(last, before) + + def test_the_reset_cells_differ_from_an_unreset_control_with_wow(self): + # The matrix's E1 and E2 cells go red on P4 at every patch, each + # with Wow above 0; with Wow 0 in the constructor and every patch + # the same cells are ok. A reset restarts the wobble where a fresh + # instance's starts (test_a_reset_restarts_the_wobble), and the + # matrix's control never stopped, so its wobble is further along. + # The class declares the cells. + for event in ("E1-reset@block", "E1-reset@part", "E2-reset_buffer"): + for patch in (None, 2): + res = _cell(event, patch) + self.assertTrue(res["P4"].startswith("RED"), (event, res)) + res = _cell(event, patch, cls=NoWowSlapback) + self.assertEqual(res["P4"], "ok", (event, res)) + + def test_a_reset_restarts_the_wobble(self): + # Backs the DECLARED reason for E1/E2 P4: after reset() or the + # host's reset_buffer, patch 0 with Wow at its top, the output is + # sample for sample a fresh instance's fed the same material from + # that frame, while an instance that ran on without the reset + # differs in nearly every sample. + at = 40 * BLOCK + for rate in (48000, 22050): + for channels in (2, 1): + noise = np.random.default_rng(7).uniform( + -12000, 12000, at + rate // 2) + for how in ("reset", "reset_buffer"): + source, _ = to_source(noise, channels, rate) + a = SlapbackDelay(source, sample_rate=rate, patch=0) + a.set_macro(WOW_I, 127) + pull(a, at) + if how == "reset": + a.reset() + else: + audiocore.reset_buffer(a.output) + a.set_macro(WOW_I, 127) + got = pull(a, rate // 2) + source, _ = to_source(noise[at:], channels, rate) + b = SlapbackDelay(source, sample_rate=rate, patch=0) + b.set_macro(WOW_I, 127) + want = pull(b, rate // 2) + key = (rate, channels, how) + self.assertEqual(int(np.count_nonzero(got != want)), 0, + key) + source, _ = to_source(noise, channels, rate) + c = SlapbackDelay(source, sample_rate=rate, patch=0) + c.set_macro(WOW_I, 127) + ran_on = pull(c, at + rate // 2)[at:] + differ = int(np.count_nonzero(ran_on[-1000:] + != want[-1000:])) + self.assertGreater(differ, 0.99 * 1000 * channels, + (rate, channels)) + + def test_a_jumping_control_steps_the_output(self): + # The matrix's E5 cell at patch 2 (Level to 0 and back) steps past + # its bar; the class declares it (audiocomponents#117). + res = _cell("E5-mix0", 2) + self.assertTrue(res["P5"].startswith("RED"), res) + self.assertEqual(res["P1"], "ok", res) + + def test_a_tail_cut_short_carries_on(self): + # The matrix's E8-dry cell at patch 2: the source hands back an + # empty buffer once, and when it comes back the tail it cut short + # plays out of the silence (audiodsp#180). + res = _cell("E8-dry", 2) + self.assertTrue(res["P3"].startswith("RED(peak"), res) + self.assertEqual(res["P2"], "ok", res) + + +# -------------------------------------------------------------------------- +# The two checks every planted fault and every row is held to + + +class FaultsAreUnreachable(unittest.TestCase): + """Every fault's reachability walk, reading only what the node is + handed, at 48, 44.1 and 22.05 kHz, on the kit's grid (17 positions per + macro) and on the fine grid (509), plus the six patches.""" + + CHECKED = 6 * 17 + 6 + CHECKED_FINE = 6 * len(FINE) + 6 + + def test_every_fault_is_off_the_surface(self): + for name, faulted, reading, ctor in REACH_WALKS: + for rate in RATES: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor) + self.assertEqual(result["checked"], self.CHECKED) + + def test_every_fault_is_off_the_fine_grid(self): + for name, faulted, reading, ctor in REACH_WALKS: + for rate in RATES: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor, grid=FINE) + self.assertEqual(result["checked"], self.CHECKED_FINE) + + def test_frames_at_44k_are_off_the_surface_at_22k_only(self): + reach(Frames441Slapback, read_frames, 22050, {}) + reach(Frames441Slapback, read_frames, 22050, {}, grid=FINE) + # At 44.1 kHz the 44.1 kHz landing is the right one: inert. + with self.assertRaises(kit_faults.FaultInert): + reach(Frames441Slapback, read_frames, 44100, {}) + + def test_the_rates_left_out_are_left_out_for_their_stated_reason(self): + # At 22.05 kHz grid 94-126 hand the old open top's clamp too. + with self.assertRaises(kit_faults.FaultReachable): + reach(OpenTopSlapback, read_damping, 22050, {}) + # The raw Tone is silent at the defaults, where Tone is out. + with self.assertRaises(kit_faults.FaultInert): + reach(RawToneSlapback, read_damping, RATE, {}) + + +class TheRetiredFaultsAreDialable(unittest.TestCase): + """The gate audit's dial, kept: the faults fix round 1 retired render + what a clean surface state renders, byte for byte.""" + + def _noise(self, rate, seconds=1.0): + frames = int(seconds * rate) + return np.frombuffer(probes.noise_det(frames=frames, dbfs=-6.0, + channels=1), + dtype=np.int16)[:frames].astype(float) + + def test_the_floor_is_repeats_0_05(self): + for rate in RATES: + x = self._noise(rate) + a = render(FloorSlapback, x, rate) + b = render(SlapbackDelay, x, rate, repeats=0.05) + c = render(SlapbackDelay, x, rate, + macros={REPEATS_I: 127 * 0.05 / 0.6}) + self.assertEqual(int(np.count_nonzero(a != b)), 0, rate) + self.assertEqual(int(np.count_nonzero(a != c)), 0, rate) + + def test_frames_at_44k_are_a_time_at_48k(self): + x = self._noise(48000) + a = render(Frames441Slapback, x, 48000) + b = render(SlapbackDelay, x, 48000, time_ms=5954 * 1000.0 / 48000) + self.assertEqual(int(np.count_nonzero(a != b)), 0) + + def test_the_open_top_is_a_constructor_tone(self): + for rate in RATES: + x = self._noise(rate) + a = render(OpenTopSlapback, x, rate) + b = render(SlapbackDelay, x, rate, tone_hz=19999.999) + self.assertEqual(int(np.count_nonzero(a != b)), 0, rate) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire, beside + a control on the real class that must pass.""" + + def test_every_row_is_red_on_a_wire(self): + for name, measure in (("T1", t1_measure), ("T2", t2_measure), + ("T3", t3_measure), ("T4", t4_measure), + ("T5", t5_measure)): + with self.subTest(row=name): + result = kit_faults.null_build_red( + SlapbackDelay, measure, label="SlapbackDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py new file mode 100644 index 0000000..1497cc4 --- /dev/null +++ b/tests/test_cpython_effects_tapedelay.py @@ -0,0 +1,3099 @@ +"""`TapeDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/TapeDelay.md`, +frozen at anchor commit fd711caf7cb421dff9c0f4d24c717f7d00548c4b; Tier 2 +rows are T1a, T1b, T2, T3, T4 (demonstrated) and T5 (disconfirmed by +design). Each demonstrated row here is the measurement at a few of the +cells its *Quantified over* column names, the same measurement red on a +planted fault of the same kind, the fault shown unreachable from every macro +position and shipped patch at three rates by what the node is handed (fix +round 1: the first walk read a marker only the fault set, and could not +fail), and the measurement red on the class built as a wire. The exhaustive grids (every Glide grid position, +every Spacing and Time position, three rates for every cell) live in the +evidence pack, not in this file. + +Re-audit fix round 2 (2026-09-28) added Tier 1's cross-feed stall: the five +stall cells and the kit's TAIL over Spread's whole travel. The trial of the +second process (2026-09-28, audiodsp v0.6.3rc3) hands Spread as set, since +the node cures the stall itself (audiodsp#173), and drops the +`RawSpreadTape` plant: it was the class as it now is, and at rc3 it ends. +`CLAIMS` ties every sentence of the module docstring to the test that +asserts it, and `BOARD_COST` holds the board figures the Cost paragraph +quotes. +""" + +import math +import os +import re +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import lifecycle # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.rebuilt import tapedelay as tape # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls) +from tools import effect_measurements as kit # noqa: E402 + +VENDOR = "PyDevices" + +TapeDelay = tape.TapeDelay + +RATE = 48000 +BLOCK = 256 +TONE = 997.0 +(TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, WOW_I, FLUTTER_I, RECORD_I, SPACING_I, + SPREAD_I, SYNC_I, DIVISION_I) = range(11) + +#: Every Tier 2 row's Held fixed: wet only, no feedback, no wobble, no +#: squash, no spread (the rows name these; Time and Spacing vary per cell). +HELD = dict(mix=2.0, feedback=0.0, wow_cents=0.0, flutter_cents=0.0, + record_level=0.0, spread=0.0) + + +# -- planted faults ------------------------------------------------------- + +class DoubleWalkTape(TapeDelay): + """T1a: the varispeed walk at twice the tape equation's rate. The Glide + law pins at 0.99 and the doubled rates here are 1.40-1.98, and no + character hands varispeed anything but the tape equation.""" + + NAME = 'TapeDelay' + + def _walk_rate(self, from_ms, to_ms): + rate = TapeDelay._walk_rate(self, from_ms, to_ms) + if self._character == tape.VARISPEED: + return 2.0 * rate + return rate + + +class GlideScaledVarispeed(TapeDelay): + """Section 8.9: the varispeed walk multiplied by 6 000 / Glide, which + would make Glide live on the character that must ignore it.""" + + NAME = 'TapeDelay' + + def _walk_rate(self, from_ms, to_ms): + rate = TapeDelay._walk_rate(self, from_ms, to_ms) + if self._character == tape.VARISPEED: + glide = self._glide_ms() + if glide > 0.0: + return rate * 6000.0 / glide + return rate + + +class WalkAtZeroTape(TapeDelay): + """Station B's step fault, Glide 0 handed slew 0.98 instead of the + jump. Fix round 1: no longer a planted fault. The clean class built with + `glide_ms=1180/0.98` (1 204.08 ms) hands the node the same 0.98 and + renders the same bytes, so the state is a constructor value; it is kept + as the control that shows the constructor walk can call a fault + reachable.""" + + NAME = 'TapeDelay' + + def _walk_rate(self, from_ms, to_ms): + rate = TapeDelay._walk_rate(self, from_ms, to_ms) + if self._character == tape.SLIDING_HEAD and rate <= 0.0: + return 0.98 + return rate + + +class FastWalkAtZeroTape(TapeDelay): + """T1b's step (fix round 1): Glide 0 handed slew 8 instead of the jump, + so the read head walks between the two Times (598 frames for + 200 -> 100.4 ms at 48 kHz) where the class jumps. Sliding-head never + hands more than the 0.99 pin, from the knob or the constructor.""" + + NAME = 'TapeDelay' + + def _walk_rate(self, from_ms, to_ms): + rate = TapeDelay._walk_rate(self, from_ms, to_ms) + if self._character == tape.SLIDING_HEAD and rate <= 0.0: + return 8.0 + return rate + + +class LoopShiftTape(TapeDelay): + """T1b's gesture, varispeed: a loop pitch shift of 0.12 semitone, which + the class never sets, so every pass round the loop is transposed.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._delay.set(loop_semitones=0.12) + + +class DoubleCornerTape(TapeDelay): + """T2: the loss corner at twice eq. (13)'s -3 dB point.""" + + NAME = 'TapeDelay' + + def _corner_hz(self, time_ms, spacing_um): + return 2.0 * TapeDelay._corner_hz(self, time_ms, spacing_um) + + +class PostLossTape(TapeDelay): + """T2's n-pass clause: the loss taken out of the loop and put once on + the wet output, through a second node one frame long, so every repeat + carries one pass of loss instead of n.""" + + NAME = 'TapeDelay' + + def _build(self, *arguments, **keywords): + self._post = None + TapeDelay._build(self, *arguments, **keywords) + import audioecho + self._post = self._own(audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, max_delay_ms=1.0, + delay_ms=1000.0 / self._sample_rate, feedback=0.0, mix=2.0, + damping_hz=self._damping)) + self._post.play(self._delay) + self._output = self._post + self._refresh() + + def _refresh(self): + TapeDelay._refresh(self) + self._delay.set(damping_hz=0.0) + if getattr(self, "_post", None) is not None: + self._post.set(damping_hz=self._damping) + + +class SquareLawTape(TapeDelay): + """T3, varispeed: the corner following the speed squared.""" + + NAME = 'TapeDelay' + + def _corner_hz(self, time_ms, spacing_um): + corner = TapeDelay._corner_hz(self, time_ms, spacing_um) + if self._character == tape.VARISPEED: + corner *= (tape.speed(tape.VARISPEED, time_ms) + / tape.speed(tape.VARISPEED, 350.0)) + return corner + + +class HalfFollowTape(TapeDelay): + """T3, sliding-head: the corner following the square root of the + varispeed speed, so the fixed transport's loss moves with Time.""" + + NAME = 'TapeDelay' + + def _corner_hz(self, time_ms, spacing_um): + corner = TapeDelay._corner_hz(self, time_ms, spacing_um) + if self._character == tape.SLIDING_HEAD: + corner *= math.sqrt(tape.speed(tape.VARISPEED, time_ms) + / tape.speed(tape.VARISPEED, 350.0)) + return corner + + +class FlutterOnWowLineTape(TapeDelay): + """T4's two-line clause: the flutter component written at the wow + line's harmonic (72) instead of its own (512), so the table carries one + line in 0.2-12 Hz. No position reaches it: the clean table always puts + Flutter at harmonic 512, and at Flutter 0 the wow line carries only the + wow, with the drift at its fixed 0.25 ms per cent of Wow beside it.""" + + NAME = 'TapeDelay' + + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, flutter_cents, out, + flutter_harmonic=tape.WOW_HARMONIC) + + +class NoFlutterLineTape(TapeDelay): + """The first round's two-line fault, kept only to show the table walk + can fail: the flutter line deleted while Flutter is up is the table the + clean class writes at Flutter grid 0 (the reviewer's 0 differing + samples), a macro position in disguise (pattern revision section 1.3). + It is not a planted fault of any row.""" + + NAME = 'TapeDelay' + + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, 0.0, out) + + +class Harmonic504Tape(TapeDelay): + """T4's ratio clause: the flutter line at harmonic 504, exactly 7 x 72.""" + + NAME = 'TapeDelay' + + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, flutter_cents, out, + flutter_harmonic=504) + + +class NoDriftTape(TapeDelay): + """T4's slow-band clause: the table with the slow component zeroed, + which the detector must read red-free.""" + + NAME = 'TapeDelay' + + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, flutter_cents, out, drift=False) + + +class DialableTape(TapeDelay): + """The reviewer's control for the walk itself: Spacing forced to 20 um, + which Spacing MIDI 127 and patch 5 both play. Not a fault of any row; + the damping reading must call it reachable.""" + + NAME = 'TapeDelay' + + def _corner_hz(self, time_ms, spacing_um): + return TapeDelay._corner_hz(self, time_ms, tape.SPACING_MAX_UM) + + +class _Stepper: + """Pulls the owner's node, letting the owner move its delay from Python + first: the finest a Python-driven Time can move is once per block + (DigitalDelay's `_Stepper`).""" + + def __init__(self, owner): + self._owner = owner + node = owner._delay + self.sample_rate = node.sample_rate + self.channel_count = node.channel_count + self.bits_per_sample = 16 + self.samples_signed = True + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + audiocore.reset_buffer(self._owner._delay) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self._owner._step() + return audiocore.get_buffer(self._owner._delay, + single_channel_output, audio_channel) + + +class StaircaseTape(TapeDelay): + """T1b's no-step clause: `delay_ms` stepped from Python once per + 256-frame block with the node's slew off, at the rate the character + asks for. No position reaches it: Glide grid 0 is one jump, and every + other position is the node's own per-frame walk, handed once per + move.""" + + NAME = 'TapeDelay' + + def _build(self, *arguments, **keywords): + self._current_ms = None + self._step_per_block = 0.0 + TapeDelay._build(self, *arguments, **keywords) + self._output = _Stepper(self) + + def _refresh(self): + TapeDelay._refresh(self) + if self._current_ms is None or self._fresh: + self._current_ms = self._node_ms + self._step_per_block = self._slew * BLOCK * 1000.0 / self._sample_rate + self._delay.set(delay_slew=0.0, delay_ms=self._current_ms) + + def _step(self): + target = self._node_ms + step = self._step_per_block + current = self._current_ms + if current == target: + return + if step <= 0.0 or abs(target - current) <= step: + current = target + elif target > current: + current += step + else: + current -= step + self._current_ms = current + self._delay.set(delay_ms=current) + + +class PerBlockSlidingTape(TapeDelay): + """T1b's sliding-head repeats clause: the moves handed the tape + equation's T(t) once per block (a motor, not a head), a per-block hook + the class does not have. The speed in each block is L / T, T the Time + last asked and L the Time the instance was built at; T at the block's + end is the span back over which the tape ran L. With it the repeats + after a gesture telescope back to the source, as a varispeed's do.""" + + NAME = 'TapeDelay' + + def _build(self, *arguments, **keywords): + self._speeds = None + TapeDelay._build(self, *arguments, **keywords) + self._length = float(self._frames) + self._speeds = [] + self._current = float(self._frames) + self._output = _Stepper(self) + + def _refresh(self): + TapeDelay._refresh(self) + if self._speeds is not None: + self._delay.set(delay_slew=0.0, + delay_ms=self._current * 1000.0 + / self._sample_rate) + + def _step(self): + self._speeds.append(self._length / self._frames) + remaining = self._length + span = 0.0 + for speed in reversed(self._speeds): + if speed * BLOCK >= remaining: + span += remaining / speed + remaining = 0.0 + break + span += BLOCK + remaining -= speed * BLOCK + if remaining > 0.0: + span += remaining / self._speeds[0] + if span != self._current: + slew = max(abs(span - self._current) / BLOCK, 1e-9) + self._current = span + self._delay.set(delay_slew=slew, + delay_ms=span * 1000.0 / self._sample_rate) + + +class SteppedTape(TapeDelay): + """The workaround retired at audiodsp v0.6.3rc1: the Feedback handed to + the node at the nearer edge of the loss low-pass's stall window + (`clear_of_stalls`), a Feedback nobody set.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + excess = tape.tone_excess(self._damping, self._sample_rate)[1] + stepped = clear_of_stalls(self._feedback, excess) + if stepped != self._feedback: + self._feedback = stepped + self._delay.set(feedback=stepped) + + +class NoneAtZeroTape(TapeDelay): + """The class before audiodsp v0.6.3rc1: a Wow and Flutter of 0 hands the + node no table, so the node ramps the old depth out (#160) on its own + sine instead of the table's shape, a jump in the read offset.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + if self._wow_ms == 0.0 and self._table is not None: + self._table = None + self._delay.set(wow_shape=None) + + +class LeanDriveOnTape(TapeDelay): + """The lean patch with the drive left on: patch 8 plays patch 0's + Record Level, so it names the saving and makes none (Brad's cost + ruling, 2026-09-28).""" + + NAME = 'TapeDelay' + PATCHES = dict(TapeDelay.PATCHES) + PATCHES[8] = ("Tape Delay - lean", TapeDelay.PATCHES[0][1]) + + +class LeanMovesMoreTape(TapeDelay): + """A lean patch that also moves Spacing: the drive is off, but it is no + longer patch 0 with the drive off.""" + + NAME = 'TapeDelay' + PATCHES = dict(TapeDelay.PATCHES) + PATCHES[8] = ("Tape Delay - lean", + (89, 58, 22, 89, 32, 32, 0, 60, 0, 0, 51)) + + +class LeanResetTape(TapeDelay): + """Not the contract: a reset that restores the lean patch instead of + patch 0, so the drive stays off. The docstring says reset() brings it + back; this is the build that sentence would be false on.""" + + NAME = 'TapeDelay' + + def reset(self): + TapeDelay.reset(self) + self.program_change(8) + + +class DriveOffTape(TapeDelay): + """Not a fault: the cost study's variant D, `loop_drive` handed 0 after + every refresh (`tapedelay_cost_variants.py`, `DriveOff`). Built at + `max_time_ms=800` it is variant K, the configuration the boards + measured.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._delay.set(loop_drive=0.0) + + +class JumpWowTape(TapeDelay): + """A Wow or Flutter move that moves the read head by the whole change in + depth at once, as the node did at the wobble's crest up to v0.6.2 (it + added depth x table with no ramp).""" + + NAME = 'TapeDelay' + + def _refresh(self): + old = self._wow_ms + TapeDelay._refresh(self) + if not self._seeding and not self._deferred and self._wow_ms != old: + self._delay.set(delay_slew=0.0, + delay_ms=self._node_ms + self._wow_ms - old) + + +# -- sources and renders -------------------------------------------------- + +def src_of(x, channels=2, rate=RATE): + """A float signal (frames,) as an int16 ArraySource, the same on every + channel.""" + x = np.clip(np.round(np.asarray(x, dtype=float)), -32768, 32767) + x = np.repeat(x.astype(np.int16)[:, None], channels, axis=1) + return probes.ArraySource(array("h", x.reshape(-1).tobytes()), + rate=rate, channels=channels, block=BLOCK) + + +def sine(hz, amp, frames, rate=RATE): + return amp * np.sin(2.0 * math.pi * hz * np.arange(frames) / rate) + + +def render(effect, frames, events=None): + """Pull `frames` frames from the effect; `events` maps a frame to a + callable applied to the effect before the block that starts there + (the render pulls whole blocks). Returns (frames, channels) float.""" + channels = effect.channel_count + events = sorted((events or {}).items()) + out = [] + done = 0 + while done < frames: + while events and events[0][0] <= done: + events.pop(0)[1](effect) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + break + block = np.frombuffer(data, dtype=np.int16).reshape(-1, channels) + out.append(block) + done += block.shape[0] + y = np.concatenate(out)[:frames].astype(float) + if y.shape[0] < frames: + y = np.vstack([y, np.zeros((frames - y.shape[0], channels))]) + return y + + +def time_midi(ms): + """Time as a float MIDI value, so `set_macro` lands on `ms`.""" + return _component.macro_position(TapeDelay._MACRO_RANGES[TIME_I], + ms) * 127.0 + + +def set_time(ms): + return lambda effect: effect.set_macro(TIME_I, time_midi(ms)) + + +def frames_of(ms, rate=RATE): + return int(math.floor(ms * rate / 1000.0 + 0.5)) + + +def cents(ratio): + return 1200.0 * math.log2(ratio) + + +# -- estimators ----------------------------------------------------------- + +def _fit(seg, rate, hz): + n = np.arange(len(seg)) + a = np.stack([np.cos(2 * np.pi * hz * n / rate), + np.sin(2 * np.pi * hz * n / rate), np.ones(len(seg))], 1) + coef, *_ = np.linalg.lstsq(a, seg, rcond=None) + r = seg - a @ coef + return float(r @ r), coef + + +def peak_hz(x, rate, pad=16): + x = np.asarray(x, dtype=float) + x = (x - x.mean()) * np.hanning(len(x)) + n = len(x) * pad + s = np.abs(np.fft.rfft(x, n)) + i = int(np.argmax(s[1:])) + 1 + a, b, c = (math.log(s[i - 1] + 1e-30), math.log(s[i] + 1e-30), + math.log(s[i + 1] + 1e-30)) + denom = a - 2 * b + c + p = 0.5 * (a - c) / denom if denom != 0.0 else 0.0 + return (i + p) * rate / n + + +def lsq_hz(seg, rate): + """Least-squares sine with a DC term: the search starts at the + segment's own spectral peak and takes no law (the Station A critique's + estimator).""" + seg = np.asarray(seg, dtype=float) + if len(seg) < 16 or not np.any(seg): + return float("nan") + f0 = peak_hz(seg, rate) + span = 1.5 * rate / len(seg) + a, b = max(1.0, f0 - span), f0 + span + g = (math.sqrt(5) - 1) / 2 + x1, x2 = b - g * (b - a), a + g * (b - a) + e1, e2 = _fit(seg, rate, x1)[0], _fit(seg, rate, x2)[0] + for _ in range(50): + if e1 < e2: + b, x2, e2 = x2, x1, e1 + x1 = b - g * (b - a) + e1 = _fit(seg, rate, x1)[0] + else: + a, x1, e1 = x1, x2, e2 + x2 = a + g * (b - a) + e2 = _fit(seg, rate, x2)[0] + return 0.5 * (a + b) + + +def analytic(x): + x = np.asarray(x, dtype=float) + n = len(x) + spec = np.fft.fft(x) + h = np.zeros(n) + h[0] = 1.0 + if n % 2 == 0: + h[n // 2] = 1.0 + h[1:n // 2] = 2.0 + else: + h[1:(n + 1) // 2] = 2.0 + return np.fft.ifft(spec * h) + + +def ifreq(x, rate): + return np.diff(np.unwrap(np.angle(analytic(x)))) * rate / (2 * math.pi) + + +# -- T1: one Time move ---------------------------------------------------- + +def start_of(from_ms, rate): + return max(20480, (frames_of(from_ms, rate) + 9600) // BLOCK * BLOCK) + + +def steady_slope(hz, time_ms, character, rate=RATE, amp=12000.0, + spacing_um=5.0): + """The largest first difference a steady tone at `hz` makes through the + class at `time_ms`, unramped: a tone's own slope through the corner in + force.""" + frames = frames_of(time_ms, rate) + int(0.3 * rate) + effect = TapeDelay(src_of(sine(hz, amp, frames, rate), 1, rate), + time_ms=time_ms, character=character, + spacing_um=spacing_um, **HELD) + y = render(effect, frames)[:, 0] + return float(np.max(np.abs(np.diff(y[frames_of(time_ms, rate) + 2000:])))) + + +def move(cls, a, b, rate=RATE, character=tape.VARISPEED, amp=12000.0, + channels=1, after_ms=400.0, **options): + """A 997 Hz tone through one Time move a -> b issued on a block + boundary once the wet tone is established. Returns the left channel, + the move's frame and the effect.""" + opts = dict(HELD) + opts.update(options) + start = start_of(a, rate) + frames = start + int(abs(frames_of(b, rate) - frames_of(a, rate)) * 3 + + rate * after_ms / 1000.0) + 2 * rate + effect = cls(src_of(sine(TONE, amp, frames, rate), channels, rate), + time_ms=a, character=character, **opts) + y = render(effect, frames, {start: set_time(b)})[:, 0] + return y, start, effect + + +def t1a_cell(cls, a, b, rate=RATE, amp=12000.0): + """T1a's clauses on one move: the pitch over the walk against + 1200 log2(T_old / T_new), the hold against T_new, the residual + 50-250 ms after, and the walk's first differences against the shifted + tone's own slope.""" + y, start, effect = move(cls, a, b, rate, amp=amp) + ta, tb = frames_of(a, rate), frames_of(b, rate) + law_c = cents(ta / float(tb)) + law_hz = TONE * ta / float(tb) + walk = tb # the tape equation: the move lasts T_new + trim = min(400, walk // 10) + got = lsq_hz(y[start + trim:start + walk - trim], rate) + err = cents(got / law_hz) if got == got else float("inf") + # The hold: from where the pitch passes half way (in cents) to the law + # on the way in to where it passes back on the way out. The smoothing + # and the low-pass's settle spread both edges alike, so they cancel. + fi = ifreq(y[start - 2000:start + 3 * walk], rate) + k = max(1, int(0.001 * rate)) + fi = np.convolve(fi, np.ones(k) / k, mode="same")[k:-k] + shift = 1200.0 * np.log2(np.maximum(fi, 1e-9) / TONE) + past = np.nonzero(shift * math.copysign(1.0, law_c) > abs(law_c) / 2)[0] + hold = int(past[-1] - past[0] + 1) if len(past) else 0 + after = y[start + walk + int(0.05 * rate):start + walk + int(0.25 * rate)] + res = lsq_hz(after, rate) + residual = cents(res / TONE) if res == res else float("inf") + tau = rate / (2 * math.pi * effect._damping) + settle = int(math.ceil(5 * tau)) + body = y[start + settle:start + walk - 64] + bar = 1.05 * steady_slope(law_hz, b, tape.VARISPEED, rate, amp) + step = float(np.max(np.abs(np.diff(body)))) / bar if len(body) > 2 \ + else 0.0 + red = (abs(err) > 10.0 or abs(hold - walk) > 0.05 * walk + or abs(residual) > 1.0 or step > 1.0) + return dict(passed=not red, err=err, law=law_c, hold=hold, walk=walk, + residual=residual, step=step) + + +class TheSurface(unittest.TestCase): + def test_macros_characters_tier_latency(self): + self.assertEqual(TapeDelay.MACRO_LABELS, ( + "Time", "Feedback", "Mix", "Glide", "Wow", "Flutter", + "Record Level", "Spacing", "Spread", "Sync", "Division")) + self.assertEqual(TapeDelay.MACRO_MODES[SYNC_I], "TOGGLE") + self.assertEqual(len(TapeDelay.PATCHES), 9) + self.assertEqual(TapeDelay.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(TapeDelay.LATENCY_SAMPLES, 0) + self.assertEqual(TapeDelay.TIER, _component.AUDIODSP) + self.assertEqual(TapeDelay.REQUIRES, ("audioecho",)) + self.assertEqual(tape.CHARACTERS, ("varispeed", "sliding-head")) + for character in tape.CHARACTERS: + effect = TapeDelay(src_of(np.zeros(512)), character=character) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.patch_index, 0) + with self.assertRaises(ValueError): + TapeDelay(src_of(np.zeros(512)), character="reel") + + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects + self.assertIs(rebuilt.module_class("TapeDelay"), TapeDelay) + self.assertIn("TapeDelay", rebuilt.ADOPTED) + self.assertNotIn("TapeDelay", rebuilt.parked()) + self.assertIs(rebuilt.load("TapeDelay"), TapeDelay) + self.assertIs(audioeffects.TapeDelay, TapeDelay) + served = audioeffects.create("TapeDelay", src_of(np.zeros(64)), RATE) + self.assertIsInstance(served, TapeDelay) + served.deinit() + + def test_patches_are_the_dossier_settings_on_the_grid(self): + # Section 6's table: Time, Fdbk, Mix, Glide, Wow, Flutter, Rec, + # Spacing, Spread, Sync, Div (as an index). + settings = ( + (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 0.0, 6.0), + (800.0, 0.55, 0.35, 12000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 0.0, 6.0), + (90.0, 0.15, 0.50, 6000.0, 1.0, 0.5, 0.2, 5.0, 0.0, 0.0, 6.0), + (450.0, 0.55, 0.35, 6000.0, 6.0, 2.0, 0.2, 15.0, 0.0, 0.0, 6.0), + (400.0, 0.90, 0.40, 6000.0, 2.0, 1.0, 0.6, 5.0, 0.0, 0.0, 6.0), + (350.0, 0.45, 0.35, 6000.0, 4.0, 3.0, 0.5, 20.0, 0.0, 0.0, 6.0), + (350.0, 0.45, 0.35, 6000.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 6.0), + (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 1.0, 8.0), + (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.0, 5.0, 0.0, 0.0, 6.0), + ) + for index, values in enumerate(settings): + want = tuple( + _component.macro_of(span, value, TapeDelay.MACRO_MODES[i]) + for i, (span, value) in enumerate( + zip(TapeDelay._MACRO_RANGES, values))) + self.assertEqual(TapeDelay.PATCHES[index][1], want, index) + + def test_patch_0_is_the_constructor_grid(self): + effect = TapeDelay(src_of(np.zeros(512))) + for index, expected in enumerate(TapeDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_time_lands_on_a_whole_frame(self): + for rate, frames in ((48000, 16800), (44100, 15435), (22050, 7718)): + effect = TapeDelay(src_of(np.zeros(512), rate=rate)) + self.assertEqual(effect._frames, frames) + self.assertEqual(effect._node_ms, frames * 1000.0 / rate) + # A host echoing Time back keeps the constructor's exact Time. + effect.set_macro(TIME_I, effect.get_macro(TIME_I)) + self.assertEqual(effect._frames, frames) + + def test_the_loss_corner(self): + # Eq. (13)'s half-power wavelength: 393.5 um at 5, 336.7 at 2 and + # 671.4 at 20 (dossier section 6). + for spacing, wavelength in ((5.0, 393.5), (2.0, 336.7), + (20.0, 671.4)): + k3 = tape.k3_of(spacing * 1e-6) + self.assertAlmostEqual(2 * math.pi / k3 * 1e6, wavelength, + delta=0.05) + # 22.05 kHz: 521.85 and 515.49 Hz (the dossier rounds the first to + # 521.9). + for rate, varispeed, sliding in ((48000, 522.6, 516.2), + (22050, 521.85, 515.49)): + got = [TapeDelay(src_of(np.zeros(512), rate=rate), + character=c)._damping + for c in tape.CHARACTERS] + self.assertAlmostEqual(got[0], varispeed, delta=0.05) + self.assertAlmostEqual(got[1], sliding, delta=0.05) + # The speed law: 40 cm/s to 180 ms, 12 cm/s from 600 ms. + self.assertEqual(tape.speed(tape.VARISPEED, 60.0), 0.40) + self.assertEqual(tape.speed(tape.VARISPEED, 1200.0), 0.12) + self.assertAlmostEqual(tape.speed(tape.VARISPEED, 350.0), 0.20571, + places=5) + self.assertEqual(tape.speed(tape.SLIDING_HEAD, 60.0), 0.2032) + + def test_the_wow_table(self): + effect = TapeDelay(src_of(np.zeros(512))) + self.assertAlmostEqual(effect._wow_ms, 0.758, delta=0.001) + table = effect._table + self.assertEqual(len(table), 4096) + self.assertEqual(max(abs(v) for v in table), 32767) + first = table + effect.set_macro(WOW_I, 127) + # A move writes the table the node is not reading. + self.assertIsNot(effect._table, first) + effect.set_macro(FLUTTER_I, 127) + self.assertIs(effect._table, first) + # The stops: 1.024 + 0.072 + 2.000 ms of components, 3.034 ms peak. + self.assertAlmostEqual(effect._wow_ms, 3.034, delta=0.001) + self.assertAlmostEqual(tape.cents_to_depth_ms(8.0, 0.72), 1.024, + delta=0.0005) + self.assertAlmostEqual(tape.cents_to_depth_ms(4.0, 5.12), 0.072, + delta=0.0005) + # Down to 0 on a playing node the last table stays handed, so the + # depth the node ramps out over 20 ms (audiodsp#160) leaves on its + # own shape; at depth 0 the table moves nothing. + effect.set_macro(WOW_I, 0) + effect.set_macro(FLUTTER_I, 0) + self.assertEqual(effect._wow_ms, 0.0) + self.assertIsNotNone(effect._table) + # Planted: the class before v0.6.3rc1 dropped it. + dropped = NoneAtZeroTape(src_of(np.zeros(512))) + dropped.set_macro(WOW_I, 0) + dropped.set_macro(FLUTTER_I, 0) + self.assertIsNone(dropped._table) + # A fresh node snaps onto its depth, so a constructor or patch at 0 + # hands no table at all. + for effect in (TapeDelay(src_of(np.zeros(512)), wow_cents=0.0, + flutter_cents=0.0), + TapeDelay(src_of(np.zeros(512)), patch=6)): + self.assertIsNone(effect._table) + self.assertEqual(effect._wow_ms, 0.0) + + def test_the_table_holds_its_three_components(self): + out = array("h", [0] * 4096) + depth = tape.wow_table(8.0, 4.0, out) + spec = np.abs(np.fft.rfft(np.array(out, dtype=float))) \ + * depth / 32767.0 * 2.0 / 4096.0 + self.assertAlmostEqual(spec[72], 1.024, delta=0.002) + self.assertAlmostEqual(spec[512], 0.072, delta=0.002) + drift = spec[1:10] * np.arange(1, 10) + self.assertLess(float(np.ptp(drift)), 0.002) + self.assertGreater(float(np.sum(spec[1:10])), 0.5) + others = np.delete(spec[1:], [k - 1 for k in + list(range(1, 10)) + [72, 512]]) + self.assertLess(float(np.max(others)), 0.0005) + + def test_tail_samples_at_each_patch_played_from_rest(self): + # Dossier Tier 3 and App. F7', varispeed, 48 kHz. Patch 7 at the + # static transport keeps its knob Time (patch 0's). At a 120 bpm host + # it plays 1/8. = 0.75 beat = 375 ms: App. F7' sized it at 187.5 ms, + # which is 1/16. at 120 bpm, so its 128 982 frames is not the bound + # this patch needs; the class's is 257 418. + want = (241990, 692550, 27336, 407322, 1671015, 245728, 240800, + 241990, 241990) + for index, frames in enumerate(want): + effect = TapeDelay(src_of(np.zeros(512)), patch=index) + self.assertEqual(effect.tail_samples, frames, index) + effect = TapeDelay.create(src_of(np.zeros(512)), RATE, + transport=lambda: (True, 0.0, 120.0, 4, 4), + patch=7) + self.assertAlmostEqual(effect._time_played, 375.0, places=6) + memory, excess = tape.tone_excess(effect._damping, RATE) + laps = tape.laps_to_zero(effect._feedback, excess) + self.assertEqual(effect.tail_samples, + laps * (18000 + 37 + 1 + memory)) + self.assertEqual(effect.tail_samples, 257418) + effect = TapeDelay(src_of(np.zeros(512))) + self.assertEqual(effect.tail_samples, 240282) + + def test_the_glide_law_and_its_floor(self): + effect = TapeDelay(src_of(np.zeros(512)), character="sliding-head") + self.assertAlmostEqual(effect._slew, 1180.0 / 6000.0, places=12) + effect.set_macro(GLIDE_I, 1) + self.assertAlmostEqual(effect._slew, 0.965666, places=5) + effect.set_macro(GLIDE_I, 127) + self.assertAlmostEqual(effect._slew, 1180.0 / 12000.0, places=12) + effect.set_macro(GLIDE_I, 0) + self.assertEqual(effect._slew, 0.0) + # A constructor Glide faster than grid 1 keeps its walk (the 0.99 + # pin) and seeds the knob at grid 1, never grid 0, the jump. + fast = TapeDelay(src_of(np.zeros(512)), character="sliding-head", + glide_ms=1000.0) + self.assertEqual(fast._slew, 0.99) + self.assertAlmostEqual(fast.get_macro(GLIDE_I), 1.0, places=9) + fast.set_macro(GLIDE_I, round(fast.get_macro(GLIDE_I))) + self.assertGreater(fast._slew, 0.0) + for jump in (0.0, -5.0, float("nan")): + self.assertEqual(TapeDelay(src_of(np.zeros(512)), + character="sliding-head", + glide_ms=jump)._slew, 0.0) + + def test_constructor_clamps_and_nan(self): + effect = TapeDelay(src_of(np.zeros(512)), time_ms=0.0, spacing_um=0.0, + max_time_ms=float("nan"), feedback=float("nan")) + self.assertEqual(effect._frames, frames_of(20.0)) + self.assertAlmostEqual(effect.macro(SPACING_I), 2.0, places=9) + self.assertEqual(effect._max_time_ms, 1200.0) + self.assertAlmostEqual(effect.macro(FEEDBACK_I), 0.45, places=9) + low = TapeDelay(src_of(np.zeros(512)), time_ms=900.0, + max_time_ms=300.0) + self.assertEqual(low._frames, frames_of(300.0)) + self.assertAlmostEqual(low.macro(TIME_I), 300.0, places=6) + low.set_macro(TIME_I, 127) + self.assertAlmostEqual(low.macro(TIME_I), 300.0, places=6) + + def test_spread_is_held_at_zero_in_mono(self): + mono = TapeDelay(src_of(np.zeros(512), channels=1), spread=1.0) + self.assertEqual(mono._spread, 0.0) + stereo = TapeDelay(src_of(np.zeros(512)), spread=1.0) + self.assertEqual(stereo._spread, 1.0) + + +LEAN = 8 + + +def lean_surface(cls): + """(the lean patch's name, the positions where it differs from patch 0 + as {index: (patch 0, lean)}).""" + name, lean = cls.PATCHES[LEAN] + full = cls.PATCHES[0][1] + return name, {i: (a, b) for i, (a, b) in enumerate(zip(full, lean)) + if a != b} + + +def lean_handed(cls, **ctor): + """What the node is handed at the lean patch and at patch 0, as + {option: (patch 0, lean)} for every option that differs; a table is + compared point by point.""" + def state(patch): + effect = cls(src_of(np.zeros(512)), patch=patch, **ctor) + handed = dict(effect._delay._handed) + effect.deinit() + if handed.get("wow_shape") is not None: + handed["wow_shape"] = tuple(handed["wow_shape"]) + return handed + with NodeSpy(): + full, lean = state(0), state(LEAN) + return {k: (full.get(k), lean.get(k)) for k in set(full) | set(lean) + if full.get(k) != lean.get(k)} + + +def lean_render(cls, patch=LEAN, **ctor): + """A 997 Hz tone at -1 dBFS for 300 ms, then silence, through `patch`, + one second at 48 kHz stereo: loud enough that the drive shows on every + repeat.""" + x = np.zeros(RATE) + x[:int(0.3 * RATE)] = sine(TONE, 29205.0, int(0.3 * RATE)) + return render(cls(src_of(x), patch=patch, **ctor), RATE) + + +class LeanPatch(unittest.TestCase): + """Brad's cost ruling of 2026-09-28: keep the class and add a lean + patch. Patch 8 `Tape Delay - lean` is patch 0 with Record Level 0, and + with `max_time_ms=800` it is the cost study's variant K, which met the + P4 and S3 bars in every run.""" + + def test_the_lean_patch_is_patch_0_with_the_drive_off(self): + name, moved = lean_surface(TapeDelay) + self.assertEqual(name, "Tape Delay - lean") + self.assertTrue(name.endswith(" - lean")) + self.assertEqual(moved, {RECORD_I: (25, 0)}) + # What the node is handed: the drive off, and nothing else moved. + handed = lean_handed(TapeDelay) + self.assertEqual(set(handed), {"loop_drive"}) + self.assertGreater(handed["loop_drive"][0], 0.19) + self.assertEqual(handed["loop_drive"][1], 0.0) + self.assertEqual(lean_handed(TapeDelay, max_time_ms=800.0), + lean_handed(TapeDelay)) + # Planted: the drive left on, and a lean patch that moves more. + self.assertEqual(lean_handed(LeanDriveOnTape), {}) + self.assertNotEqual(lean_surface(LeanDriveOnTape)[1], + {RECORD_I: (25, 0)}) + self.assertIn("damping_hz", lean_handed(LeanMovesMoreTape)) + self.assertNotEqual(lean_surface(LeanMovesMoreTape)[1], + {RECORD_I: (25, 0)}) + + def test_the_lean_build_renders_what_the_boards_measured(self): + lean = lean_render(TapeDelay, max_time_ms=800.0) + self.assertGreater(float(np.max(np.abs(lean[int(0.4 * RATE):]))), + 1000.0) + # Variant K at patch 0 is the cell the boards timed; patch 8 at the + # 800 ms line renders it byte for byte, and so does patch 8 on the + # full line (the shorter line moves no byte at this Time). + k = lean_render(DriveOffTape, 0, max_time_ms=800.0) + self.assertEqual(lean.tobytes(), k.tobytes()) + self.assertEqual(lean.tobytes(), lean_render(TapeDelay).tobytes()) + # The drive is what the lean patch drops: patch 0 differs. + self.assertNotEqual(lean.tobytes(), + lean_render(TapeDelay, 0).tobytes()) + # Planted: the drive left on renders patch 0, not variant K. + on = lean_render(LeanDriveOnTape, max_time_ms=800.0) + self.assertNotEqual(on.tobytes(), k.tobytes()) + # The 800 ms build stops Time there, where get_macro(0) shows it. + effect = TapeDelay(src_of(np.zeros(512)), patch=LEAN, + max_time_ms=800.0) + effect.set_macro(TIME_I, 127) + self.assertAlmostEqual(effect.macro(TIME_I), 800.0, places=6) + self.assertEqual(effect._frames, frames_of(800.0)) + + def _drive_across_reset(self, cls): + """(Record Level MIDI and the drive handed) on the lean patch at the + 800 ms build, after `reset()`, and after `program_change(8)`.""" + readings = [] + with NodeSpy(): + effect = cls(src_of(np.zeros(512)), patch=LEAN, + max_time_ms=800.0) + for step in (None, effect.reset, + lambda: effect.program_change(LEAN)): + if step is not None: + step() + readings.append((effect.get_macro(RECORD_I), + round(effect._delay._handed["loop_drive"], + 5))) + effect.deinit() + return readings + + def test_reset_brings_the_drive_back(self): + # Re-audit fix round 2, the docstring's restated sentence: reset() + # restores patch 0 (the component contract), so the drive and its + # cost come back and a board calls program_change(8) after it. + self.assertEqual(self._drive_across_reset(TapeDelay), + [(0.0, 0.0), (25.0, 0.19685), (0.0, 0.0)]) + # Planted: a reset that restored the lean patch would make the + # sentence false; the reading sees it. + self.assertEqual(self._drive_across_reset(LeanResetTape)[1], + (0.0, 0.0)) + + +class T1aVarispeed(unittest.TestCase): + def test_the_named_moves(self): + for rate, a, b in ((48000, 200.0, 100.4), (48000, 180.0, 600.0), + (48000, 40.0, 20.0), (22050, 200.0, 100.4), + (44100, 600.0, 180.0)): + got = t1a_cell(TapeDelay, a, b, rate) + self.assertTrue(got["passed"], (rate, a, b, got)) + got = t1a_cell(TapeDelay, 200.0, 100.4) + self.assertAlmostEqual(got["law"], 1193.2, delta=0.05) + self.assertLess(abs(got["err"]), 1.0) + + def test_the_level_span(self): + for amp in (380.0, 32000.0): + got = t1a_cell(TapeDelay, 200.0, 100.4, 48000, amp) + self.assertTrue(got["passed"], (amp, got)) + + def test_a_doubled_walk_is_red(self): + for rate, a, b in ((48000, 200.0, 100.4), (22050, 180.0, 600.0)): + got = t1a_cell(DoubleWalkTape, a, b, rate) + self.assertFalse(got["passed"], got) + self.assertGreater(abs(got["err"]), 400.0) + + def test_the_top_binade_per_piece(self): + # Fix round 1 (dossier T1a, revised under vision 7.2): a rising + # move from rest whose walk passes 32 768 frames is claimed to a + # ratio of 2.95 : 1. The node rounds each step of its float32 walk + # to the read head's ulp (2^-8 frames up there), and past that + # ratio the top piece can read over 10 c. The claimed edge cell is + # the float32 model's worst claimed move (408.90 -> 1 196.31 ms, + # ratio 2.926, model -9.92 c); 333 -> 1 100 ms (ratio 3.30) is the + # span refuter's excluded cell, red on its top piece. + for a, b, red in ((19627 / 48.0, 57423 / 48.0, False), + (333.0, 1100.0, True)): + law = TONE * frames_of(a) / float(frames_of(b)) + got = walk_cell(TapeDelay, a, b, RATE, tape.VARISPEED, law) + self.assertEqual(got["unread"], 0, got) + self.assertEqual(got["passed"], not red, (a, b, got)) + self.assertAlmostEqual(got["worst"], -11.16, delta=0.05) + + +class GlideIsInertOnVarispeed(unittest.TestCase): + """Section 8.9: one varispeed Time move at Glide grid 1, grid 127 and + the constructor's 6 000 ms renders byte-identical, and the fault that + scales the walk by 6 000 / Glide does not.""" + + def _renders(self, cls): + out = [] + for glide in (None, 1, 127): + opts = dict(HELD) + frames = 20480 + 3 * RATE // 4 + effect = cls(src_of(sine(TONE, 12000.0, frames), 2), + time_ms=200.0, **opts) + if glide is not None: + effect.set_macro(GLIDE_I, glide) + out.append(render(effect, frames, + {20480: set_time(100.4)}).tobytes()) + return out + + def test_three_glides_one_render(self): + a, b, c = self._renders(TapeDelay) + self.assertEqual(a, b) + self.assertEqual(a, c) + + def test_a_glide_scaled_walk_is_red(self): + a, b, c = self._renders(GlideScaledVarispeed) + self.assertNotEqual(a, b) + self.assertNotEqual(a, c) + + +#: How far either side of the nominal walk end the edge window reaches: the +#: float32 walk lands -65 to +16 frames off it (dossier App. F1'). +WALK_SLACK = 128 + + +def ramp_walk_end(cls, a, b, rate, character, start, **options): + """Where the read head stops, read off a render of the same move with + a slope-1/k ramp in place of the tone (DigitalDelay's `walk_end_ramp`): + the wet output is the ramp delayed, so D[n] = n - k (y[n] + 30 000) is + the delay in frames to within k, plus the loss low-pass's constant lag. + The walk ends at the last frame whose D is more than 2k + 2 frames off + the settled D of the render's last 0.2 s.""" + opts = dict(HELD) + opts.update(options) + slew = abs(frames_of(b, rate) - frames_of(a, rate)) + frames = start + int(slew * 12) + rate // 2 + k = max(1, -(-frames // 60000)) + ramp = np.arange(frames) // k - 30000.0 + effect = cls(src_of(ramp, 1, rate), time_ms=a, character=character, + **opts) + y = render(effect, frames, {start: set_time(b)})[:, 0] + d = np.arange(frames) - k * (y + 30000.0) + settled = float(np.median(d[-rate // 5:])) + off = np.nonzero(np.abs(d[start:] - settled) > 2 * k + 2)[0] + return start + (int(off[-1]) + 1 if len(off) else 0) + + +def no_step(y, start, end, a, b, law_hz, rate, amp, character, damping): + """T1b's no-step clause, T1a's three windows: inside the walk, the + shifted tone's own slope at T_new through the corner in force; in the + first five time constants after either end, DigitalDelay's signed law + max(1, ratio) x the unfiltered unramped maximum; before and after, the + 997 Hz tone's own slope at each Time. Each bar plus 5 %. Returns each + window's largest first difference over its bar, in that order. The + before window's bar is the same tone's own render, so it reads + 1 / 1.05 exactly on a clean class; the four after it are the clause.""" + settle = int(math.ceil(5 * rate / (2 * math.pi * damping))) + raw = 2.0 * math.pi * TONE / rate * amp + signed = max(1.0, law_hz / TONE) * raw + windows = ( + (start - 4000, start, steady_slope(TONE, a, character, rate, amp)), + (start, start + settle, signed), + (start + settle, end - WALK_SLACK, + steady_slope(law_hz, b, character, rate, amp)), + (end - WALK_SLACK, end + settle + WALK_SLACK, signed), + (end + settle + WALK_SLACK, end + settle + WALK_SLACK + rate // 5, + steady_slope(TONE, b, character, rate, amp))) + out = [] + for lo, hi, bar in windows: + got = float(np.max(np.abs(np.diff(y[lo:hi])))) if hi - lo > 2 \ + else 0.0 + out.append(got / (1.05 * bar)) + return out + + +def glide_cell(cls, grid, a, b, rate=RATE, amp=12000.0): + """One of T1b's Glide-grid cells, sliding-head: each binade piece's + pitch against 1 - dT/dt written from the Glide asked (never the class's + slew function), the residual 50-250 ms after, and the no-step clause.""" + law = 1180.0 / _component.macro_value(TapeDelay._MACRO_RANGES[GLIDE_I], + grid / 127.0) + start = start_of(a, rate) + frames = start + 3 * rate + effect = cls(src_of(sine(TONE, amp, frames, rate), 1, rate), time_ms=a, + character=tape.SLIDING_HEAD, **HELD) + effect.set_macro(GLIDE_I, grid) + y = render(effect, frames, {start: set_time(b)})[:, 0] + fa, fb = frames_of(a, rate), frames_of(b, rate) + walk = int(abs(fb - fa) / law) + want = TONE * (1.0 + law if b < a else 1.0 - law) + pieces = [] + edges = [start] + power = 1 << int(math.log2(max(fa, fb))) + if min(fa, fb) < power < max(fa, fb): + edges.append(start + int(abs(power - fa) / law)) + edges.append(start + walk) + for lo, hi in zip(edges[:-1], edges[1:]): + trim = min(400, (hi - lo) // 10) + got = lsq_hz(y[lo + trim:hi - trim], rate) + pieces.append(cents(got / want) if got == got else float("inf")) + after = lsq_hz(y[start + walk + int(0.05 * rate): + start + walk + int(0.25 * rate)], rate) + residual = cents(after / TONE) if after == after else float("inf") + end = ramp_walk_end(TapeDelay, a, b, rate, tape.SLIDING_HEAD, start, + glide_ms=_component.macro_value( + TapeDelay._MACRO_RANGES[GLIDE_I], grid / 127.0)) + windows = no_step(y, start, end, a, b, want, rate, amp, + tape.SLIDING_HEAD, effect._damping) + step = max(windows) + red = (max(abs(p) for p in pieces) > 10.0 or abs(residual) > 1.0 + or step > 1.0) + return dict(passed=not red, pieces=pieces, residual=residual, step=step, + windows=windows, walk=walk, end=end - start) + + +def ramp_cell(cls, rate=RATE, amp=12000.0): + """T1b's ramp: sliding-head, Glide 2 950 ms (slew 0.4), 200 -> 400 ms. + The walk is 24 000 frames; the pitch -884.4 cents while it walks. The + pitch, the residual and the no-step clause.""" + y, start, effect = move(cls, 200.0, 400.0, rate, tape.SLIDING_HEAD, amp, + glide_ms=2950.0) + law_slew = 1180.0 / 2950.0 + walk = int(round((frames_of(400.0, rate) - frames_of(200.0, rate)) + / law_slew)) + trim = min(400, walk // 10) + law = TONE * (1.0 - law_slew) + got = lsq_hz(y[start + trim:start + walk - trim], rate) + err = cents(got / law) if got == got else float("inf") + after = lsq_hz(y[start + walk + int(0.05 * rate): + start + walk + int(0.25 * rate)], rate) + residual = cents(after / TONE) if after == after else float("inf") + end = ramp_walk_end(TapeDelay, 200.0, 400.0, rate, tape.SLIDING_HEAD, + start, glide_ms=2950.0) + windows = no_step(y, start, end, 200.0, 400.0, law, rate, amp, + tape.SLIDING_HEAD, effect._damping) + step = max(windows) + return dict(passed=abs(err) <= 10.0 and abs(residual) <= 1.0 + and step <= 1.0, err=err, residual=residual, step=step, + windows=windows, + law=cents(law / TONE), end=end - start, walk=walk) + + +#: Fix round 1: a binade piece counts only when its two readings - the +#: read head's slope off the ramp render and the least-squares sine on the +#: tone - both exist and agree within PIECE_AGREE cents. The span refuter +#: read -215.6 c off the ramp slope alone on a 45-frame piece (grid 1, +#: 41.7 -> 55.7 ms), where no sine fits; such a piece is reported unread. +PIECE_AGREE = 1.0 + +#: The sine is fitted on at most this many frames from the middle of a +#: piece: the pitch is constant inside a binade, and a long piece costs +#: time and says nothing more. +PIECE_FIT_MAX = 24000 + + +def binade_pieces(y, d, ok, start, end, fa, fb, law_hz, rate): + """The walk start..end cut where the read head crosses a power of two + (inside a binade the node rounds each step to the head's float32 ulp, + so each binade plays its own rate), each piece read two ways in cents + off `law_hz`. Returns [(frames, ramp cents, sine cents, read)], `read` + True when both readings exist and agree within PIECE_AGREE.""" + edges = [start] + lo_f, hi_f = min(fa, fb), max(fa, fb) + p = 1 + while p <= hi_f: + if lo_f < p < hi_f: + seg = d[start:end] + idx = np.nonzero(((seg - p) * np.sign(fb - fa) >= 0) + & ok[start:end])[0] + if len(idx): + edges.append(start + int(idx[0])) + p <<= 1 + edges.append(end) + edges = sorted(set(edges)) + out = [] + for lo, hi in zip(edges[:-1], edges[1:]): + n = hi - lo + ramp_c = float("nan") + if n > 8: + idx = np.arange(lo + 2, hi - 2) + idx = idx[ok[idx]] + if len(idx) > 4: + slope = np.polyfit(idx.astype(float), + d[idx].astype(float), 1)[0] + if 1.0 - slope > 0.0: + ramp_c = cents((1.0 - slope) * TONE / law_hz) + trim = min(400, n // 10) + a, b = lo + trim, hi - trim + if b - a > PIECE_FIT_MAX: + mid = (a + b) // 2 + a, b = mid - PIECE_FIT_MAX // 2, mid + PIECE_FIT_MAX // 2 + sine_c = float("nan") + if b - a >= max(64, rate / law_hz): + got = lsq_hz(y[a:b], rate) + if got == got: + sine_c = cents(got / law_hz) + read = (ramp_c == ramp_c and sine_c == sine_c + and abs(ramp_c - sine_c) <= PIECE_AGREE) + out.append((n, ramp_c, sine_c, read)) + return out + + +def walk_cell(cls, a, b, rate, character, law_hz, amp=12000.0, setup=None, + after_ms=300.0, **options): + """One Time move a -> b read per binade piece with the guard above: + the tone render and the ramp render of the same move, the walk's end + off the read position. `law_hz` is the pitch the law says the walk + plays. Returns dict(pieces, worst (over read pieces), unread (pieces + not read), read_frames, walk, passed (no read piece over 10 c)).""" + fa, fb = frames_of(a, rate), frames_of(b, rate) + start = start_of(a, rate) + rate_of_walk = abs(1.0 - law_hz / TONE) + walk_est = int(abs(fb - fa) / rate_of_walk) if rate_of_walk > 0 else 0 + frames = start + int(1.1 * walk_est) + int(after_ms * rate / 1000.0) + opts = dict(HELD) + opts.update(options) + effect = cls(src_of(sine(TONE, amp, frames, rate), 1, rate), time_ms=a, + character=character, **opts) + if setup is not None: + setup(effect) + y = render(effect, frames, {start: set_time(b)})[:, 0] + effect.deinit() + d, ok = read_position(cls, a, b, rate, character, start, frames, setup, + **options) + walking = np.nonzero((d[start:] != fb) & ok[start:])[0] + end = start + (int(walking[-1]) + 1 if len(walking) else 0) + pieces = binade_pieces(y, d, ok, start, end, fa, fb, law_hz, rate) + read = [p for p in pieces if p[3]] + worst = max((max(p[1], p[2], key=abs) for p in read), key=abs, + default=float("nan")) + return dict(pieces=pieces, worst=worst, + unread=sum(1 for p in pieces if not p[3]), + read_frames=sum(p[0] for p in read), walk=end - start, + passed=bool(read) and abs(worst) <= 10.0) + + +def glide_law_hz(glide_ms, a, b): + """Sliding-head's pitch while the head walks a -> b at Glide + `glide_ms`, from the Glide asked (1 180 ms / Glide), never from the + class's slew function.""" + law = FULL_RANGE_LAW / glide_ms + return TONE * (1.0 + law if b < a else 1.0 - law) + + +#: 1 180 ms, sliding-head's full-range Time move, written from the numbers. +FULL_RANGE_LAW = 1200.0 - 20.0 + + +def glide_ms_of(grid): + return _component.macro_value(TapeDelay._MACRO_RANGES[GLIDE_I], + grid / 127.0) + + +_READ_HEADS = {} + + +def read_head(cls): + """`cls` with its loop low-pass handed 0 after every refresh: an + instrument for reading where the read head is, never a subject. The + walk is the node's `delay_slew` (`audiodsp_feedback_delay.c:492`, + `:497` at v0.6.3rc1) and never sees the filter, which filters what was read, so the + positions are the class's (Station C's `read_head_class`).""" + if cls not in _READ_HEADS: + def _refresh(self): + cls._refresh(self) + self._delay.set(damping_hz=0.0) + _READ_HEADS[cls] = type("ReadHead" + cls.__name__, (cls,), + {"_refresh": _refresh}) + return _READ_HEADS[cls] + + +def read_position(cls, a, b, rate, character, start, frames, setup=None, + **options): + """The read position D[n], in frames, off a render of the move a -> b + issued at `start`, with a wrapping 1-LSB-per-frame ramp as the source + (the wet sample is n - D[n], so D is exact on a whole frame and the + interpolated position rounded between them), on `read_head(cls)`. + `ok` is False next to the ramp's wrap and before the line has filled. + `setup(effect)` runs after construction (a Glide grid position). + Returns (D, ok).""" + opts = dict(HELD) + opts.update(options) + n = np.arange(frames) + x = (n % 65536) - 32768.0 + effect = read_head(cls)(src_of(x, 1, rate), time_ms=a, + character=character, **opts) + if setup is not None: + setup(effect) + y = render(effect, frames, {start: set_time(b)})[:, 0] + d = (n - (y.astype(np.int64) + 32768)) % 65536 + wrap = np.abs(np.diff(y)) > 30000 + ok = np.ones(frames, bool) + ok[1:] &= ~wrap + ok[:-1] &= ~wrap + ok[:start // 2] = False + return d, ok + + +def step_cell(cls, rate=RATE, amp=12000.0, start=None): + """T1b's step (fix round 1): sliding-head at Glide 0, 200 -> 100.4 ms. + The read position off a ramp render sits on T_old's whole frame before + the move and on T_new's after it, and no frame reads a position between + the two: the node jumps, where any walk, however fast, reads the + positions between. The pitch 2-50 ms after the move is within 10 cents + of the tone. `start` is the frame the move is issued on (a block + boundary; the default is the other T1 cells' frame). + + The round-0 clause (a jump on the read recovered through the inverted + loss low-pass of at least 5x the unfiltered tone's slope) is struck: it + reads the tone's two phases across the jump, so it moves with the frame + the move lands on (x1.00-x13.03 over 16 block boundaries at 48 kHz).""" + fa, fb = frames_of(200.0, rate), frames_of(100.4, rate) + if start is None: + start = start_of(200.0, rate) + frames = start + rate // 2 + opts = dict(HELD) + effect = cls(src_of(sine(TONE, amp, frames, rate), 1, rate), + time_ms=200.0, character=tape.SLIDING_HEAD, glide_ms=0.0, + **opts) + y = render(effect, frames, {start: set_time(100.4)})[:, 0] + got = lsq_hz(y[start + int(0.002 * rate):start + int(0.05 * rate)], rate) + offset = cents(got / TONE) if got == got else float("inf") + # The struck round-0 figure, reported and never graded, so the earlier + # probes that print it still run. + coef = 1.0 - math.exp(-2.0 * math.pi * effect._damping / rate) + recovered = y[:-1] + np.diff(y) / coef + ratio = float(np.max(np.abs(np.diff(recovered))[start - 3:start + 3])) \ + / (2.0 * math.pi * TONE / rate * amp) + d, ok = read_position(cls, 200.0, 100.4, rate, tape.SLIDING_HEAD, start, + frames, glide_ms=0.0) + lo = start - 2000 + window = np.arange(lo, frames) + window = window[ok[lo:frames]] + pos = d[window] + between = int(np.sum((pos != fa) & (pos != fb))) + before = bool(np.all(d[lo:start][ok[lo:start]] == fa)) + tail = start + rate // 10 + after = bool(np.all(d[tail:frames][ok[tail:frames]] == fb)) + landed = window[pos == fb] + first = int(landed[0]) - start if len(landed) else None + return dict(passed=before and after and between == 0 + and abs(offset) <= 10.0, between=between, before=before, + after=after, offset=offset, landed=first, fa=fa, fb=fb, + ratio=ratio) + + +G_START = 9472 +T0, T1 = 200.0, 300.0 + + +def burst(frames, peak, rate=RATE, at=0.100): + x = np.zeros(frames) + n = int(0.040 * rate) + t = np.arange(n) + s0 = int(at * rate) + x[s0:s0 + n] = peak * np.sin(np.pi * t / n) ** 2 * np.sin( + 2 * np.pi * 1000.0 * t / rate) + return x + + +def find_repeats(y, count=4, rate=RATE): + env = np.abs(analytic(y)) + k = int(0.005 * rate) + env = np.convolve(env, np.ones(k) / k, mode="same") + i = int(0.19 * rate) + thr = 0.01 * env[i:].max() + peaks = [] + while i < len(env) and len(peaks) < count: + if env[i] > thr: + m = i + int(np.argmax(env[i:i + int(0.03 * rate)])) + lo = m + while lo > 0 and env[lo] > env[m] * 0.5: + lo -= 1 + hi = m + while hi < len(env) - 1 and env[hi] > env[m] * 0.5: + hi += 1 + peaks.append(((lo + hi) // 2) - int(0.010 * rate)) + i = hi + int(0.1 * rate) + else: + i += 1 + return peaks + + +def gesture(cls, character, peak=12000.0, moves=True): + """The 200 -> 300 -> 200 ms gesture at Feedback 0.7 with a 40 ms burst + at 100-140 ms, stereo, 48 kHz. Varispeed: two moves from rest, the + return 400 ms after the first. Sliding-head: two walks at slew 0.5 + (Glide 2 360 ms), 200 ms apart. Returns each repeat's pitch in cents + against 1 kHz.""" + frames = int(2.2 * RATE) + opts = dict(HELD) + opts.update(feedback=0.7) + effect = cls(src_of(burst(frames, peak), 2), time_ms=T0, + character=character, glide_ms=2360.0, **opts) + events = {} + if moves: + if character == tape.VARISPEED: + second = (G_START + int(0.4 * RATE)) // BLOCK * BLOCK + else: + second = G_START + int(0.2 * RATE) // BLOCK * BLOCK + events = {G_START: set_time(T1), second: set_time(T0)} + y = render(effect, frames, events)[:, 0] + out = [] + for a in find_repeats(y): + seg = y[a:a + int(0.02 * RATE)] + out.append(cents(peak_hz(seg, RATE, pad=8) / 1000.0)) + return out + + +def gesture_verdict(cls): + """T1b's feedback clauses: every sliding-head repeat 1-4 more than 100 + cents off the source; every varispeed repeat 2-4 within 5 cents of the + same repeat with no gesture; and the two characters not the same.""" + control = gesture(TapeDelay, tape.VARISPEED, moves=False) + vari = gesture(cls, tape.VARISPEED) + slide = gesture(cls, tape.SLIDING_HEAD) + ok_slide = len(slide) == 4 and all(abs(c) > 100.0 for c in slide) + ok_vari = (len(vari) == 4 and len(control) == 4 + and all(abs(v - c) <= 5.0 + for v, c in zip(vari[1:], control[1:]))) + return dict(passed=ok_slide and ok_vari, vari=vari, slide=slide, + control=control) + + +class T1bSlidingHead(unittest.TestCase): + def test_the_ramp(self): + got = ramp_cell(TapeDelay) + self.assertTrue(got["passed"], got) + self.assertAlmostEqual(got["law"], -884.4, delta=0.05) + + def test_the_ramp_at_other_levels_and_rates(self): + for rate, amp in ((48000, 120.0), (48000, 32000.0), (22050, 12000.0)): + got = ramp_cell(TapeDelay, rate, amp) + self.assertTrue(got["passed"], (rate, amp, got)) + + def test_glide_grid_cells_both_ways(self): + # Each side of the power of two the walk crosses (8 192 frames at + # 48 kHz, 4 096 at 22.05) is read on its own, and the no-step + # clause holds over T1a's three windows. + for rate, grid in ((48000, 4), (48000, 89), (48000, 127), + (22050, 89)): + for a, b in ((200.0, 150.0), (150.0, 200.0)): + got = glide_cell(TapeDelay, grid, a, b, rate) + self.assertTrue(got["passed"], (rate, grid, a, b, got)) + + def test_a_staircase_is_red_on_the_no_step_clause(self): + # Station A's cell (App. F8): grid 89, 200 -> 150 ms, 48 kHz. + got = glide_cell(StaircaseTape, 89, 200.0, 150.0) + self.assertFalse(got["passed"], got) + self.assertGreater(got["step"], 1.0, got) + clean = glide_cell(TapeDelay, 89, 200.0, 150.0) + self.assertLess(clean["step"], 1.0, clean) + + def test_a_staircase_is_red_on_the_ramp(self): + got = ramp_cell(StaircaseTape) + self.assertFalse(got["passed"], got) + self.assertGreater(got["step"], 1.0, got) + + def test_the_glide_binades(self): + # Fix round 1 (dossier T1b, revised under vision 7.2): a rising move + # is claimed from grid 4 while the head stays under 16 384 frames, + # from grid 10 past 16 384 and from grid 22 past 32 768 (48 kHz: + # 341.3 and 682.7 ms). The claimed edge grids read green inside + # their binades; grid 1 at the default Time's 350 -> 450 ms, no + # longer claimed, reads +40.98 c; falling moves are claimed at + # every grid, grid 1 included. + cells = ((4, 200.0, 330.0, False), (10, 400.0, 650.0, False), + (22, 750.0, 1150.0, False), (1, 350.0, 450.0, True), + (1, 650.0, 400.0, False)) + for grid, a, b, red in cells: + got = walk_cell(TapeDelay, a, b, RATE, tape.SLIDING_HEAD, + glide_law_hz(glide_ms_of(grid), a, b), + setup=lambda e, g=grid: e.set_macro(GLIDE_I, g)) + self.assertEqual(got["unread"], 0, (grid, a, b, got)) + self.assertEqual(got["passed"], not red, (grid, a, b, got)) + if red: + self.assertAlmostEqual(got["worst"], 40.98, delta=0.05) + + def test_a_short_piece_is_not_read(self): + # Fix round 1: a piece whose two readings do not both exist and + # agree within 1 c is reported unread, not green or red. The span + # refuter's cell, grid 1, 41.75 -> 55.66 ms (Time MIDI 31.75 and + # 0.75 of it), cuts a 45-frame piece and a 646-frame one; the + # shifted tone (34 Hz) has no period in either, so neither is read + # (Station C's ramp-slope-only reader put -215.6 c on the first). + grid = 1 + b = _component.macro_value(TapeDelay._MACRO_RANGES[TIME_I], 0.25) + a = 0.75 * b + got = walk_cell(TapeDelay, a, b, RATE, tape.SLIDING_HEAD, + glide_law_hz(glide_ms_of(grid), a, b), + setup=lambda e: e.set_macro(GLIDE_I, grid)) + self.assertEqual(got["unread"], len(got["pieces"]), got) + self.assertFalse(got["passed"], got) + self.assertLess(min(p[0] for p in got["pieces"]), 64, got) + + def test_the_step_at_glide_0(self): + # Fix round 1: the read position jumps between two frames, at three + # rates and on four block boundaries each; no pitch offset after. + for rate in (48000, 44100, 22050): + base = start_of(200.0, rate) + for k in (0, 2, 5, 13): + got = step_cell(TapeDelay, rate, start=base + k * BLOCK) + self.assertTrue(got["passed"], (rate, k, got)) + self.assertEqual(got["landed"], 0, (rate, k, got)) + + def test_a_fast_walk_at_glide_0_is_red(self): + for rate in (48000, 22050): + got = step_cell(FastWalkAtZeroTape, rate) + self.assertFalse(got["passed"], got) + self.assertGreater(got["between"], 200, got) + + def test_the_gesture(self): + got = gesture_verdict(TapeDelay) + self.assertTrue(got["passed"], got) + self.assertLess(abs(got["vari"][0] + 701.4), 2.0, got) + + def test_a_loop_shift_is_red_on_the_gesture(self): + got = gesture_verdict(LoopShiftTape) + self.assertFalse(got["passed"], got) + + def test_a_per_block_tape_equation_is_red_on_the_sliding_head(self): + # The sliding-head's moves driven along the tape equation once per + # block telescope: its repeats come back to the source, so the + # sliding-head clause (every repeat 1-4 over 100 cents off) fails. + slide = gesture(PerBlockSlidingTape, tape.SLIDING_HEAD) + self.assertEqual(len(slide), 4, slide) + self.assertTrue(any(abs(c) <= 100.0 for c in slide), slide) + # Measured at 48 kHz: -701.5, -3.7, -5.2, -7.4 cents. + self.assertLess(max(abs(c) for c in slide[1:]), 10.0, slide) + self.assertFalse(gesture_verdict(PerBlockSlidingTape)["passed"]) + + +# -- T2, T3: the loss law ------------------------------------------------- + +def eq13_db(f, v, spacing_m): + k = 2.0 * np.pi * np.asarray(f, dtype=float) / v + spacing = np.exp(-k * spacing_m) + thick = (1.0 - np.exp(-k * tape.THICK_M)) / (k * tape.THICK_M) + half = k * tape.GAP_M / 2.0 + gap = np.abs(np.sin(half) / half) + return 20.0 * np.log10(spacing * thick * gap) + + +def lambda_top_um(spacing_um, tol=1.75): + """The shortest wavelength down to which a one-pole with eq. (13)'s + -3 dB point stays within `tol` dB of eq. (13) (dossier App. F3).""" + d = spacing_um * 1e-6 + kc = tape.k3_of(d) + ks = np.geomspace(kc / 30.0, kc * 100.0, 20000) + onepole = -10.0 * np.log10(1.0 + (ks / kc) ** 2) + err = np.abs(onepole - eq13_db(ks / (2 * math.pi), 1.0, d)) + bad = np.where(err > tol)[0] + kmax = ks[bad[0] - 1] if len(bad) else ks[-1] + return 2 * math.pi / kmax * 1e6 + + +def one_pass(cls, time_ms, spacing_um, character=tape.VARISPEED, rate=RATE, + amp=32767.0): + """The one-pass response of an impulse through the class: 4 096 frames + from the whole-frame Time, scaled to the impulse.""" + t = frames_of(time_ms, rate) + frames = t + 4096 + 512 + x = np.zeros(frames) + x[0] = amp + opts = dict(HELD) + effect = cls(src_of(x, 2, rate), time_ms=time_ms, spacing_um=spacing_um, + character=character, **opts) + y = render(effect, frames)[:, 0] + return y[t:t + 4096] / amp + + +def response_db(seg, rate, freqs): + n = np.arange(len(seg)) + freqs = np.asarray(freqs, dtype=float) + basis = np.exp(-2j * np.pi * np.outer(freqs, n) / rate) + mag = np.abs(basis @ seg) + return 20.0 * np.log10(np.maximum(mag, 1e-30)) + + +def t2_one_pass(cls, time_ms, spacing_um, character, rate=RATE, + amp=32767.0): + v = tape.speed(character, time_ms) + top = min(v / (lambda_top_um(spacing_um) * 1e-6), rate / 8.0) + f = np.geomspace(100.0, top, 200) + got = response_db(one_pass(cls, time_ms, spacing_um, character, rate, + amp), rate, f) + err = got - eq13_db(f, v, spacing_um * 1e-6) + worst = float(np.max(np.abs(err))) + return dict(passed=worst <= 2.0, worst=worst, top=top, + at=float(f[int(np.argmax(np.abs(err)))])) + + +def tone_db_off_eq13(time_ms, spacing_um, character, hz, rate=RATE, + amp=30000.0): + """One pass on a steady tone at `hz`: the least-squares amplitude of + the wet tone once the loop low-pass has settled, over the input's, in + dB, less eq. (13) there (the material refuter's `tone_level_db`).""" + t = frames_of(time_ms, rate) + frames = t + int(0.3 * rate) + effect = TapeDelay(src_of(sine(hz, amp, frames, rate), 2, rate), + time_ms=time_ms, spacing_um=spacing_um, + character=character, **HELD) + y = render(effect, frames)[:, 0] + seg = y[t + 2000:t + 2000 + int(0.2 * rate)] + n = np.arange(len(seg)) + a = np.stack([np.cos(2 * np.pi * hz * n / rate), + np.sin(2 * np.pi * hz * n / rate), np.ones(len(seg))], 1) + coef, *_ = np.linalg.lstsq(a, seg, rcond=None) + level = 20 * math.log10(math.hypot(coef[0], coef[1]) / amp) + v = tape.speed(character, time_ms) + return level - float(eq13_db([hz], v, spacing_um * 1e-6)[0]) + + +def t2_npass(cls, time_ms=350.0, spacing_um=5.0, character=tape.VARISPEED, + rate=RATE): + """Bursts at the band top through the loop at Feedback 0.7: the loss + of each repeat above 4 LSB against n x the first's.""" + v = tape.speed(character, time_ms) + top = min(v / (lambda_top_um(spacing_um) * 1e-6), rate / 8.0) + t = frames_of(time_ms, rate) + n_b = int(0.060 * rate) + frames = t * 6 + int(0.2 * rate) + x = np.zeros(frames) + tt = np.arange(n_b) + x[:n_b] = 30000 * np.sin(np.pi * tt / n_b) ** 2 * np.sin( + 2 * np.pi * top * tt / rate) + opts = dict(HELD) + opts.update(feedback=0.7) + effect = cls(src_of(x, 2, rate), time_ms=time_ms, spacing_um=spacing_um, + character=character, **opts) + y = render(effect, frames)[:, 0] + basis = np.stack([np.cos(2 * np.pi * top * tt / rate), + np.sin(2 * np.pi * top * tt / rate)], 1) + + def amp_of(seg): + coef, *_ = np.linalg.lstsq(basis, seg, rcond=None) + return math.hypot(*coef) + ref = amp_of(x[:n_b]) + g = 20 * math.log10(effect._feedback) + losses = [] + for k in range(1, 5): + seg = y[k * t:k * t + n_b] + a = amp_of(seg) + if a < 4.0 or float(np.max(np.abs(seg))) < 4.0: + break + losses.append(20 * math.log10(a / ref) - (k - 1) * g) + worst = max(abs(loss - (k + 1) * losses[0]) + for k, loss in enumerate(losses)) if losses else float("inf") + return dict(passed=len(losses) >= 2 and worst <= 2.0, worst=worst, + per_pass=[loss / (k + 1) for k, loss in enumerate(losses)], + top=top) + + +class T2LossLaw(unittest.TestCase): + def test_the_band_tops(self): + self.assertAlmostEqual(lambda_top_um(5.0), 41.4, delta=0.1) + self.assertAlmostEqual(lambda_top_um(2.0), 122.2, delta=0.1) + self.assertAlmostEqual(lambda_top_um(20.0), 105.3, delta=0.1) + + def test_one_pass_at_the_named_cells(self): + for rate, character, time_ms, spacing in ( + (48000, tape.VARISPEED, 350.0, 5.0), + (48000, tape.VARISPEED, 600.0, 20.0), + (48000, tape.VARISPEED, 60.0, 2.0), + (48000, tape.SLIDING_HEAD, 350.0, 5.0), + (48000, tape.VARISPEED, 350.0, 3.772), + (44100, tape.VARISPEED, 320.0, 5.0), + (22050, tape.VARISPEED, 600.0, 5.0), + (22050, tape.SLIDING_HEAD, 350.0, 20.0)): + got = t2_one_pass(TapeDelay, time_ms, spacing, character, rate) + self.assertTrue(got["passed"], + (rate, character, time_ms, spacing, got)) + + def test_below_0_dbfs_the_impulse_reads_high_not_the_class(self): + # Fix round 1 (dossier T2, revised under vision 7.2): the one-pass + # clause is stated at 0 dBFS. At -20 dBFS the impulse's int16- + # rounded tail carries this cell's reading over the bar (2.052 dB, + # sliding-head 350 ms, Spacing MIDI 39, 48 kHz), while a steady tone + # at the frequency it reads worst sits inside at both levels, so the + # -20 dBFS impulse is no longer a claim of the row. + spacing = _component.macro_value( + TapeDelay._MACRO_RANGES[SPACING_I], 39 / 127.0) + low = t2_one_pass(TapeDelay, 350.0, spacing, tape.SLIDING_HEAD, + amp=3277.0) + full = t2_one_pass(TapeDelay, 350.0, spacing, tape.SLIDING_HEAD) + self.assertGreater(low["worst"], 2.0, low) + self.assertTrue(full["passed"], full) + for amp in (3277.0, 30000.0): + off = tone_db_off_eq13(350.0, spacing, tape.SLIDING_HEAD, + low["at"], amp=amp) + self.assertLess(abs(off), 2.0, (amp, off)) + + def test_a_doubled_corner_is_red(self): + for rate in (48000, 22050): + got = t2_one_pass(DoubleCornerTape, 350.0, 5.0, tape.VARISPEED, + rate) + self.assertFalse(got["passed"], (rate, got)) + self.assertGreater(got["worst"], 4.0) + + def test_n_passes(self): + for character, time_ms in ((tape.VARISPEED, 350.0), + (tape.VARISPEED, 600.0), + (tape.SLIDING_HEAD, 350.0)): + got = t2_npass(TapeDelay, time_ms, character=character) + self.assertTrue(got["passed"], (character, time_ms, got)) + + def test_the_loss_outside_the_loop_is_red(self): + got = t2_npass(PostLossTape) + self.assertFalse(got["passed"], got) + self.assertGreater(got["worst"], 10.0) + + +def corner_and_10k(seg, rate): + """The first crossing 3.01 dB under the one-pass response's own DC + level, interpolated in log frequency on a 20 000-point grid, and its + level at 10 kHz (against unity). + + Fix round 1: the crossing was read against unity. As the impulse falls + its int16-rounded tail loses DC, which moved the corner ratio with level + (+0.734 % at -20 dBFS against unity, +0.125 % against the response's + DC; Spacing MIDI 51, 48 kHz). A response with no DC has no corner.""" + dc = float(np.sum(seg)) + level_10k = float(response_db(seg, rate, [10000.0])[0]) + if not dc > 0.0: + return float("nan"), level_10k + ref = 20.0 * math.log10(dc) - 3.0103 + f = np.geomspace(20.0, min(20000.0, 0.49 * rate), 20000) + db = response_db(seg, rate, f) + below = np.nonzero(db <= ref)[0] + if not len(below) or below[0] == 0: + return float("nan"), level_10k + i = below[0] + a, b = db[i - 1], db[i] + frac = (a - ref) / (a - b) + corner = math.exp(math.log(f[i - 1]) + frac + * (math.log(f[i]) - math.log(f[i - 1]))) + return corner, level_10k + + +def t3_cell(cls, spacing_um=5.0, rate=RATE, amp=32767.0): + out = {} + for character in tape.CHARACTERS: + c1, l1 = corner_and_10k(one_pass(cls, 180.0, spacing_um, character, + rate, amp), rate) + c2, l2 = corner_and_10k(one_pass(cls, 600.0, spacing_um, character, + rate, amp), rate) + out[character] = (c1 / c2, l1 - l2) + ratio_v, span_v = out[tape.VARISPEED] + ratio_s, span_s = out[tape.SLIDING_HEAD] + ok_v = abs(ratio_v / (10.0 / 3.0) - 1.0) <= 0.01 and span_v >= 9.0 + ok_s = abs(ratio_s - 1.0) <= 0.01 and abs(span_s) <= 1.0 + return dict(passed=ok_v and ok_s, varispeed=out[tape.VARISPEED], + sliding=out[tape.SLIDING_HEAD]) + + +class T3LossFollowsSpeed(unittest.TestCase): + def test_the_span_at_the_named_cells(self): + for rate, midi in ((48000, 51), (48000, 0), (48000, 127), + (44100, 64), (22050, 51)): + spacing = _component.macro_value( + TapeDelay._MACRO_RANGES[SPACING_I], midi / 127.0) + got = t3_cell(TapeDelay, spacing, rate) + self.assertTrue(got["passed"], (rate, midi, got)) + + def test_at_minus_20_dbfs(self): + got = t3_cell(TapeDelay, 5.0, RATE, 3277.0) + self.assertTrue(got["passed"], got) + + def test_a_square_law_and_a_half_follow_are_red(self): + for rate in (48000, 22050): + got = t3_cell(SquareLawTape, 5.0, rate) + self.assertFalse(got["passed"], got) + self.assertGreater(got["varispeed"][0], 10.0) + got = t3_cell(HalfFollowTape, 5.0, rate) + self.assertFalse(got["passed"], got) + self.assertGreater(got["sliding"][0], 1.5) + + +# -- T4: the fluctuation -------------------------------------------------- + +def bh4(n): + a = (0.35875, 0.48829, 0.14128, 0.01168) + k = np.arange(n) / (n - 1) + return (a[0] - a[1] * np.cos(2 * np.pi * k) + a[2] * np.cos(4 * np.pi * k) + - a[3] * np.cos(6 * np.pi * k)) + + +def delay_trace(cls, rate=RATE, wow=2.0, flutter=1.0, amp=12000.0, + seconds=60.0, time_ms=350.0, tone=TONE): + """The delay recovered from a steady tone's analytic phase (997 Hz, the + row's) over `seconds`, averaged to 200 Hz, mean removed; one + channel.""" + frames = int(seconds * rate) + int(time_ms / 1000 * rate) + rate + opts = dict(HELD) + opts.update(wow_cents=wow, flutter_cents=flutter) + effect = cls(src_of(sine(tone, amp, frames, rate), 1, rate), + time_ms=time_ms, **opts) + y = render(effect, frames)[:, 0] + start = int(time_ms / 1000 * rate) + rate // 2 + y = y[start:start + int(seconds * rate)] + ph = np.unwrap(np.angle(analytic(y))) + t = (np.arange(len(y)) + start) / float(rate) + trace = t - ph / (2 * np.pi * tone) + step = int(round(rate / 200.0)) + m = len(trace) // step + trace = trace[:m * step].reshape(m, step).mean(axis=1) * 1000.0 + return trace - trace.mean(), rate / float(step) + + +def _fit_line(trace, t, f0): + """The least-squares sinusoid near `f0`: (frequency, the fit, its + amplitude in the trace's units).""" + best = None + for f in np.linspace(f0 - 0.005, f0 + 0.005, 101): + a = np.stack([np.sin(2 * np.pi * f * t), np.cos(2 * np.pi * f * t)], + axis=1) + coef, *_ = np.linalg.lstsq(a, trace, rcond=None) + fit = a @ coef + err = float(np.sum((trace - fit) ** 2)) + if best is None or err < best[0]: + best = (err, f, fit, math.hypot(*coef)) + return best[1], best[2], best[3] + + +#: Fix round 1: every T4 clause also needs what it reads to be there in +#: absolute terms, each line's fitted amplitude and the slow band's at least +#: this many ms of delay. The weakest claimed line, Flutter grid 1 +#: (0.0315 c), is 5.66e-4 ms peak by the wow law; the class built as a wire +#: has a whole delay trace of 3e-7 ms rms. Read in dB over its own floor +#: alone, the detector could not tell the two apart. +T4_FLOOR_MS = 1e-5 + + +def t4_verdict(trace, rate): + """T4's clauses: two lines in 0.2-12 Hz each >= 40 dB over the median + floor between them, their ratio not within 1 % of p/q (p, q <= 8), and + the 0.017-0.1 Hz band of the residual after both lines are fitted out + >= 40 dB over that residual's floor. Fix round 1: each clause also + needs its lines, or its band, at least T4_FLOOR_MS in amplitude; the + ratio clause reads two lines, so it needs both there.""" + n = len(trace) + w = bh4(n) + f = np.fft.rfftfreq(n, 1.0 / rate) + df = f[1] + sdb = 10 * np.log10(np.abs(np.fft.rfft(trace * w)) ** 2 + 1e-300) + lo = np.searchsorted(f, 0.2) + hi = np.searchsorted(f, 12.0) + lines = [] + for i in np.argsort(sdb[lo:hi])[::-1] + lo: + if all(abs(f[i] - f[j]) > 0.3 for j in lines): + lines.append(i) + if len(lines) == 2: + break + lines.sort() + a, b = lines + between = sdb[a + 8:b - 8] + if len(between) < 8: + return dict(passed=False, why="lines adjacent", two_lines=False, + ratio_ok=False, drift_ok=False) + floor = float(np.median(between)) + + def parabolic(i): + p, q, r = sdb[i - 1], sdb[i], sdb[i + 1] + return (i + 0.5 * (p - r) / (p - 2 * q + r)) * df + t = np.arange(n) / rate + f1, fit1, a1 = _fit_line(trace, t, parabolic(a)) + f2, fit2, a2 = _fit_line(trace - fit1, t, parabolic(b)) + r = trace - fit1 - fit2 + r = r - np.sum(r * w) / np.sum(w) + spec = np.abs(np.fft.rfft(r * w)) + rdb = 10 * np.log10(spec ** 2 + 1e-300) + rfloor = float(np.median(rdb[a + 8:b - 8])) + ratio = f2 / f1 + near = min(abs(ratio - p / float(q)) / (p / float(q)) + for p in range(1, 9) for q in range(1, 9)) + top = np.searchsorted(f, 0.1) + drift = float(rdb[1:top].max()) - rfloor + # The band's largest bin as a sinusoid's amplitude (the window's + # coherent gain is its sum). + slow_ms = 2.0 * float(spec[1:top].max()) / float(np.sum(w)) + d1, d2 = sdb[a] - floor, sdb[b] - floor + there = a1 >= T4_FLOOR_MS and a2 >= T4_FLOOR_MS + two = d1 >= 40.0 and d2 >= 40.0 and there + ratio_ok = near > 0.01 and there + drift_ok = drift >= 40.0 and slow_ms >= T4_FLOOR_MS + return dict(passed=two and ratio_ok and drift_ok, + two_lines=two, ratio_ok=ratio_ok, drift_ok=drift_ok, + f1=f1, f2=f2, d1=d1, d2=d2, a1=a1, a2=a2, slow_ms=slow_ms, + ratio=ratio, drift=drift) + + +class T4Fluctuation(unittest.TestCase): + def test_the_default_at_48k(self): + got = t4_verdict(*delay_trace(TapeDelay)) + self.assertTrue(got["passed"], got) + self.assertAlmostEqual(got["f1"], 0.7194, delta=0.001) + self.assertAlmostEqual(got["f2"], 5.1155, delta=0.001) + + def test_the_stops_and_the_default_at_22k(self): + got = t4_verdict(*delay_trace(TapeDelay, wow=8.0, flutter=4.0, + seconds=60.0)) + self.assertTrue(got["passed"], got) + got = t4_verdict(*delay_trace(TapeDelay, rate=22050)) + self.assertTrue(got["passed"], got) + + def test_the_planted_tables_are_each_red_on_their_clause(self): + for rate in (48000, 22050): + got = t4_verdict(*delay_trace(FlutterOnWowLineTape, rate=rate)) + self.assertFalse(got["passed"], (rate, got)) + self.assertFalse(got.get("two_lines", False), (rate, got)) + got = t4_verdict(*delay_trace(Harmonic504Tape)) + self.assertFalse(got["passed"], got) + self.assertFalse(got["ratio_ok"], got) + self.assertTrue(got["drift_ok"], got) + got = t4_verdict(*delay_trace(NoDriftTape)) + self.assertFalse(got["passed"], got) + self.assertFalse(got["drift_ok"], got) + self.assertTrue(got["two_lines"], got) + + def test_the_weakest_claimed_lines_clear_the_absolute_floor(self): + # Fix round 1: Wow grid 1 x Flutter grid 1 at the row's lower level, + # where the flutter line is 5.66e-4 ms by the wow law. + got = t4_verdict(*delay_trace(TapeDelay, wow=8.0 / 127, + flutter=4.0 / 127, amp=1200.0)) + self.assertTrue(got["passed"], got) + self.assertGreater(min(got["a1"], got["a2"], got["slow_ms"]), + 10 * T4_FLOOR_MS, got) + + def test_the_wire_is_red_on_every_clause(self): + # Fix round 1: the class built as a wire, at the probe tones where + # the round-0 detector read it green on every clause (996 Hz) or on + # all but the ratio (997 Hz, 1 000 Hz). The absolute clause is what + # turns each red; the pack runs 990-1 004 Hz at three rates. + wire = kit_faults.wire_build(TapeDelay) + for tone in (996.0, 997.0, 1000.0): + got = t4_verdict(*delay_trace(wire, tone=tone)) + self.assertFalse(got["two_lines"], (tone, got)) + self.assertFalse(got["ratio_ok"], (tone, got)) + self.assertFalse(got["drift_ok"], (tone, got)) + + def test_flutter_under_grid_1_is_red_and_not_claimed(self): + # Fix round 1: Flutter above 0 and under grid 1 (0.0315 c) is added + # to Not claimed; at 0.005 c (Wow 8 c) the flutter line reads under + # 40 dB over its floor. + got = t4_verdict(*delay_trace(TapeDelay, wow=8.0, flutter=0.005)) + self.assertFalse(got["two_lines"], got) + + def test_wow_under_grid_1_is_red_and_not_claimed(self): + # Fix round 2: Wow above 0 and under grid 1 (0.063 c) is added to + # Not claimed, as Flutter's was. At 1.2e-4 c (Flutter 1 c, 48 kHz) + # the slow band still reads well over 40 dB above its own floor but + # carries under T4_FLOOR_MS of delay (8.21e-6 ms), so the absolute + # clause is what fails it: the exclusion is needed, and grid 1 + # (test_the_weakest_claimed_lines_clear_the_absolute_floor) is not + # in it. + got = t4_verdict(*delay_trace(TapeDelay, wow=1.2e-4, flutter=1.0)) + self.assertFalse(got["passed"], got) + self.assertFalse(got["drift_ok"], got) + self.assertGreaterEqual(got["drift"], 40.0, got) + self.assertLess(got["slow_ms"], T4_FLOOR_MS, got) + self.assertTrue(got["two_lines"], got) + self.assertTrue(got["ratio_ok"], got) + + +# -- T5: disconfirmed by design ------------------------------------------- + +def loop_area(source_of, record_level, rate=RATE): + """Output against input over one period of a 5 Hz triangle at + 29 205 LSB peak, one pass, Time 350 ms, the loss low-pass in: the + enclosed area by the shoelace formula (Saturation TP3's shape).""" + per = rate // 5 + periods = 8 + t = frames_of(350.0, rate) + frames = t + per * periods + ph = (np.arange(frames) % per) / float(per) + tri = 29205.0 * (4 * np.abs(ph - 0.5) - 1) + opts = dict(HELD) + opts.update(record_level=record_level) + effect = TapeDelay(source_of(tri), time_ms=350.0, **opts) + y = render(effect, frames)[:, 0] + a = t + per * (periods - 2) + x, z = tri[a - t:a - t + per], y[a:a + per] + return 0.5 * abs(float(np.dot(x, np.roll(z, -1)) + - np.dot(z, np.roll(x, -1)))) + + +class T5NoMemory(unittest.TestCase): + """T5 is recorded disconfirmed by design: the loop's only record + nonlinearity is a static cubic, so Record Level 1's loop is no larger + than the Record Level 0 control's. These tests pin that, and show the + measurement can pass on a build with memory.""" + + def test_record_level_draws_no_hysteresis_loop(self): + control = loop_area(lambda x: src_of(x, 1), 0.0) + half = loop_area(lambda x: src_of(x, 1), 0.5) + full = loop_area(lambda x: src_of(x, 1), 1.0) + self.assertLess(10 * math.log10(full / control), 6.0) + self.assertLess(10 * math.log10(full / control), 0.0) + self.assertLess(full, half) + + def test_the_measurement_passes_a_build_with_memory(self): + import audioshaper + xs = np.linspace(-1, 1, 1025) + curve = array("h", np.round(xs * 32767).astype(np.int16).tobytes()) + + def shaped(h): + def source_of(x): + ws = audioshaper.Waveshaper(sample_rate=RATE, channel_count=1, + curve=curve, hysteresis=h, + hysteresis_width=0.02) + ws.play(src_of(x, 1)) + return ws + return source_of + control = loop_area(shaped(0.0), 0.0) + memory = loop_area(shaped(1.0), 0.0) + self.assertGreater(10 * math.log10(memory / control), 6.0) + + +# -- Tier 1 --------------------------------------------------------------- + +class Tier1Fast(unittest.TestCase): + def test_mix_zero_is_a_wire_on_the_full_scale_ramp(self): + for channels in (2, 1): + data = probes.ramp_fs(frames=48000, channels=channels) + src = probes.ArraySource(data, rate=RATE, channels=channels) + effect = TapeDelay(src, mix=0.0, time_ms=20.0) + out = render(effect, 48000) + want = np.frombuffer(data.tobytes(), dtype=np.int16).reshape( + -1, channels) + self.assertEqual(int(np.sum(out != want)), 0, channels) + + def test_the_dry_is_unity_until_the_repeat(self): + x = sine(440.0, 12000.0, 4096) + effect = TapeDelay(src_of(x), mix=1.0) + out = render(effect, 4096)[:, 0] + self.assertTrue(np.array_equal(out, np.round(x))) + + def test_the_control_spans(self): + # Time runs 20 to 1 200 ms and Feedback stops at 0.99, handed so. + effect = TapeDelay(src_of(np.zeros(512))) + effect.set_macro(TIME_I, 0) + self.assertAlmostEqual(effect.macro(TIME_I), 20.0, places=9) + self.assertEqual(effect._frames, frames_of(20.0)) + effect.set_macro(TIME_I, 127) + self.assertAlmostEqual(effect.macro(TIME_I), 1200.0, places=9) + self.assertEqual(effect._frames, frames_of(1200.0)) + effect.set_macro(FEEDBACK_I, 0) + self.assertEqual(effect._feedback, 0.0) + effect.set_macro(FEEDBACK_I, 127) + self.assertEqual(effect._feedback, 0.99) + + def test_mix_2_is_the_repeats_alone(self): + # A click at Mix 2: nothing until the repeat, 100 ms later. + x = np.zeros(8192) + x[10] = 30000 + out = render(TapeDelay(src_of(x), mix=2.0, time_ms=100.0, + feedback=0.0), 8192)[:, 0] + self.assertEqual(float(np.max(np.abs(out[:4800]))), 0.0) + self.assertGreater(float(np.max(np.abs(out[4800:5200]))), 1000.0) + + def test_construction_needs_audioecho(self): + saved = sys.modules.get("audioecho", False) + sys.modules["audioecho"] = None + try: + with self.assertRaises(ImportError): + TapeDelay(src_of(np.zeros(512))) + finally: + if saved is False: + del sys.modules["audioecho"] + else: + sys.modules["audioecho"] = saved + TapeDelay(src_of(np.zeros(512))).deinit() + + def _tail_across_a_stop(self, stop): + """A 50 ms burst into Time 100 ms, Feedback 0.5, Mix 2, then + silence; after 24 pulls the source hands empty buffers for `stop` + pulls, then silence again. Returns (the bytes handed while it was + stopped, the 40 blocks pulled after it came back).""" + feed = lifecycle.Feed(array("h", np.repeat( + np.round(sine(TONE, 12000.0, 2400)).astype(np.int16), 2) + .tobytes()), RATE, 2, "256", False) + effect = TapeDelay(feed.port, sample_rate=RATE, time_ms=100.0, + feedback=0.5, mix=2.0) + for _ in range(24): + audiocore.get_buffer(effect.output) + feed.point(feed.empty) + stopped = bytearray() + for _ in range(stop): + stopped.extend(bytes(audiocore.get_buffer(effect.output)[1])) + feed.point(feed.sil) + after = bytearray() + while len(after) < 40 * BLOCK * 4: + after.extend(bytes(audiocore.get_buffer(effect.output)[1])) + effect.deinit() + return bytes(stopped), bytes(after[:40 * BLOCK * 4]) + + def test_the_tail_waits_for_the_source(self): + # The family limit (audiocomponents#117, audiodsp#180): a source + # that hands empty buffers stops the tail; when it feeds again the + # tail carries on where it was, as if the stop had not happened. + stopped, after = self._tail_across_a_stop(30) + self.assertEqual(stopped.strip(b"\x00"), b"") + self.assertGreater(max(abs(v) for v in array("h", after)), 1000) + self.assertEqual(after, self._tail_across_a_stop(0)[1]) + + def test_silence_stays_silence(self): + for character in tape.CHARACTERS: + for patch in range(len(TapeDelay.PATCHES)): + effect = TapeDelay(src_of(np.zeros(RATE)), + character=character, patch=patch) + self.assertEqual(float(np.max(np.abs(render(effect, RATE)))), + 0.0, (character, patch)) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + for patch, channels in ((0, 2), (2, 1), (5, 2)): + effect = TapeDelay(src_of(np.zeros(512), channels=channels), + patch=patch) + bound = effect.tail_samples + n = 4800 + x = 0.9 * 32767.0 * (np.random.RandomState(7).rand(n) * 2 - 1) + frames = n + bound + 4096 + effect = TapeDelay(src_of(np.concatenate( + [x, np.zeros(frames - n)]), channels), patch=patch) + out = render(effect, frames) + nz = np.nonzero(np.any(out != 0, axis=1))[0] + last = int(nz[-1]) if len(nz) else 0 + self.assertLess(last - n, bound, (patch, last - n, bound)) + + def test_reset_empties_the_line(self): + x = np.concatenate([sine(1000.0, 20000.0, 4800), + np.zeros(RATE)]) + effect = TapeDelay(src_of(x), mix=2.0, time_ms=100.0) + render(effect, 5120) + effect.reset() + out = render(effect, 4800 * 3) + self.assertEqual(float(np.max(np.abs(out))), 0.0) + + def test_deinit_leaves_the_source(self): + src = src_of(sine(440.0, 8000.0, 2048)) + effect = TapeDelay(src) + render(effect, 256) + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(src)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + + def test_click_delay_is_zero(self): + for rate in (48000, 44100): + x = np.zeros(2048) + x[10] = 30000 + effect = TapeDelay(src_of(x, rate=rate), mix=1.0) + out = render(effect, 2048)[:, 0] + self.assertEqual(int(np.argmax(np.abs(out))), 10) + self.assertEqual(effect.latency_samples, 0) + + def test_spread_does_not_silence_mono(self): + x = np.zeros(RATE) + x[32] = 12000 + outs = [] + for spread in (0.0, 37.0 / 127.0, 1.0): + effect = TapeDelay(src_of(x, 1), time_ms=100.0, feedback=0.7, + mix=1.0, spread=spread) + outs.append(render(effect, RATE).tobytes()) + self.assertEqual(outs[0], outs[1]) + self.assertEqual(outs[0], outs[2]) + + def test_the_transport_is_read_only_with_sync_on(self): + reads = [] + + def transport(): + reads.append(1) + return (True, 0.0, 100.0, 4, 4) + effect = TapeDelay.create(src_of(np.zeros(512)), RATE, + transport=transport) + effect.set_macro(FEEDBACK_I, 64) + self.assertEqual(reads, []) + effect.set_macro(SYNC_I, 127) + self.assertGreater(len(reads), 0) + # 1/8 of 100 bpm is 300 ms. + self.assertAlmostEqual(effect._time_played, 300.0, places=6) + effect.set_macro(DIVISION_I, 127) + self.assertAlmostEqual(effect._time_played, 1200.0, places=6) + + def test_a_host_without_a_tempo_leaves_time_on_the_knob(self): + for bpm in (0.0, -10.0, float("nan"), float("inf"), None): + effect = TapeDelay.create( + src_of(np.zeros(512)), RATE, + transport=lambda _b=bpm: (True, 0.0, _b, 4, 4), sync=True) + self.assertAlmostEqual(effect._time_played, 350.0, places=9) + static = TapeDelay(src_of(np.zeros(512)), sync=True) + self.assertAlmostEqual(static._time_played, 350.0, places=9) + + def test_a_synced_move_is_a_varispeed_move(self): + tempo = [120.0] + effect = TapeDelay.create( + src_of(np.zeros(512)), RATE, + transport=lambda: (True, 0.0, tempo[0], 4, 4), sync=True) + self.assertAlmostEqual(effect._time_played, 250.0, places=6) + tempo[0] = 60.0 + effect.set_macro(FEEDBACK_I, 50) + self.assertAlmostEqual(effect._time_played, 500.0, places=6) + self.assertAlmostEqual(effect._slew, 250.0 / 500.0, places=9) + + def test_a_move_inside_a_walk_takes_its_rate_from_the_last_time(self): + effect = TapeDelay(src_of(np.zeros(RATE)), time_ms=200.0) + render(effect, 512) + effect.set_macro(TIME_I, time_midi(300.0)) + self.assertAlmostEqual(effect._slew, 100.0 / 300.0, places=9) + effect.set_macro(FEEDBACK_I, 10) + self.assertAlmostEqual(effect._slew, 100.0 / 300.0, places=9) + effect.set_macro(TIME_I, time_midi(200.0)) + self.assertAlmostEqual(effect._slew, 100.0 / 200.0, places=9) + # The falling move keeps the old Time in the tail's reach. + self.assertEqual(effect._reach, frames_of(300.0)) + + def test_the_feedback_is_handed_as_set(self): + # Up to audiodsp v0.6.2 the always-in loss low-pass could hold a + # small value for ever a hair either side of 1 - 0.5 / k, and the + # class stepped the Feedback clear (the 0.99 stop played as about + # 0.98998). Since v0.6.3rc1 the node lands a stalled low-pass + # (#157): every position is handed as set and the bound is finite, + # 686 laps at the stop (683 stepped). + for rate in (48000, 44100, 22050): + effect = TapeDelay(src_of(np.zeros(512), rate=rate)) + for midi in range(128): + effect.set_macro(FEEDBACK_I, midi) + self.assertEqual(effect._feedback, + min(0.99, effect._value(FEEDBACK_I)), + (rate, midi)) + self.assertIsNotNone(effect.tail_samples, (rate, midi)) + excess = tape.tone_excess(effect._damping, rate)[1] + self.assertEqual(effect._feedback, 0.99) + self.assertEqual(tape.laps_to_zero(effect._feedback, excess), + 686) + # Planted: the retired stepping hands a Feedback nobody set. + stepped = SteppedTape(src_of(np.zeros(512), rate=rate)) + stepped.set_macro(FEEDBACK_I, 127) + self.assertNotEqual(stepped._feedback, 0.99, rate) + self.assertLess(abs(stepped._feedback - 0.99), 3e-5) + self.assertEqual(tape.laps_to_zero(stepped._feedback, excess), + 683) + + def _stall(self, cls, rate=RATE, channels=2): + """The stall cell: Feedback 0.5 (k = 1), Mix 2, Time 100 ms, no + wobble or squash, a 2 LSB DC for 1 s, then 3 s of silence. Returns + (the Feedback handed, tail_samples, frames from the input's end to + the last non-zero sample, whether the last frame is non-zero).""" + frames = 4 * rate + x = np.zeros(frames) + x[:rate] = 2.0 + effect = cls(src_of(x, channels, rate), feedback=0.5, mix=2.0, + record_level=0.0, time_ms=100.0, wow_cents=0.0, + flutter_cents=0.0) + declared = effect.tail_samples + out = render(effect, frames) + nz = np.nonzero(np.any(out != 0, axis=1))[0] + last = int(nz[-1]) if len(nz) else -1 + return effect._feedback, declared, last - rate + 1, last == frames - 1 + + def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): + # Up to audiodsp v0.6.2 the node held this cell for ever with the + # Feedback handed raw, and the class stepped it clear. Since + # v0.6.3rc1 (#157) 0.5 is handed as set and the tail ends inside + # the bound (the Station C Tier 1 cell). + for rate in (48000, 44100, 22050): + for channels in (2, 1): + feedback, declared, tail, held = self._stall( + TapeDelay, rate, channels) + self.assertEqual(feedback, 0.5, (rate, channels)) + self.assertFalse(held, (rate, channels)) + self.assertGreater(tail, 0, (rate, channels)) + self.assertLessEqual(tail, declared, (rate, channels)) + # Planted: the retired stepping hands a Feedback nobody set. + stepped = SteppedTape(src_of(np.zeros(512)), feedback=0.5) + self.assertNotEqual(stepped._feedback, 0.5) + self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + + #: Re-audit round 1's cross-feed stall cells, (label, k, the Feedback + #: the node is handed as float32, Spread): "knob" cells set Spread's + #: MIDI position and a fractional Feedback position by `set_macro`, + #: "ctor" cells pass both to the constructor. The first three are the + #: re-refuter's portable cells (k = 9, 11, 50), the fourth a constructor + #: Spread off every grid, and the fifth one of the 13 cells whose + #: hand-back survives every order a compiler may sum the cross-feed in + #: (separate roundings and both fused multiply-adds), so the plant holds + #: there on a board too (tapedelay_reaudit1_audit.mirror.out.txt). + CROSS_FEED_CELLS = ( + ("knob", 9, 0.9444443583488464, 1), + ("knob", 11, 0.9545453786849976, 3), + ("knob", 50, 0.9899999499320984, 2), + ("ctor", 50, 0.9899998903274536, 0.1726040393114090), + ("knob", 15, 0.9666665792465210, 37), + ) + + @staticmethod + def feedback_position(target): + """A fractional Feedback knob position whose value reaches the node + as exactly `target` in float32 (0..0.99, linear).""" + want = np.float32(target) + m = float(want) * 127.0 / 0.99 + for step in range(-400, 401): + cand = m + step * 1e-9 + if np.float32(0.99 * (cand / 127.0)) == want: + return cand + raise ValueError("no Feedback position reaches %r" % target) + + def _cross_feed_stall(self, cls, cell, rate=RATE, channels=2): + """Time 20 ms, Mix 2, Record Level 0, patch 8's wobble (Wow 2 c, + Flutter 1 c), the cell's Feedback and Spread: a DC of 2k + 2 LSB for + four laps, then silence for `tail_samples` (read after the knobs + move) plus a lap plus 4 096 frames. Returns (tail_samples, frames + from the input's end to the last non-zero frame, |the last frame|, + the Feedback handed).""" + route, k, feedback, spread = cell + ctor = dict(time_ms=20.0, mix=2.0, record_level=0.0, wow_cents=2.0, + flutter_cents=1.0) + if route == "ctor": + ctor.update(feedback=feedback, spread=spread) + + def build(src): + effect = cls(src, **ctor) + if route == "knob": + effect.set_macro(FEEDBACK_I, self.feedback_position(feedback)) + effect.set_macro(SPREAD_I, spread) + return effect + probe = build(src_of(np.zeros(512), channels, rate)) + declared = probe.tail_samples + lap = probe._frames + handed = probe._feedback + probe.deinit() + lead = 4 * lap + frames = lead + declared + lap + 4096 + x = np.zeros(frames) + x[:lead] = 2 * k + 2 + out = render(build(src_of(x, channels, rate)), frames) + nz = np.nonzero(np.any(out[lead:] != 0, axis=1))[0] + tail = int(nz[-1]) + 1 if len(nz) else 0 + return declared, tail, int(np.max(np.abs(out[-1]))), handed + + def test_the_cross_feed_stall_cells_reach_zero(self): + # Re-audit round 1's Tier 1 failure: up to audiodsp v0.6.3rc2 these + # cells held k LSB on both lanes for ever with Spread handed as set, + # and the class put Spread on a 1/4096 grid. Since v0.6.3rc3 the node + # ends a cross-fed tail itself (#173): Spread is handed as set and + # each cell ends inside the bound, in stereo and (Spread held at 0) + # in mono. The trial dropped the RawSpreadTape plant: it was the + # class as it now is, and at rc3 it ends too. + for cell in self.CROSS_FEED_CELLS: + route, k, feedback, spread = cell + for channels in (2, 1): + declared, tail, final, handed = self._cross_feed_stall( + TapeDelay, cell, channels=channels) + self.assertEqual(np.float32(handed), np.float32(feedback), + cell) + self.assertEqual(final, 0, (cell, channels)) + self.assertGreater(tail, 0, (cell, channels)) + self.assertLessEqual(tail, declared, (cell, channels)) + + def test_spread_is_handed_as_set(self): + # At two channels Spread reaches the node as the knob sets it, the + # constructor's too; at one it is held at 0. The 1/4096 grid the + # class used up to v0.6.3rc2 is gone. + for rate in (48000, 44100, 22050): + effect = TapeDelay(src_of(np.zeros(512), 2, rate)) + with NodeSpy(): + for midi in [m / 4.0 for m in range(4 * 127 + 1)]: + effect.set_macro(SPREAD_I, midi) + knob = min(1.0, max(0.0, effect._value(SPREAD_I))) + self.assertEqual(effect._delay._handed["cross_feed"], + knob, midi) + self.assertEqual(effect._spread, knob, midi) + typed = TapeDelay(src_of(np.zeros(512), 2, rate), + spread=0.1726040393114090) + self.assertEqual(typed._spread, 0.1726040393114090) + mono = TapeDelay(src_of(np.zeros(512), 1, rate), + spread=37.0 / 127.0) + self.assertEqual(mono._spread, 0.0) + + def _depth_move(self, cls, start, target, points=8): + """(the tone's own largest step before the move, the largest step in + the 2 000 frames after it) over `points` moves a quarter of the + 0.72 Hz wow line apart, on 997 Hz at 12 000 LSB, mono, wet only, + Time 350 ms, 48 kHz (`pin063cls_tape_wowmove.py`).""" + first = (16800 + 9600) // BLOCK * BLOCK + steadies, worsts = [], [] + for k in range(points): + at = first + k * 65 * BLOCK + events = {at: lambda e: [e.set_macro(i, v) + for i, v in target.items()]} + effect = cls(src_of(sine(TONE, 12000.0, at + 2400), 1), + mix=2.0, feedback=0.0, record_level=0.0, + spread=0.0) + for index, value in start.items(): + effect.set_macro(index, value) + y = render(effect, at + 2000 + BLOCK, events)[:, 0] + steadies.append(float(np.abs(np.diff(y[at - 3000:at - 1])).max())) + worsts.append(float(np.abs(np.diff(y[at - 1:at + 2000])).max())) + return max(steadies), max(worsts) + + def test_a_depth_move_does_not_step(self): + # Since audiodsp v0.6.3rc1 the node ramps a new depth in over 20 ms + # (#160). While it travels the read offset may move |change| / 20 ms + # of a frame per frame on top of the wobble, so the tone may slope up + # to its own largest step times 1 + |change| / 20 ms, and no more. + # Wow at Flutter 0 keeps the table's shape; a knob down to 0 keeps + # the last table while the depth ramps out. + # The planted faults are read where they show: through the loss + # low-pass a read-head jump is plain only at some phases of the + # tone (on Wow 32 -> 127 the eight moves read 791 against 809). + wow_up = ({WOW_I: 32, FLUTTER_I: 0}, {WOW_I: 127}, 2.257, ()) + wow_out = ({WOW_I: 127, FLUTTER_I: 0}, {WOW_I: 0}, 3.017, + (NoneAtZeroTape,)) + flutter_out = ({WOW_I: 0, FLUTTER_I: 127}, {FLUTTER_I: 0}, 0.072, + (NoneAtZeroTape, JumpWowTape)) + for start, target, change, faults in (wow_up, wow_out, flutter_out): + steady, worst = self._depth_move(TapeDelay, start, target) + self.assertLessEqual(worst, steady * (1.0 + change / 20.0), + (start, target)) + # Planted: no table at 0, so the depth ramps out on the node's + # sine; and the read head moved by the whole change at once. + for cls in faults: + steady, worst = self._depth_move(cls, start, target) + self.assertGreater(worst, steady * (1.0 + change / 20.0), + (cls.__name__, start, target)) + + def test_a_balance_move_steps_as_the_docstring_says(self): + # A move that changes the balance of Wow and Flutter changes the + # table's shape, which the node swaps at once: the docstring's 803 + # against the tone's 728, and 1 117 through a flutter-only table. + self.assertEqual(self._depth_move(TapeDelay, + {WOW_I: 32, FLUTTER_I: 0}, + {FLUTTER_I: 127}), (728.0, 803.0)) + self.assertEqual(self._depth_move(TapeDelay, + {WOW_I: 32, FLUTTER_I: 32}, + {WOW_I: 0, FLUTTER_I: 0}), + (728.0, 1117.0)) + + +def spread_tail_sweep(cls, feedback, rate=RATE): + """The kit's TAIL through `macro_sweep` over Spread's 128 grid + positions, stereo, Time 20 ms, Mix 2, Record Level 0, no wobble, at + `feedback` (the constructor's, reaching the node as its float32): each + cell a 0.5 FS DC held 0.5 s (`kit_probes.dc_step`), then silence for + `tail_samples` plus a lap plus 4 096 frames. A cell's figure is its tail + over `tail_samples`, infinite when the line never empties. Returns + (the sweep's result, the MIDI positions whose figure is over 1).""" + ctor = dict(feedback=feedback, mix=2.0, time_ms=20.0, record_level=0.0, + wow_cents=0.0, flutter_cents=0.0) + subject = cls(src_of(np.zeros(512), 2, rate), **ctor) + step, held = probes.dc_step(level=0.5, hold_s=0.5, total_s=0.5, + rate=rate, channels=2) + lead = np.frombuffer(step.tobytes(), dtype=np.int16).reshape(-1, 2)[:, 0] + + def measure(settings): + declared = subject.tail_samples + frames = held + declared + subject._frames + 4096 + x = np.zeros(frames) + x[:len(lead)] = lead + effect = cls(src_of(x, 2, rate), **ctor) + effect.set_macro(SPREAD_I, settings[SPREAD_I]) + out = render(effect, frames).astype(np.int16) + effect.deinit() + got = kit.tail(kit.Render(out.tobytes(), rate, 2), + burst_end_frame=held, declared_tail_samples=declared, + settle_frames=4096)["values"] + if not got["returns_to_zero"]: + return float("inf") + return got["tail_samples"] / float(declared) + + result = kit.macro_sweep( + subject, [kit.MacroSpan(SPREAD_I, 0, 127, midpoints=126)], measure, + worst="max", bar=1.0, name="TAIL/SPREAD") + red = sorted(c["settings"][SPREAD_I] for c in result["values"]["cells"] + if not c["figure"] <= 1.0) + subject.deinit() + return result, red + + +class CrossFeedTailSweep(unittest.TestCase): + """Tier 1 TAIL over Spread's whole travel at the re-refuter's two + Feedbacks (re-audit round 1): the class is green at all 128 positions. + Up to audiodsp v0.6.3rc2 Spread handed as set was red at MIDI 1 and 5 + (0.9444443583) and 2 and 39 (0.9899999499); at rc3 the node ends those + tails itself (#173).""" + + def test_the_tail_ends_at_every_spread(self): + for feedback in (0.9444443583488464, 0.9899999499320984): + result, red = spread_tail_sweep(TapeDelay, feedback) + self.assertEqual(result["values"]["points"], 128) + self.assertEqual(red, [], feedback) + self.assertFalse(result["red"], feedback) + self.assertLess(result["values"]["worst"], 1.0, feedback) + + +class InputCeiling(unittest.TestCase): + """The docstring's ceiling on the kit's `noise_det`, 20 s at 48 kHz: + clean at -1.1 dBFS at the defaults (stereo and mono) and at -2.0 dBFS + at every patch; the defaults rail 0.1 dB above.""" + + def _rails(self, dbfs, channels=2, patch=None, character="varispeed"): + frames = 20 * RATE + data = probes.noise_det(frames=frames, dbfs=dbfs, channels=channels) + src = probes.ArraySource(data, rate=RATE, channels=channels) + effect = TapeDelay(src, patch=patch, character=character) + out = render(effect, frames) + return int(np.sum((out >= 32767) | (out <= -32768))) + + def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): + for channels, over in ((2, 14), (1, 7)): + self.assertEqual(self._rails(-1.1, channels), 0, channels) + self.assertEqual(self._rails(-1.0, channels), over, channels) + for character in tape.CHARACTERS: + for patch in range(len(TapeDelay.PATCHES)): + self.assertEqual(self._rails(-2.0, 2, patch, character), 0, + (character, patch)) + self.assertGreater(self._rails(-1.8, 2, 2), 0) + self.assertGreater(self._rails(-1.7, 2, 4, "sliding-head"), 0) + + +# -- reachability: what the node is handed -------------------------------- +# +# Fix round 1: the first walk read a marker attribute only the faulted +# subclass set, so it could not fail (the reviewer's `DialableTape` passed +# it). Every reading below is a value the class handed its node, or a law +# over handed values at the position walked, and nothing else. + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node: `_handed` (the latest value of each option) and + `_writes` (each call's options, in order).""" + + def __enter__(self): + node_class = tape.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._writes = [] + node._handed.update(options) + node._writes.append(dict(options)) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def copy_of(effect): + """A fresh instance of the same class and character at `effect`'s macro + positions, on silence at its rate. Fix round 1: it also carries the + constructor values the knobs cannot hold - an exact Glide off the grid + (under grid 1 down to the 0.99 pin, or the jump), an exact Time and the + line's `max_time_ms` - so a reading taken on the copy sees what the + node is handed; the round-0 copy dropped them.""" + ctor = {"character": effect._character, + "max_time_ms": effect._max_time_ms} + if effect._glide_exact is not None: + ctor["glide_ms"] = effect._glide_exact + if effect._time_exact is not None: + ctor["time_ms"] = effect._time_exact + other = type(effect)(src_of(np.zeros(4096), 2, effect._sample_rate), + **ctor) + for index in range(len(type(effect).MACRO_LABELS)): + if index == GLIDE_I and effect._glide_exact is not None: + continue + if index == TIME_I and effect._time_exact is not None: + continue + other.set_macro(index, effect.get_macro(index)) + return other + + +def nudge_time(effect): + """Move Time 32 grid steps, toward the middle of the knob.""" + now = effect.get_macro(TIME_I) + effect.set_macro(TIME_I, now + 32.0 if now < 64.0 else now - 32.0) + + +def pull(effect, blocks): + for _ in range(blocks): + audiocore.get_buffer(effect.output) + + +def read_damping(effect): + """The `damping_hz` handed to the loop now.""" + return round(float(effect._delay._handed["damping_hz"]), 2) + + +def k3_law(spacing_m): + """Eq. (13)'s half-power wavenumber, by bisection on this file's own + `eq13_db` (not the class's `k3_of`).""" + lo, hi = 1.0, 1e7 + for _ in range(100): + mid = math.sqrt(lo * hi) + if float(eq13_db(mid / (2 * math.pi), 1.0, spacing_m)) > -3.0103: + lo = mid + else: + hi = mid + return math.sqrt(lo * hi) + + +def speed_law(character, time_ms): + """Dossier section 6's speed law, written here from the numbers.""" + if character == tape.SLIDING_HEAD: + return 0.2032 + return 0.40 * 180.0 / min(600.0, max(180.0, time_ms)) + + +def read_corner_against_law(effect): + """The handed `damping_hz` over the pre-warped eq. (13) corner at the + Time and Spacing the knobs' labels say and the character's speed.""" + time_ms = min(effect._max_time_ms, effect.macro(TIME_I)) + spacing = effect.macro(SPACING_I) + fc = speed_law(effect._character, time_ms) * k3_law(spacing * 1e-6) \ + / (2 * math.pi) + law = tape.nominal_damping_hz(fc, effect._sample_rate) + return round(effect._delay._handed["damping_hz"] / law, 3) + + +def read_speed_law(effect): + """How the handed corner follows Time at these positions: on a copy, + the `damping_hz` handed at Time 180 ms over the one at 600 ms (3.333 on + varispeed, 1 on sliding-head).""" + other = copy_of(effect) + other.set_macro(TIME_I, time_midi(180.0)) + fast = other._delay._handed["damping_hz"] + other.set_macro(TIME_I, time_midi(600.0)) + slow = other._delay._handed["damping_hz"] + other.deinit() + return round(fast / slow, 3) + + +def read_loss_placement(effect): + """(the `damping_hz` handed to the loop, the one handed to a node after + it).""" + post = getattr(effect, "_post", None) + after = post._handed["damping_hz"] if post is not None else 0.0 + return (round(float(effect._delay._handed["damping_hz"]), 2), + round(float(after), 2)) + + +def read_loop_shift(effect): + """The `loop_semitones` handed to the node (never, on the clean + class).""" + return round(float(effect._delay._handed.get("loop_semitones", 0.0)), 4) + + +def read_table(effect): + """(the handed `wow_depth_ms`, the handed table's points).""" + handed = effect._delay._handed + shape = handed["wow_shape"] + return (round(float(handed["wow_depth_ms"]), 9), + tuple(shape) if shape is not None else None) + + +def read_walk(effect): + """(the `delay_slew` handed for one Time move, the law's) on a copy at + these positions: varispeed's law is |dT| / T_new from the two handed + `delay_ms`; sliding-head's is 1 180 ms over the Glide the knob's label + says, pinned at 0.99, and 0 at grid 0.""" + other = copy_of(effect) + before = other._delay._handed["delay_ms"] + nudge_time(other) + after = other._delay._handed["delay_ms"] + handed = other._delay._handed["delay_slew"] + if other._character == tape.VARISPEED: + law = abs(after - before) / after + elif other._macros[GLIDE_I] <= 0.0: + law = 0.0 + else: + law = min(0.99, 1180.0 / other.macro(GLIDE_I)) + other.deinit() + return (round(float(handed), 6), round(float(law), 6)) + + +def read_handed_slew(effect): + """The `delay_slew` handed for one Time move, on a copy at these + positions and constructor values: the node's walk alone, whatever the + knob's label says. It is what T1b's step fault changes, and a + constructor Glide reaches every slew up to the 0.99 pin.""" + other = copy_of(effect) + nudge_time(other) + handed = other._delay._handed["delay_slew"] + other.deinit() + return round(float(handed), 9) + + +def read_python_steps(effect): + """How many times `delay_ms` is handed while eight blocks are pulled + after one Time move, on a copy at these positions: the class hands it + once, on the move; a Python-driven Time hands it every block.""" + other = copy_of(effect) + pull(other, 1) + mark = len(other._delay._writes) + nudge_time(other) + pull(other, 8) + writes = sum(1 for w in other._delay._writes[mark:] if "delay_ms" in w) + other.deinit() + return writes + + +#: (name, fault, reading, constructor options for both builds). A fault +#: whose law is live only away from the defaults is built where it is live +#: (DigitalDelay's corner-cell precedent): `GlideScaledVarispeed` is the +#: clean class at Glide 6 000 ms, so it is built at grid 1's 1 222 ms; +#: `FastWalkAtZeroTape` differs only at Glide 0. Fix round 1: T1b's step +#: fault is `FastWalkAtZeroTape`, read by the handed slew alone; +#: `WalkAtZeroTape` was a constructor value (1 204.08 ms). +SLIDE = {"character": tape.SLIDING_HEAD} +REACH_WALKS = ( + ("DoubleWalkTape", DoubleWalkTape, read_walk, {}), + ("GlideScaledVarispeed", GlideScaledVarispeed, read_walk, + {"glide_ms": 1222.0}), + ("FastWalkAtZeroTape", FastWalkAtZeroTape, read_handed_slew, + dict(SLIDE, glide_ms=0.0)), + ("StaircaseTape", StaircaseTape, read_python_steps, SLIDE), + ("LoopShiftTape", LoopShiftTape, read_loop_shift, {}), + ("PerBlockSlidingTape", PerBlockSlidingTape, read_python_steps, SLIDE), + ("DoubleCornerTape varispeed", DoubleCornerTape, + read_corner_against_law, {}), + ("DoubleCornerTape sliding-head", DoubleCornerTape, + read_corner_against_law, SLIDE), + ("PostLossTape", PostLossTape, read_loss_placement, {}), + ("SquareLawTape", SquareLawTape, read_speed_law, {}), + ("HalfFollowTape", HalfFollowTape, read_speed_law, SLIDE), + ("FlutterOnWowLineTape", FlutterOnWowLineTape, read_table, {}), + ("Harmonic504Tape", Harmonic504Tape, read_table, {}), + ("NoDriftTape", NoDriftTape, read_table, {}), +) + + +def reach(faulted, reading, rate, ctor): + """`kit_faults.fault_reachability` at `rate`, the node watched.""" + def build(cls): + return cls(src_of(np.zeros(512), 2, rate), **ctor) + + with NodeSpy(): + return kit_faults.fault_reachability(TapeDelay, faulted, reading, + build) + + +#: Fix round 1: the constructor values the reachability walk visits beside +#: the macro grid and the patches - the ones a knob cannot hold. Glide: the +#: jump, under the 0.99 pin, the pin's edge (1 191.9 ms), between the pin +#: and grid 1 (1 204.08 ms is slew 0.98 exactly), grid 1 itself, the +#: defaults and past the 12 s top. Time: 0 (20 ms), off-grid values, past +#: the top. The line: a lowered `max_time_ms`. +CTOR_VALUES = ( + ("glide_ms", (0.0, 500.0, 1000.0, 1191.9, 1195.0, 1200.0, + 1180.0 / 0.98, 1210.0, 1221.0, 1221.96, 1250.0, 2950.0, + 6000.0, 12000.0, 30000.0)), + ("time_ms", (0.0, 20.0, 100.4, 351.0, 1199.9, 5000.0)), + ("max_time_ms", (300.0, 1200.0)), +) + + +def _same(a, b, tolerance): + if isinstance(a, (tuple, list)) and isinstance(b, (tuple, list)): + return len(a) == len(b) and all(_same(x, y, tolerance) + for x, y in zip(a, b)) + if a is None or b is None: + return a is b + return abs(float(a) - float(b)) <= tolerance + + +def reach_ctor(faulted, reading, rate, ctor, tolerance=1e-9): + """The fault's reading against the clean class built at every value in + CTOR_VALUES, one option at a time on top of `ctor`, the node watched. + Raises `kit_faults.FaultReachable` on a match; returns the number of + constructions checked.""" + checked = 0 + with NodeSpy(): + subject = faulted(src_of(np.zeros(512), 2, rate), **ctor) + target = reading(subject) + subject.deinit() + for name, values in CTOR_VALUES: + for value in values: + opts = dict(ctor) + opts[name] = value + clean = TapeDelay(src_of(np.zeros(512), 2, rate), **opts) + got = reading(clean) + clean.deinit() + checked += 1 + if _same(got, target, tolerance): + raise kit_faults.FaultReachable( + "%s: the clean class built with %s=%r reads %r, the " + "state the fault forces" % ( + faulted.__name__, name, value, got)) + return checked + + +class FaultsAreUnreachable(unittest.TestCase): + """Every planted fault's reachability walk, at 48, 44.1 and 22.05 kHz, + reading what the node is handed at each position (11 macros x 17 + positions + 9 patches); and two builds the walk must call reachable, so + the readings are shown able to fail.""" + + CHECKED = 11 * 17 + 9 + + def test_every_fault_is_off_the_surface_at_three_rates(self): + for rate in (48000, 44100, 22050): + for name, faulted, reading, ctor in REACH_WALKS: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor) + self.assertEqual(result["checked"], self.CHECKED) + + def test_every_fault_is_off_the_constructor_values(self): + # Fix round 1: the walk also visits the constructor values a knob + # cannot hold (CTOR_VALUES, 23 constructions per fault). + for rate in (48000, 44100, 22050): + for name, faulted, reading, ctor in REACH_WALKS: + with self.subTest(fault=name, rate=rate): + self.assertEqual( + reach_ctor(faulted, reading, rate, ctor), + sum(len(v) for _, v in CTOR_VALUES)) + + def test_a_constructor_value_fault_is_called_reachable(self): + # Station B's step fault, slew 0.98 at Glide 0, is the clean class + # built at glide_ms 1 204.08: the constructor walk must say so, + # and the macro walk alone cannot. + ctor = dict(SLIDE, glide_ms=0.0) + with self.assertRaises(kit_faults.FaultReachable): + reach_ctor(WalkAtZeroTape, read_handed_slew, RATE, ctor) + self.assertEqual(reach(WalkAtZeroTape, read_handed_slew, RATE, + ctor)["checked"], self.CHECKED) + + def test_a_dialable_fault_is_called_reachable(self): + # Spacing forced to 20 um: Spacing MIDI 127 plays it. + with self.assertRaises(kit_faults.FaultReachable): + reach(DialableTape, read_damping, RATE, {}) + # The first round's two-line fault is the table Flutter 0 writes. + with self.assertRaises(kit_faults.FaultReachable): + reach(NoFlutterLineTape, read_table, RATE, {}) + # Read by the handed corner alone, the T3 faults are the clean + # class at 350 ms; that is why they are read by the corner's law + # over Time. + for faulted, character in ((SquareLawTape, tape.VARISPEED), + (HalfFollowTape, tape.SLIDING_HEAD)): + with self.assertRaises(kit_faults.FaultInert): + reach(faulted, read_damping, RATE, {"character": character}) + + def test_the_varispeed_walk_is_the_tape_equation_everywhere(self): + effect = TapeDelay(src_of(np.zeros(RATE))) + render(effect, 512) + last = effect._node_ms + for midi in list(range(0, 128, 8)) + [127]: + effect.set_macro(TIME_I, midi) + if effect._node_ms != last: + self.assertAlmostEqual( + effect._slew, abs(effect._node_ms - last) + / effect._node_ms, places=12) + last = effect._node_ms + for index in range(11): + if index == TIME_I: + continue + for midi in (0, 64, 127): + effect.set_macro(index, midi) + self.assertLess(effect._slew, 60.0) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire.""" + + def test_every_demonstrated_row_is_red_on_a_wire(self): + rows = ( + ("T1a", lambda cls: t1a_cell(cls, 200.0, 100.4)), + ("T1b ramp", lambda cls: ramp_cell(cls)), + ("T2", lambda cls: t2_one_pass(cls, 350.0, 5.0, tape.VARISPEED)), + ("T3", lambda cls: t3_cell(cls)), + ("T4", lambda cls: t4_verdict(*delay_trace(cls, seconds=60.0))), + # Fix round 1: the step's read-position clause and the + # per-piece reader. + ("T1b step", lambda cls: step_cell(cls)), + ("T1a per piece", lambda cls: walk_cell( + cls, 600.0, 1200.0, RATE, tape.VARISPEED, TONE * 0.5)), + ("T1b per piece", lambda cls: walk_cell( + cls, 400.0, 650.0, RATE, tape.SLIDING_HEAD, + glide_law_hz(glide_ms_of(10), 400.0, 650.0), + setup=lambda e: e.set_macro(GLIDE_I, 10))), + ) + for name, measure in rows: + result = kit_faults.null_build_red(TapeDelay, measure, + label="TapeDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + if name == "T4": + # Fix round 1: red on every clause, not on the ratio alone. + null = result["null"] + self.assertFalse(null["two_lines"] or null["ratio_ok"] + or null["drift_ok"], null) + + +# -- the docstring's claims ----------------------------------------------- + +#: The board figures the docstring's Cost quotes, in ms per 256-frame stereo +#: block (budget: palette row FeedbackDelay +options with no glue). Measured +#: at audiodsp v0.6.2 (the full class at the default and every patch; patch +#: 8 at `max_time_ms=800`, three runs); re-measured once at the final release. +BOARD_COST = { + "measured_at": "v0.6.2", + "budget": {"P4": 0.480, "S3": 0.800}, + "full": {"P4": (0.551, 0.608), "S3": (1.056, 1.093)}, + "lean": {"P4": (0.445, 0.455), "S3": (0.781, 0.797)}, +} + +#: Every claim the module docstring makes, word for word, and the tests that +#: assert it ("Class.test_name", in this file). +CLAIMS = ( + ("Time is the delay, from 20 to 1200 ms, and Feedback is how much of " + "each repeat goes round again, up to 0.99.", + ("Tier1Fast.test_the_control_spans",)), + ("Mix is the echo return: the dry stays at unity up to Mix 1, Mix 2 is " + "the repeats alone, and at Mix 0 the output is the input.", + ("Tier1Fast.test_the_dry_is_unity_until_the_repeat", + "Tier1Fast.test_mix_2_is_the_repeats_alone", + "Tier1Fast.test_mix_zero_is_a_wire_on_the_full_scale_ramp")), + ("Spread feeds each channel's repeats into the other, and does nothing " + "on a mono source.", + ("Tier1Fast.test_spread_does_not_silence_mono", + "Tier1Fast.test_spread_is_handed_as_set")), + ("With Sync on, Time is Division of the host's beat; with no host " + "tempo, Time stays where the knob is.", + ("Tier1Fast.test_the_transport_is_read_only_with_sync_on", + "Tier1Fast.test_a_host_without_a_tempo_leaves_time_on_the_knob")), + ("`character=\"varispeed\"`, the default, is the RE-201: Time moves the " + "motor, so a Time move bends the pitch of everything on the tape " + "instead of clicking, then settles.", + ("T1aVarispeed.test_the_named_moves",)), + ("Glide does nothing on this character.", + ("GlideIsInertOnVarispeed.test_three_glides_one_render",)), + ("`character=\"sliding-head\"` is the EP-3: Time slides a head, so the " + "pitch bends only while the head moves, at the rate Glide sets, and " + "the tape runs at one speed, so the darkening does not follow Time.", + ("T1bSlidingHead.test_the_ramp", "TheSurface.test_the_loss_corner")), + ("Glide 0 is an instant slide, and its price is a click.", + ("T1bSlidingHead.test_the_step_at_glide_0",)), + ("A Time move made while the last one is still bending takes its rate " + "from the last Time you set, not from where the tape has got to, so it " + "does not telescope as a real motor would.", + ("Tier1Fast.test_a_move_inside_a_walk_takes_its_rate_from_the_last_time",)), + ("A long rising move, or a rising slide at the fastest Glides, can read " + "more than 10 cents off the ideal bend, because the node walks its " + "read head in single precision.", + ("T1aVarispeed.test_the_top_binade_per_piece", + "T1bSlidingHead.test_the_glide_binades")), + ("The wobble is periodic, not random.", + ("TheSurface.test_the_table_holds_its_three_components",)), + ("A Wow or Flutter move that changes only how deep the wobble is glides " + "in, and one that changes their balance steps, so set the balance " + "before you play.", + ("Tier1Fast.test_a_depth_move_does_not_step", + "Tier1Fast.test_a_balance_move_steps_as_the_docstring_says")), + ("The darkening follows the tape's loss law only up to a band top that " + "rises with the tape's speed.", + ("T2LossLaw.test_one_pass_at_the_named_cells",)), + ("Record Level has no memory: tape hysteresis is not modelled.", + ("T5NoMemory.test_record_level_draws_no_hysteresis_loop",)), + ("The RE-201's Bass and Treble are not here.", + ("TheSurface.test_macros_characters_tier_latency",)), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + ("Tier1Fast.test_a_balance_move_steps_as_the_docstring_says",)), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", + ("Tier1Fast.test_the_tail_waits_for_the_source",)), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", + ("Tier1Fast.test_the_tail_waits_for_the_source",)), + ("Latency is zero samples: nothing looks ahead.", + ("Tier1Fast.test_click_delay_is_zero",)), + ("`tail_samples` is an upper bound on how long the output takes to " + "reach exact zero once your input stops, at every Feedback and Spread, " + "stereo and mono.", + ("Tier1Fast.test_the_tail_reaches_exact_zero_inside_tail_samples", + "Tier1Fast.test_the_cross_feed_stall_cells_reach_zero", + "CrossFeedTailSweep.test_the_tail_ends_at_every_spread")), + ("Pass a lower `max_time_ms` for a shorter line: Time then stops at " + "that ceiling, and `get_macro(0)` shows where it stopped.", + ("LeanPatch.test_the_lean_build_renders_what_the_boards_measured",)), + ("The class needs audiodsp's `audioecho`, and on a board without it " + "construction raises `ImportError`.", + ("Tier1Fast.test_construction_needs_audioecho",)), + ("`character` must be `\"varispeed\"` or `\"sliding-head\"`.", + ("TheSurface.test_macros_characters_tier_latency",)), + ("Measured at v0.6.2 at the default and every patch, the full class " + "costs 0.551-0.608 ms a block on the P4 and 1.056-1.093 ms on the S3, " + "over the budgets of 0.480 ms and 0.800 ms.", + ("Claims.test_the_board_figures_are_the_table",)), + ("Measured at v0.6.2, patch 8 on a class built with `max_time_ms=800` " + "costs 0.445-0.455 ms on the P4 and 0.781-0.797 ms on the S3, inside " + "both budgets.", + ("Claims.test_the_board_figures_are_the_table", + "LeanPatch.test_the_lean_build_renders_what_the_boards_measured")), + ("`reset()` returns to patch 0, so a host that wants the lean patch " + "sets it again after a reset.", + ("LeanPatch.test_reset_brings_the_drive_back",)), +) + +#: Model names that carry digits and are not figures. +NAMES = ("RE-201", "EP-3") + +FAMILY_HEADING = "**Limits shared by the family.**" + + +def _flat(text): + return " ".join(text.split()) + + +def claim_problems(doc, claims=CLAIMS): + """What is wrong between a docstring and `claims`: a sentence missing, a + named test that does not exist, a digit outside every claim.""" + doc = _flat(doc) + problems = [] + rest = doc + for sentence, tests in claims: + if sentence not in doc: + problems.append("missing: %s" % sentence) + rest = rest.replace(sentence, " ") + for name in tests: + owner, _, test = name.partition(".") + if not hasattr(globals().get(owner), test): + problems.append("no test %s" % name) + for name in NAMES: + rest = rest.replace(name, " ") + for match in re.finditer(r"\S*\d\S*", rest): + problems.append("figure outside a claim: %s" % match.group()) + return problems + + +class Claims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + self.assertEqual(claim_problems(tape.__doc__), []) + self.assertEqual(claim_problems(TapeDelay.__doc__, ()), []) + self.assertIn(FAMILY_HEADING, _flat(tape.__doc__)) + # The checker can fail: a figure outside a claim, a claim the + # docstring does not carry, a test that does not exist. + self.assertTrue(claim_problems(tape.__doc__ + " It reads 12 ms.")) + self.assertTrue(claim_problems(tape.__doc__.replace( + "nothing looks ahead", "nothing looks back"))) + self.assertTrue(claim_problems(tape.__doc__, CLAIMS + ( + ("The wobble is periodic, not random.", + ("Tier1Fast.test_nothing_here",)),))) + + def test_the_board_figures_are_the_table(self): + cost = BOARD_COST + doc = _flat(tape.__doc__) + + def span(pair): + return "%.3f-%.3f ms" % pair + full = ("Measured at %s at the default and every patch, the full " + "class costs %s a block on the P4 and %s on the S3, over " + "the budgets of %.3f ms and %.3f ms." % ( + cost["measured_at"], span(cost["full"]["P4"]), + span(cost["full"]["S3"]), cost["budget"]["P4"], + cost["budget"]["S3"])) + lean = ("Measured at %s, patch 8 on a class built with " + "`max_time_ms=800` costs %s on the P4 and %s on the S3, " + "inside both budgets." % ( + cost["measured_at"], span(cost["lean"]["P4"]), + span(cost["lean"]["S3"]))) + self.assertIn(full, doc) + self.assertIn(lean, doc) + # "over" and "inside" are what the table says. + for board in ("P4", "S3"): + self.assertGreater(cost["full"][board][0], cost["budget"][board]) + self.assertLess(cost["lean"][board][1], cost["budget"][board]) + self.assertEqual(TapeDelay.PATCHES[8][0], "Tape Delay - lean") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_time.py b/tests/test_cpython_effects_time.py index ad18246..45a4030 100644 --- a/tests/test_cpython_effects_time.py +++ b/tests/test_cpython_effects_time.py @@ -27,6 +27,13 @@ from effects_measure import (SAMPLE_RATE, burst, channels, loudest_in, peak, source, tone_gain_db) # noqa: E402 +# The tests below that name `delay.` or `reverb.` are written against the old +# `_core.Effect` classes those modules keep. `audioeffects.TapeDelay`, +# `AnalogDelay` and `ConvolutionReverb` serve the Phase 5 rebuilds since +# their adoption on 2026-09-29, which take different settings (no `wow`, +# `age` or `stereo`) and have no `clear()`. +from audioeffects import delay, reverb # noqa: E402 + class TimeTest(unittest.TestCase): def test_a_tape_delay_leaves_the_dry_path_alone(self): @@ -47,10 +54,10 @@ def test_a_tape_delay_darkens_each_repeat_more_than_the_last(self): # through it once per lap, so the third is darker than the first. # Measured as how much of each tone survives three laps. def survival(hz): - delay = audioeffects.TapeDelay(burst(hz), time_ms=100.0, - feedback=0.7, mix=1.0, wow=0.0, - tone_hz=3000.0, drive=0.0) - left, _ = channels(delay.output, 200) + line = delay.TapeDelay(burst(hz), time_ms=100.0, + feedback=0.7, mix=1.0, wow=0.0, + tone_hz=3000.0, drive=0.0) + left, _ = channels(line.output, 200) step = int(0.1 * SAMPLE_RATE) first = loudest_in(left, step, 2000) third = loudest_in(left, step * 3, 2000) @@ -80,26 +87,26 @@ def test_an_older_analog_delay_is_darker_and_narrower(self): # `age` is one knob over the loop's low-pass, its high-pass and its # drift. Only the first two are measurable as a level. def repeat_level(hz, age): - delay = audioeffects.AnalogDelay(burst(hz), time_ms=100.0, - feedback=0.6, mix=1.0, age=age, - drive=0.0) - left, _ = channels(delay.output, 200) + line = delay.AnalogDelay(burst(hz), time_ms=100.0, + feedback=0.6, mix=1.0, age=age, + drive=0.0) + left, _ = channels(line.output, 200) return loudest_in(left, int(0.2 * SAMPLE_RATE), 2000) self.assertLess(repeat_level(6000.0, 1.0), repeat_level(6000.0, 0.0)) self.assertLess(repeat_level(80.0, 1.0), repeat_level(80.0, 0.0)) def test_a_delay_line_can_be_emptied(self): - delay = audioeffects.TapeDelay(burst(1000.0), time_ms=100.0, - feedback=0.8, mix=1.0) - channels(delay.output, 40) - delay.clear() - self.assertEqual(peak(delay.output, 4), 0.0) + line = delay.TapeDelay(burst(1000.0), time_ms=100.0, + feedback=0.8, mix=1.0) + channels(line.output, 40) + line.clear() + self.assertEqual(peak(line.output, 4), 0.0) def test_a_synthesized_room_decays_at_the_time_it_was_asked_for(self): - verb = audioeffects.ConvolutionReverb(source(), seconds=0.5) + verb = reverb.ConvolutionReverb(source(), seconds=0.5) verb.set_macro(4, 127) # full wet, so only the tail is measured - low, high = audioeffects.ConvolutionReverb._MACRO_RANGES[0][:2] + low, high = reverb.ConvolutionReverb._MACRO_RANGES[0][:2] for knob in (127, 64, 0): verb.set_macro(0, knob) expected = 0.5 * (low + (high - low) * knob / 127.0) @@ -108,8 +115,8 @@ def test_a_synthesized_room_decays_at_the_time_it_was_asked_for(self): def test_a_synthesized_room_is_not_the_same_noise_on_both_sides(self): # A stereo impulse whose channels agreed would be a mono impulse, and # the whole reason to spend twice the memory is that they do not. - verb = audioeffects.ConvolutionReverb(source(), seconds=0.25, - stereo=True) + verb = reverb.ConvolutionReverb(source(), seconds=0.25, + stereo=True) verb.set_macro(4, 127) left, right = [], [] for _ in range(12): diff --git a/tests/test_effect_kit.py b/tests/test_effect_kit.py index bf6d236..d832101 100644 --- a/tests/test_effect_kit.py +++ b/tests/test_effect_kit.py @@ -246,7 +246,7 @@ def pull(blocks): def test_a_delay_line_left_full_after_reset_is_red(self): options = {"time_ms": 150.0, "feedback": 0.5, "mix": 0.5} - control = self._run(audioeffects.DigitalDelay, **options) + control = self._run(faults._OldDigitalDelay, **options) self.assertTrue(control["passed"], control["red"]) self.assertEqual(control["values"]["reset_residual_lsb"], 0) self.assertTrue(control["values"]["resumed"]) diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py new file mode 100644 index 0000000..7d57b3d --- /dev/null +++ b/tests/test_lifecycle_matrix.py @@ -0,0 +1,336 @@ +"""The lifecycle matrix (`tests/support/lifecycle.py`) on every Phase 5 +class this branch carries, on CPython and on the two native interpreters. + +What is red today is written down in KNOWN_RED below, one row per defect, +each with a one-line reason. The file passes while the red cells are exactly +those rows' cells, and goes red when a cell changes either way: a new red +cell nobody listed, or a listed cell that turned green (then delete the +row). A row is a finding, not an exception: a class opts out of a property +only through `lifecycle.DECLARED`. + +The planted faults at the bottom are the matrix's proof that each property +can fail: every plant is red on its cell and its control is green there. + + OMP_NUM_THREADS=1 python -m unittest tests.test_lifecycle_matrix + LIFECYCLE_CLASSES=DigitalDelay python -m unittest tests.test_lifecycle_matrix + +`LIFECYCLE.md` beside the helper says what each event and property is. +""" + +import os +import subprocess +import sys +import tempfile +import unittest +import zlib + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(ROOT, "lib")) +sys.path.insert(0, os.path.join(ROOT, "tests", "support")) + +import lifecycle # noqa: E402 +from audioeffects import rebuilt # noqa: E402 + +PHASE5 = ("DigitalDelay", "SlapbackDelay", "TapeDelay", "PingPongDelay", + "MultiTapDelay", "AnalogDelay", "Reverb", "ConvolutionReverb") + +#: What is red today. (class, event name prefix, property) -> +#: (red cells in the quick matrix, their digest, red cells in the full +#: matrix, their digest, reason). A red cell belongs to the row with the +#: longest matching prefix. The file goes red when a red cell belongs to no +#: row, or when a row's cells change in number or in which cells they are +#: (the message lists them). The full-matrix pair is None where the full +#: matrix has not been run (MultiTapDelay: it takes about 40 minutes on +#: CPython). +KNOWN_RED = {} + +#: P6 differences today: class -> (cells whose line differs between CPython +#: and a native interpreter in the quick matrix, their digest, the same for +#: the full matrix, reason). Both native interpreters print the same lines +#: as each other for every class. +KNOWN_P6 = { + "MultiTapDelay": ( + 29, "cedec0fa", None, None, + "after a Time move or a patch change CPython renders other bytes " + "than both native interpreters, and on 20 cells plays old audio " + "out of silence where they do not: the CPython audiodelays twin " + "keeps the line past a shorter delay_ms, which the C node zeroes " + "(audiodsp#177)"), +} + +#: LIFECYCLE_FULL=1 runs the full matrix (every event at every patch); +#: otherwise the quick one (lifecycle.run_class(quick=True)). +FULL = bool(os.environ.get("LIFECYCLE_FULL")) + + +def digest(cells): + text = "\n".join(sorted("|".join(map(str, c)) for c in cells)) + return "%08x" % (zlib.crc32(text.encode()) & 0xFFFFFFFF) + + +def parse(lines): + """{(class, event, rate, ch, patch): {prop: verdict}}.""" + cells = {} + for line in lines: + parts = line.split("|") + if len(parts) < 6 or parts[0] in ("SUMMARY", "DONE"): + continue + key = (parts[0], parts[1], int(parts[2]), int(parts[3]), parts[4]) + cells[key] = dict(p.split(":", 1) for p in parts[5:]) + return cells + + +def red_cells(cells): + out = set() + for key, props in cells.items(): + for prop, verdict in props.items(): + if verdict.startswith("RED"): + out.add((key[0], key[1], prop, key[2], key[3], key[4])) + return out + + +def row_of(cell, rows): + best = None + for row in rows: + if row[0] == cell[0] and row[2] == cell[2] and \ + cell[1].startswith(row[1]): + if best is None or len(row[1]) > len(best[1]): + best = row + return best + + +def group(name, red, table): + rows = [row for row in table if row[0] == name] + groups = dict((row, set()) for row in rows) + loose = [] + for cell in sorted(red): + row = row_of(cell, rows) + if row is None: + loose.append(cell) + else: + groups[row].add(cell) + return groups, loose + + +def check_known(name, red, table=None, full=None): + """Problems with class `name`'s red cells against KNOWN_RED.""" + table = KNOWN_RED if table is None else table + full = FULL if full is None else full + groups, loose = group(name, red, table) + problems = ["new red: %s" % "|".join(map(str, c)) for c in loose] + for row, got in sorted(groups.items()): + entry = table[row] + count, dig = (entry[2], entry[3]) if full else (entry[0], entry[1]) + if count is None: + continue + if (len(got), digest(got)) != (count, dig): + problems.append("row %r changed: %d red cells (digest %s), " + "expected %d (%s); now red: %s" % ( + row, len(got), digest(got), count, dig, + ", ".join("|".join(map(str, c[1:])) + for c in sorted(got))[:1500])) + return problems + + +def classes(): + wanted = os.environ.get("LIFECYCLE_CLASSES") + names = wanted.split(",") if wanted else PHASE5 + return [n for n in names if rebuilt.module_class(n) is not None] + + +def native_binaries(): + """{"micropython": path or None, "circuitpython": path or None}: the + workspace's `bin/`, or LIFECYCLE_MICROPYTHON / LIFECYCLE_CIRCUITPYTHON.""" + found = {} + here = ROOT + bindir = None + for _up in range(5): + here = os.path.dirname(here) + if os.path.isfile(os.path.join(here, "bin", "micropython")): + bindir = os.path.join(here, "bin") + break + for family in ("micropython", "circuitpython"): + path = os.environ.get("LIFECYCLE_" + family.upper()) + if path is None and bindir is not None: + path = os.path.join(bindir, family) + found[family] = path if path and os.path.isfile(path) else None + return found + + +def run_native(binary, names, extra=()): + """The helper's printed lines for `names` under a native binary.""" + with tempfile.TemporaryDirectory() as scratch: + env = dict(os.environ, MICROPYPATH="lib:tests/support", + GCOV_PREFIX=scratch, PYTHONDONTWRITEBYTECODE="1") + done = subprocess.run( + [binary, "-X", "heapsize=256M", "tests/support/lifecycle.py"] + + list(names) + list(extra), + capture_output=True, text=True, cwd=ROOT, env=env) + lines = done.stdout.splitlines() + if done.returncode != 0 or not lines or lines[-1] != "DONE": + raise AssertionError("%s: %s%s" % (binary, done.stdout[-1500:], + done.stderr[-1500:])) + return lines + + +def start_native(binary, names, scratch): + env = dict(os.environ, MICROPYPATH="lib:tests/support", + GCOV_PREFIX=scratch, PYTHONDONTWRITEBYTECODE="1") + return subprocess.Popen( + [binary, "-X", "heapsize=256M", "tests/support/lifecycle.py"] + + list(names) + ([] if FULL else ["--quick"]), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, cwd=ROOT, env=env) + + +def run_cpython(name): + lines = [] + lifecycle.run_class(rebuilt.module_class(name), emit=lines.append, + quick=not FULL) + return lines + + +class TestMatrix(unittest.TestCase): + """Each class's red cells are exactly KNOWN_RED's, and the three + interpreters print the same line for every cell (P6).""" + + @classmethod + def setUpClass(cls): + cls.names = classes() + cls.natives = native_binaries() + cls.scratch = tempfile.TemporaryDirectory() + cls.procs = {} + for family, binary in cls.natives.items(): + if binary is not None and cls.names: + cls.procs[family] = start_native(binary, cls.names, + cls.scratch.name) + cls.cpython = {} + for name in cls.names: + cls.cpython[name] = run_cpython(name) + cls.native_lines = {} + for family, proc in cls.procs.items(): + out, err = proc.communicate() + lines = out.splitlines() + if proc.returncode != 0 or not lines or lines[-1] != "DONE": + cls.native_lines[family] = AssertionError( + "%s: %s%s" % (family, out[-1500:], err[-1500:])) + else: + cls.native_lines[family] = lines + + @classmethod + def tearDownClass(cls): + cls.scratch.cleanup() + + def test_known_red_on_cpython(self): + problems = [] + for name in self.names: + cells = parse(self.cpython[name]) + problems += check_known(name, red_cells(cells)) + self.assertEqual(problems, [], "\n".join(problems[:60])) + + def _p6(self, family): + if self.natives.get(family) is None: + self.skipTest("no %s binary: set LIFECYCLE_%s or build the " + "workspace's bin/%s" % (family, family.upper(), + family)) + got = self.native_lines[family] + if isinstance(got, Exception): + raise got + problems = [] + for name in self.names: + ours = parse(self.cpython[name]) + theirs = parse([l for l in got if l.startswith(name + "|")]) + keys = [key for key in sorted(set(ours) | set(theirs)) + if ours.get(key) != theirs.get(key)] + entry = KNOWN_P6.get(name) + if entry is not None: + count, dig = (entry[2], entry[3]) if FULL else entry[:2] + if count is None or (len(keys), digest(keys)) == (count, + dig): + continue + for key in keys: + problems.append("%s: %s cpython %s, %s %s" % ( + family, "|".join(map(str, key)), ours.get(key), + family, theirs.get(key))) + self.assertEqual(problems, [], "\n".join(problems[:60])) + + def test_p6_micropython(self): + self._p6("micropython") + + def test_p6_circuitpython(self): + self._p6("circuitpython") + + +def plant_cell(plant, event, prop, patch=None, rate=48000, channels=2): + lines = [] + lifecycle.run_class(lifecycle.PLANTS[plant], rates=(rate,), + channels=(channels,), patches=[patch], + only=[event.split("-")[0]], emit=lines.append, + name=plant) + cells = parse(lines) + return cells[(plant, event, rate, channels, + "d" if patch is None else str(patch))][prop] + + +class TestPlants(unittest.TestCase): + """Every property can fail: each plant is red on its cell, and its + control is green on the same cell.""" + + CASES = ( + ("droponreset", "plain", "E1-reset@block", "P1"), + ("holddc", "plain", "E1-reset@block", "P2"), + ("stalemix", "routed", "E5-mix0", "P3"), + ("latemove", "plain", "E4-m1=0", "P4"), + ("hardswitch", "plain", "E4-m0=0", "P5"), + ("busydeinit", "plain", "E3-deinit", "PD"), + ) + + def test_each_plant_is_red_and_its_control_green(self): + for plant, control, event, prop in self.CASES: + with self.subTest(plant=plant): + self.assertTrue(plant_cell(plant, event, prop) + .startswith("RED"), plant) + self.assertEqual(plant_cell(control, event, prop), "ok", + control) + + def test_a_declared_exception_prints_decl(self): + lifecycle.DECLARED[("LifecyclePlain", "E1-reset@block", "P1")] = \ + "planted declaration" + try: + got = plant_cell("droponreset", "E1-reset@block", "P1") + finally: + del lifecycle.DECLARED[("LifecyclePlain", "E1-reset@block", + "P1")] + self.assertEqual(got, "decl") + + def test_known_red_catches_both_directions(self): + one = ("X", "E1-a", "P1", 48000, 2, "d") + two = ("X", "E1-b", "P1", 48000, 2, "d") + table = {("X", "E1-", "P1"): (2, digest([one, two]), None, None, + "planted")} + self.assertEqual(check_known("X", {one, two}, table, False), []) + # A listed cell turned green, a cell swapped for another, a red + # cell no row names: each is a problem. + self.assertTrue(check_known("X", {one}, table, False)) + three = ("X", "E1-c", "P1", 48000, 2, "d") + self.assertTrue(check_known("X", {one, three}, table, False)) + four = ("X", "E2-a", "P1", 48000, 2, "d") + got = check_known("X", {one, two, four}, table, False) + self.assertTrue(any(p.startswith("new red") for p in got)) + + def test_p6_can_fail(self): + """A plant whose level depends on the interpreter prints a + different line natively than on CPython.""" + binary = native_binaries()["micropython"] + if binary is None: + self.skipTest("no micropython binary") + extra = ["--events=E4"] + ours = parse(run_native(sys.executable, ["implplant"], extra)) + theirs = parse(run_native(binary, ["implplant"], extra)) + self.assertNotEqual(ours, theirs) + same = parse(run_native(binary, ["plain"], extra)) + self.assertEqual(parse(run_native(sys.executable, ["plain"], extra)), + same) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_measure_effect_cost.py b/tests/test_measure_effect_cost.py new file mode 100644 index 0000000..450401c --- /dev/null +++ b/tests/test_measure_effect_cost.py @@ -0,0 +1,70 @@ +"""The cost tool's Phase 5 additions (housekeeping, 2026-09-28). + +`tools/measure_effect_cost.py` is a board tool and its timings are not +desktop facts, so this checks only what the desktop can: the two new palette +rows render what the boards rendered, and an effect target finds the palette +row it is measured beside without importing anything to do it. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from tools import measure_effect_cost as m # noqa: E402 + + +def digest(key): + probe = m.Probe() + output, extras, keep = m.resolve("node:" + key)(probe) + value = m._warm(output, extras, True)[0] + del keep + return value + + +class TheNewRows(unittest.TestCase): + def test_feedback_09_is_the_row_the_boards_took(self): + # Both boards and both desktop legs rendered this at audiodsp + # v0.6.2 (board-test-plan.md, "Section 1 at v0.6.2"); at v0.6.1 it + # was d9fb49a51cd7379a, which is why the row exists. + self.assertEqual(digest("audioecho.FeedbackDelay@fb0.9"), + "3b76320cb19450a8") + # The 0.45 row renders other bytes; a 0.9 row that fell back to it + # would be red here. + self.assertEqual(digest("audioecho.FeedbackDelay"), + "a6abc903a9e8e073") + + def test_the_damping_row_turns_the_loop_low_pass_on(self): + options = digest("audioecho.FeedbackDelay@options") + self.assertEqual(options, "e7a07359012d7a8e") + self.assertEqual(digest("audioecho.FeedbackDelay@options+damping"), + "9e06dd6c5d8a2f38") + + +class TheBesideRow(unittest.TestCase): + def test_a_class_target_names_its_class(self): + for target, name in ( + ("rebuilt:DigitalDelay@5", "DigitalDelay"), + ("effect:SlapbackDelay#3", "SlapbackDelay"), + ("rebuilt:Fuzz@ch-cascade@os2@4", "Fuzz"), + ("DigitalDelay", "DigitalDelay"), + ("source", None), + ("node:audioecho.FeedbackDelay", None), + ("audioecho.FeedbackDelay@options", None)): + self.assertEqual(m._class_name(target), name, target) + + def test_every_named_row_is_a_node_row(self): + for name, key in m.BESIDE.items(): + self.assertIn(key, m.NODES, name) + self.assertIs(m.resolve("node:" + key), m.NODES[key]) + self.assertEqual(m.BESIDE["DigitalDelay"], + "audioecho.FeedbackDelay@options") + self.assertEqual(m.BESIDE["SlapbackDelay"], + "audioecho.FeedbackDelay@options") + with self.assertRaises(ValueError): + m.resolve("node:audioecho.FeedbackDelay@nosuchrow") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rebuilt_registry.py b/tests/test_rebuilt_registry.py index f977479..9678cf7 100644 --- a/tests/test_rebuilt_registry.py +++ b/tests/test_rebuilt_registry.py @@ -861,10 +861,20 @@ def test_phase2_names_have_left_adopted(self): # `Exciter` last, under Brad's G6 ruling that the cost gate is a # real-time ceiling, 80 % of a stereo block, which all three are # inside on both boards at every shipped patch. `drive.py` is - # deleted, so ADOPTED is empty again and the substitution machinery - # stays for Flanger and the phases to come. - self.assertEqual(rebuilt.ADOPTED, ()) + # deleted, so ADOPTED emptied and the substitution machinery stayed + # for Flanger and the phases to come. Phase 5's first two, + # `DigitalDelay` and `SlapbackDelay`, were adopted on 2026-09-28 on + # Brad's rulings, and are served from here until they come home. + # Phase 5's last six followed on 2026-09-29, on the board session + # at audiodsp v0.6.3. + self.assertEqual(rebuilt.ADOPTED, ( + "DigitalDelay", "SlapbackDelay", "TapeDelay", "AnalogDelay", + "PingPongDelay", "MultiTapDelay", "Reverb", "ConvolutionReverb")) self.assertEqual(rebuilt.adopted(), rebuilt.ADOPTED) + for name in rebuilt.ADOPTED: + self.assertIs(rebuilt.load(name), rebuilt.module_class(name)) + self.assertIs(getattr(audioeffects, name), + rebuilt.module_class(name)) parked = set(rebuilt.parked()) known = set(rebuilt.known()) self.assertTrue(set(FIXTURES) <= parked) diff --git a/tests/test_scheduling_seam.py b/tests/test_scheduling_seam.py index 4e1a158..a96d67f 100644 --- a/tests/test_scheduling_seam.py +++ b/tests/test_scheduling_seam.py @@ -326,8 +326,10 @@ class _Counting: """A synthesizer stand-in that can, or cannot, count refused presses. `synthio.Synthesizer.refused` arrived in audiodsp#137 and this - repository's `AUDIODSP_PIN` is older, so the engine CI runs against - cannot answer. Testing the reading against whatever engine happens to be + repository's `AUDIODSP_PIN` was older when this was written, so the + engine CI ran against could not answer (the pin passed #137 on + 2026-09-27; the reasoning holds either side of it). Testing the + reading against whatever engine happens to be installed would mean this file proves one thing today and the other thing after the pin moves, and says nothing either way -- so the subject is a stand-in with the property and one without it. diff --git a/tests/test_sequencer.py b/tests/test_sequencer.py index 01e51aa..46593bf 100644 --- a/tests/test_sequencer.py +++ b/tests/test_sequencer.py @@ -275,8 +275,9 @@ def test_start_again_picks_the_groove_up_where_it_was(self): class _Track: """A real instrument with the refusal count answered by hand. - `health()` has to carry a number the engine CI runs against cannot - produce -- `synthio.Synthesizer.refused` is newer than `AUDIODSP_PIN` -- + `health()` has to carry a number the engine CI ran against could not + produce when this was written -- `synthio.Synthesizer.refused` was newer + than `AUDIODSP_PIN` until the pin passed audiodsp#137 on 2026-09-27 -- so what is proved here is the plumbing and the separation, and the numbers themselves are measured on the real pump. diff --git a/tools/measure_effect_cost.py b/tools/measure_effect_cost.py index c510079..6704136 100644 --- a/tools/measure_effect_cost.py +++ b/tools/measure_effect_cost.py @@ -49,6 +49,31 @@ Read the marginal column when comparing two nodes, and the rt column when asking whether a chain fits in real time. +THE CONTROL, ONCE `audioeffects` IS IMPORTED +-------------------------------------------- +The node rows import nothing, so their control is taken on a small heap. +An effect target imports `audioeffects` (in `prime()`, before the control), +and the control then costs more: on the boards the probe alone went from +0.20 to 0.60 ms per block on the P4 and from 0.41 to 1.05-1.14 ms on the S3 +(board-test-plan.md, sections 2 for DigitalDelay and SlapbackDelay), and on +desktop MicroPython from 0.0019 to 0.0118 ms with the live heap at 60 KB +and then 480 KB. That is the collector's work growing with the heap, and it +lands on the control and the target alike. On the desktop and the P4 the +subtraction cancels it. On the S3 it does not: with the class module +imported, the `FeedbackDelay@options` row's marginal read 0.638 ms against +0.781 without, so a class's marginal and the palette row it was priced +from were not measured in the same conditions. + +So an effect target is measured BESIDE its palette row by default: the row +first, in the clean VM before anything imports `audioeffects` (its clean +marginal, the figure the cost table holds), then again in the same session +as the target, once before it and once after. The report adds the +same-conditions figure, the row's clean marginal plus the target's ms per +block minus the row's in the same session, which is the correction the board +runs made by hand. `BESIDE` names each class's row; `main(target, beside= +"")` names another, and `beside=False` skips it. A class with no +row named gets the old report and a line saying so. + THE DIGEST ---------- One sha256 per run, over the first 128 blocks (683 ms) the target renders from @@ -582,6 +607,31 @@ def _feedback_delay_options(probe): return node, (), (node, shape) +def _feedback_delay_09(probe): + """The base row's node and settings at feedback 0.9, where audiodsp#154's + rounding rule fires: at 0.45 it never does, so the two rows above render + the same bytes at v0.6.1 and v0.6.2 by design. Ran on both boards on + 2026-09-27 as a probe beside the tool (`fb09.py`, digest + `3b76320cb19450a8` at v0.6.2, `d9fb49a51cd7379a` at v0.6.1).""" + import audioecho + node = audioecho.FeedbackDelay(sample_rate=SAMPLE_RATE, + channel_count=CHANNELS, max_delay_ms=250) + node.set(delay_ms=180.0, feedback=0.9, mix=0.5) + node.play(probe.output) + return node, (), (node,) + + +def _feedback_delay_options_damping(probe): + """The +options row with the loop low-pass in circuit (`damping_hz` + 5000). Neither +options nor the base row turns a loop filter on, so no + row priced the one that `DigitalDelay`'s and `SlapbackDelay`'s patch 5 + carry (Repeat Tone, Tone). The corner does not change the work per + sample; 5000 is a value every leg holds exactly.""" + output, extras, keep = _feedback_delay_options(probe) + output.set(damping_hz=5000.0) + return output, extras, keep + + # --- the rate path the drive family prices from ---------------------------- # # Phase 4 added these. `Bitcrusher` holds two `audiospeed.SpeedChanger` nodes @@ -884,6 +934,10 @@ def _dynamics_transient(probe): "audioshaper.Waveshaper@x8": _waveshaper(8), "audioecho.FeedbackDelay@options": _feedback_delay_options, "audiodynamics.Dynamics@options": _dynamics_options, + # Phase 5: the row that can show audiodsp#154, and the loop low-pass. + "audioecho.FeedbackDelay@fb0.9": _feedback_delay_09, + "audioecho.FeedbackDelay@options+damping": + _feedback_delay_options_damping, # The rate path, Phase 4. Both legs of Bitcrusher's pair, at its rates. "audiospeed.SpeedChanger": _speed_changer(48000.0 / STAND_RATE_HZ), "audiospeed.SpeedChanger@up": _speed_changer(STAND_RATE_HZ / 48000.0), @@ -1034,6 +1088,29 @@ def build(probe): return build +#: The palette row each class's cost budget was priced from, which `main()` +#: measures beside it (see "THE CONTROL" above). From the classes' own +#: docstrings and dossiers; a class not named here gets no beside row. +BESIDE = { + "DigitalDelay": "audioecho.FeedbackDelay@options", + "SlapbackDelay": "audioecho.FeedbackDelay@options", + "Flanger": "audioecho.FeedbackDelay@options", +} + + +def _class_name(target): + """The class a target names, or `None` for `source` and node rows, + read from the string alone so nothing is imported to answer it.""" + if target == "source" or target.startswith("node:") or target in NODES: + return None + for prefix in ("effect:", "rebuilt:"): + if target.startswith(prefix): + target = target[len(prefix):] + for mark in ("@", "#"): + target = target.partition(mark)[0] + return target + + def resolve(target): """`target` -> a builder, or raise with something readable. @@ -1359,7 +1436,7 @@ def digests(*targets): print("DIGESTS DONE") -def main(target=None): +def main(target=None, beside=None): if target is None: catalogue() return None @@ -1372,12 +1449,33 @@ def main(target=None): BLOCK_SECONDS * 1000.0)) build = resolve(target) + if beside is None: + beside = BESIDE.get(_class_name(target)) + row_build = resolve("node:" + beside) if beside else None + + row_clean = None + if row_build is not None: + # Before anything imports `audioeffects`: the row as the cost table + # priced it. A node row imports nothing, so this VM is still clean. + print("clean control, before the import ", end="") + clean_control = run(_source, False) + print("clean %s " % beside, end="") + row_clean = run(row_build, False) + row_clean["marginal"] = (row_clean["ms_per_block"] + - clean_control["ms_per_block"]) prime(build) print("control (probe source alone) ", end="") control = run(_source, False) + row_before = row_after = None + if row_build is not None: + print("beside (%s) " % beside, end="") + row_before = run(row_build, False) print("target (%s) " % target, end="") measured = run(build, True) + if row_build is not None: + print("beside (%s) " % beside, end="") + row_after = run(row_build, False) print("") print("%-36s %10s %9s %10s %10s" @@ -1391,6 +1489,24 @@ def main(target=None): marginal = measured["ms_per_block"] - control["ms_per_block"] print("%-36s %10s %9s %10.3f" % ("marginal (target - control)", "", "", marginal)) + corrected = None + if row_clean is not None: + row_ms = (row_before["ms_per_block"] + row_after["ms_per_block"]) / 2 + corrected = row_clean["marginal"] + measured["ms_per_block"] - row_ms + print("") + print("beside %s:" % beside) + print(" clean marginal, before the import %10.3f" + % row_clean["marginal"]) + print(" ms/block in this session, before/after %10.3f / %.3f" + % (row_before["ms_per_block"], row_after["ms_per_block"])) + print(" same-conditions marginal (clean row + target - row) %.3f" + % corrected) + elif _class_name(target) is not None and beside is not False: + print("") + print("no palette row named for %s: the marginal above subtracts a " + "control taken after the import (see THE CONTROL); pass " + "beside= to measure it beside its row" + % _class_name(target)) print("") print("budget: %.3f ms/block is real time; this target uses %.0f%% of it" % (BLOCK_SECONDS * 1000.0, @@ -1442,6 +1558,12 @@ def main(target=None): % (target, measured["blocks_per_s"], measured["rt"], measured["ms_per_block"], control["ms_per_block"], marginal, measured["bytes"], measured["digest"])) + if corrected is not None: + print("BESIDE\t%s\t%s\t%.3f\t%.3f\t%.3f\t%.3f\t%.3f" + % (target, beside, row_clean["marginal"], + row_before["ms_per_block"], row_after["ms_per_block"], + measured["ms_per_block"], corrected)) + measured["same_conditions_marginal"] = corrected return measured