Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
name: CI

# Dedup: push runs only on main, so a PR branch gets exactly one run set
# (the pull_request events) and its merge box never shows the cancelled
# twin a SHA-keyed cancellation scheme would leave attached to the head
# commit. A branch with no PR yet runs CI via workflow_dispatch — the
# pre-PR baseline-harvest flow (scripts/icount.py, PLAN section 7) — or
# simply by opening the PR first and harvesting from its run.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# Superseded-run cancellation: a new push to the same PR (or to main)
# cancels the previous in-flight run. Those cancelled checks attach to the
# old commit, so the current head stays clean.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
build-test:
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@ name: Tap House Style
# pre-commit hook; this adds (1) a drift check against the canonical TapHouse
# configs and (2) clang-tidy naming + mandatory-braces enforcement over this
# repo's own translation units (scripts/tidy.sh is the local mirror).
on: [push, pull_request]
# Same dedup scheme as ci.yml: PR branches run on pull_request events only,
# main runs on push, superseded in-flight runs get cancelled per PR/ref.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
drift:
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(RatioTap VERSION 0.2.0 LANGUAGES CXX)
project(RatioTap VERSION 0.3.0 LANGUAGES CXX)

# ==============================================================================
# RatioTap — synchronous 44.1 <-> 48 kHz sample rate conversion, as fast as
Expand Down
54 changes: 41 additions & 13 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,16 @@ side of the ASRC:

## 4. Profiles

Two quality tiers behind one design path, named in the family vocabulary:
Four quality tiers behind one design path, named in the family vocabulary
(ladder re-pinned 2026-08-07, v0.3: the 18 kHz design became `economy` and
the default; the former economy design continues unchanged as `balanced`;
`super_economy` added as the explicit voice/comms tier):

| Profile | Stopband | Passband edge | Taps/phase (=MACs/out) down / up | Storage f32 down / up | Role |
|---|---|---|---|---|---|
| `economy()` — **default** | 70 dB | 19 kHz | **78 / 44** | 22.5 / 13.8 KiB | The speed-first default. All alias products land above 20 kHz at ≤ −71 dBFS — arithmetically confined to the ultrasonic band (see HANDOFF §4) |
| `super_economy()` | 70 dB | 16 kHz | **40 / 28** | 11.6 / 8.8 KiB | Voice/comms tier: audibly shelves the top octave (−1.4 dB at 18 kHz, −5.9 dB at 19 kHz going down) for half of balanced's MACs. Never a default — opt-in by name |
| `economy()` — **default** | 70 dB | 18 kHz | **58 / 38** | 16.8 / 11.9 KiB | The speed-first default. All alias products land above 20 kHz at ≤ −71 dBFS — arithmetically confined to the ultrasonic band (see HANDOFF §4) — at 26%/14% fewer MACs than balanced |
| `balanced()` | 70 dB | 19 kHz | **78 / 44** | 22.5 / 13.8 KiB | The v0.1–v0.2 economy design, unchanged: top of the audible band stays inside the flat passband |
| `transparent()` | 120 dB | 20 kHz | **184 / 96** | 53.2 / 30.0 KiB | Pristine/offline tier |

Storage figures are with the M7d symmetry halving (ceil(L/2) stored rows;
Expand All @@ -116,17 +121,26 @@ pinned by `PhaseTable.StorageBudgetsArePinned`); Q15 halves them again.
speed-first charter; the README must state the reasoning (the §4 argument:
nothing *can* fold below 20.1 kHz going down; images land ≥ 22.05 kHz going
up) rather than just the number, and the program-weighted measurement style
from SampleRateTap's `economy` preset applies here too.
from SampleRateTap's `economy` preset applies here too. The 18 kHz edge is
the same species of inaudible trade that put economy at 19 kHz rather than
transparent's 20: the 18–19 kHz shelf moves into the transition band
(measured −1.4 dB at 19 kHz going down, −0.5 dB going up). Content that
must keep that shelf flat pairs with `balanced`.

Numbers pinned by the M2 design spike (`notebooks/design_spike.ipynb`,
executed and committed; enforced in CI by `test_design.cpp`): taps are the
minimal even counts meeting the stopband with ≥ 1 dB margin. Measured
worst-case stopband on the shipping designs: economy −72.1 dB (down) /
−72.8 dB (up); transparent −121.7 dB (both). Passband ripple ±0.003 dB
(economy) / ±0.00001 dB (transparent). Q15 tables halve the storage. The
designs additionally normalize every polyphase branch's DC sum to exactly
1.0 (kills fs_out/L-harmonic spurs from DC/LF energy; lets fixed-point
row-sum quantization land on format unity exactly).
executed and committed; enforced in CI by `test_design.cpp`) for
balanced/transparent, and by the 2026-08-07 ladder re-pin for
super_economy/economy: taps are the minimal even counts meeting the
stopband with ≥ 1 dB margin on a fine (12.5 Hz) sweep grid. Measured
worst-case stopband on the shipping designs: super_economy −71.7 dB (both);
economy −71.5 dB (down) / −71.7 dB (up); balanced −72.1 / −72.8;
transparent −121.7 (both). Passband ripple ±0.003 dB (the 70 dB tiers) /
±0.00001 dB (transparent). One re-pin quirk worth recording: in the up
direction at the 18 kHz passband, 40 taps *fails* the margin criterion
while 38 passes (Kaiser sidelobe peaking is non-monotonic near threshold) —
38 is a genuine sweet spot, not a typo. The designs additionally normalize every polyphase
branch's DC sum to exactly 1.0 (kills fs_out/L-harmonic spurs from DC/LF
energy; lets fixed-point row-sum quantization land on format unity exactly).

## 5. The async composition (`bluetooth_bridge`)

Expand Down Expand Up @@ -272,7 +286,15 @@ executed (it measures the shipping C++, not a Python re-implementation).
the tap::dsp kernel gates. Worst residual rides inside the ±3% gate
(Hexagon down_q31 +2.7%); Arm came out slightly ahead (M33 Q31 −2.5%).

v0.1 ships at M6. Nothing in M7+ blocks it. **Status: M0–M6 complete —
v0.1 ships at M6. Nothing in M7+ blocks it. **v0.3 (2026-08-07): the
profile-ladder re-pin.** economy moved to the 18 kHz/58/38 design (the
"economy18" spec-relaxation experiment, measured through every leg: scipy
vectors regenerated, cross-validation floors re-pinned at −98/−90 dB,
Q15 flagship unchanged at 76.5 dB, storage −25%/−14%); the former economy
became `balanced`, unchanged; `super_economy` (16 kHz, 40/28) joined as
the explicit voice/comms tier with its own scipy vectors and two Q15
icount scenarios; icount baselines re-recorded for the six economy
workloads and recorded for the two new super_economy ones. **Status: M0–M6 complete —
v0.1 shipped (2026-07-23). M7 codegen phase complete — v0.2 (2026-07-24):
M7a measurement harness, M7b superblock codegen, M7c committed trip
counts, M7d symmetry halving, all measured, outputs bit-identical
Expand All @@ -288,7 +310,13 @@ since double accumulation is the float golden model's identity).**

## 8. Acceptance criteria (v0.1)

All numbers pinned (M2 design spike, 2026-07-23).
All numbers pinned (M2 design spike, 2026-07-23). *These are the v0.1
acceptance records: "economy" below refers to the 19 kHz design that is
`balanced()` as of the v0.3 ladder re-pin (§4). The v0.3 economy's numbers:
worst stopband −71.5/−71.7 dB, 997 Hz imaging floor ~91 dB (float) /
76.5 dB (Q15), cross-validation floors 1.2e-5 down / 3.1e-5 up, latency
29 smp (0.60 ms) down / 19 smp (0.43 ms) up — every contract bound below
still holds on the new default, re-measured in the same test batteries.*

- `economy`, both directions: every spurious product ≥ **71 dB below the
source content** (design floors: −72.1 dB down, −72.8 dB up). Two species,
Expand Down
41 changes: 24 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,26 @@ on the Tap family's shared FIR substrate
float/Q15/Q31 sample-format traits, measured dot-product kernels, row-sum
quantization, measurement instruments).

> **Status: v0.2 (M7 codegen campaign).** v0.1 shipped the converter for
> all three sample formats: float (the golden model, pinned against
> committed scipy reference vectors sample-for-sample), Q31 (tracks float
> within −147 dB), and Q15 (format-limited: pair it with `economy`, which
> is both cheaper *and* quieter than `transparent` at 16 bits), plus the
> golden cross-validation against SampleRateTap's async engine (−109 dB
> down / −99 dB up over every phase), the `bluetooth_bridge` example, the
> C ABI, and the executed demo notebook. v0.2 is the measured optimization
> campaign on top — four levers, each gated by the instruction-count
> ratchet, outputs bit-identical throughout: the superblock walk,
> committed compile-time trip counts, and symmetry-halved tables. Since
> the campaign's baselines: **Q15 −59%/−60% and float −35%/−37% on
> Cortex-M55, Q31 −26%/−27% on Cortex-M33, Q15 −13%/−10% on Hexagon —
> with table storage halved** (economy Q15 up: 6.9 KiB). The remaining
> PLAN §7 levers (multistage, minimum-phase, IIR, FFT) change the output
> contract and stay deferred until a consumer needs them.
> **Status: v0.3 (profile-ladder re-pin).** The default `economy` profile
> moved to an 18 kHz passband at **58/38 taps — 26%/14% fewer MACs and
> −25%/−14% storage** than the previous default, with every 70 dB contract
> bound re-measured and held (the 18–19 kHz shelf moves into the
> transition band; the former economy design continues unchanged as
> `balanced` for content that needs that shelf flat). v0.1 shipped the
> converter for all three sample formats: float (the golden model, pinned
> against committed scipy reference vectors sample-for-sample), Q31
> (tracks float within −147 dB), and Q15 (format-limited: pair it with
> `economy`, which is both cheaper *and* quieter than `transparent` at 16
> bits), plus the golden cross-validation against SampleRateTap's async
> engine (every phase, floor at the one deliberate design difference), the
> `bluetooth_bridge` example, the C ABI, and the executed demo notebook.
> v0.2 was the measured optimization campaign — superblock walk, committed
> compile-time trip counts, symmetry-halved tables, each gated by the
> instruction-count ratchet, outputs bit-identical throughout: **Q15
> −59%/−60% and float −35%/−37% on Cortex-M55, Q31 −26%/−27% on
> Cortex-M33, Q15 −13%/−10% on Hexagon**. The remaining PLAN §7 levers
> (multistage, minimum-phase, IIR, FFT) change the output contract and
> stay deferred until a consumer needs them.
> [PLAN.md](PLAN.md) is the authoritative roadmap (charter, architecture
> decisions, milestones, acceptance criteria, per-lever measurements);
> [HANDOFF.md](HANDOFF.md) is the original design brief it grew from.
Expand All @@ -39,6 +43,9 @@ quantization, measurement instruments).
#include <tap/ratio/ratio.h>

tap::ratio::converter_to_44k1 down(2); // 48 -> 44.1, stereo, economy
// profiles: economy() (default, 18 kHz passband) | balanced() (19 kHz,
// the pre-v0.3 default) | transparent() (120 dB pristine tier) |
// super_economy() (16 kHz voice/comms tier — audible top-octave shelf)
std::vector<float> out(down.outputs_for(n_in) * 2);
std::size_t made = down.process(in, n_in, out.data()); // noexcept, alloc-free
// ... and at end of stream:
Expand All @@ -52,7 +59,7 @@ callback-driven shape, and `frames_needed(n)` is exact arithmetic. For
44.1↔48 across *independent clocks* (a Bluetooth chip on its own crystal),
compose with SampleRateTap — `examples/bluetooth_bridge.cpp` is the
documented recipe: +200 ppm crystal, servo locked, 997 Hz recovered
exactly, 2.0 ms total latency.
exactly, 1.9 ms total latency.

## The boundaries are identity, not policy

Expand Down
54 changes: 30 additions & 24 deletions bench/baselines.json
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
{
"hexagon": {
"down_float_eco": 408799302,
"down_float_tr": 967322580,
"down_q15_eco": 69795923,
"down_q31_eco": 67717362,
"up_float_eco": 253482716,
"up_float_tr": 551268731,
"up_q15_eco": 43962582,
"up_q31_eco": 43918255
"down_float_eco": 313564771,
"down_float_tr": 979598163,
"down_q15_eco": 54576857,
"down_q15_se": 39378689,
"down_q31_eco": 54524022,
"up_float_eco": 226581827,
"up_float_tr": 553818763,
"up_q15_eco": 42340774,
"up_q15_se": 32106712,
"up_q31_eco": 42358271
},
"m33": {
"down_float_eco": 2405598841,
"down_float_tr": 5786890466,
"down_q15_eco": 319593940,
"down_q31_eco": 421757767,
"up_float_eco": 1483417327,
"up_float_tr": 3287871636,
"up_q15_eco": 208462743,
"up_q31_eco": 272069248
"down_float_eco": 1791425947,
"down_float_tr": 5772164016,
"down_q15_eco": 244450483,
"down_q15_se": 179742418,
"down_q31_eco": 315684738,
"up_float_eco": 1282189491,
"up_float_tr": 3280145822,
"up_q15_eco": 184359117,
"up_q15_se": 140538142,
"up_q31_eco": 235400465
},
"m55": {
"down_float_eco": 96760015,
"down_float_tr": 218246138,
"down_q15_eco": 74152380,
"down_q31_eco": 126978624,
"up_float_eco": 62737997,
"up_float_tr": 127311142,
"up_q15_eco": 49644781,
"up_q31_eco": 82296302
"down_float_eco": 74885193,
"down_float_tr": 219532760,
"down_q15_eco": 58251264,
"down_q15_se": 47710153,
"down_q31_eco": 97748073,
"up_float_eco": 56229455,
"up_float_tr": 128890219,
"up_q15_eco": 45305070,
"up_q15_se": 39607853,
"up_q31_eco": 73399258
}
}
7 changes: 5 additions & 2 deletions bench/icount/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
# targets have no argv, and per-binary instruction totals are what the
# ratchet compares. Economy is the speed-first default the M7 levers are
# judged on, in every format and both directions; the two transparent legs
# keep the pristine profile honest without tripling the matrix.
# keep the pristine profile honest without tripling the matrix, and the two
# Q15 super_economy legs pin the voice tier in its deployment format.
set(_ratio_icount_scenarios
up_float_eco:0:0:0:2
down_float_eco:1:0:0:2
Expand All @@ -11,7 +12,9 @@ set(_ratio_icount_scenarios
up_q31_eco:0:2:0:2
down_q31_eco:1:2:0:2
up_float_tr:0:0:1:2
down_float_tr:1:0:1:2)
down_float_tr:1:0:1:2
up_q15_se:0:1:3:2
down_q15_se:1:1:3:2)

foreach(_sc IN LISTS _ratio_icount_scenarios)
string(REPLACE ":" ";" _parts "${_sc}")
Expand Down
9 changes: 7 additions & 2 deletions bench/icount/icount_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
//
// RATIO_SC_DIR: 0 = up (44.1 -> 48), 1 = down (48 -> 44.1)
// RATIO_SC_TYPE: 0 = float, 1 = Q15, 2 = Q31
// RATIO_SC_PROFILE: 0 = economy, 1 = transparent
// RATIO_SC_PROFILE: 0 = economy, 1 = transparent, 3 = super_economy
// (matching the C ABI tags; 2 = balanced unused here)
// RATIO_SC_CH: channel count (default 2)
// SPDX-License-Identifier: MIT
// Copyright 2026 Timothy Place and the RatioTap contributors.
Expand Down Expand Up @@ -66,8 +67,12 @@ namespace {
#endif
#if RATIO_SC_PROFILE == 0
const tap::ratio::profile k_prof = tap::ratio::profile::economy();
#else
#elif RATIO_SC_PROFILE == 1
const tap::ratio::profile k_prof = tap::ratio::profile::transparent();
#elif RATIO_SC_PROFILE == 2
const tap::ratio::profile k_prof = tap::ratio::profile::balanced();
#else
const tap::ratio::profile k_prof = tap::ratio::profile::super_economy();
#endif
constexpr std::size_t k_ch = RATIO_SC_CH;
constexpr std::size_t k_block = 32;
Expand Down
14 changes: 11 additions & 3 deletions include/tap/ratio/converter.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ namespace tap::ratio {
/// The canonical trip counts (taps per phase = MACs per output) the
/// hot path hard-commits to at compile time; any other profile runs
/// the runtime-length walk.
static constexpr std::size_t k_taps_economy = profile::economy().taps<D>();
static constexpr std::size_t k_taps_transparent = profile::transparent().taps<D>();
static constexpr std::size_t k_taps_economy = profile::economy().taps<D>();
static constexpr std::size_t k_taps_super_economy = profile::super_economy().taps<D>();
static constexpr std::size_t k_taps_balanced = profile::balanced().taps<D>();
static constexpr std::size_t k_taps_transparent = profile::transparent().taps<D>();

/// Allocates histories and designs the table; setup time only.
explicit basic_converter(std::size_t channels = 1, const profile& p = profile::economy())
Expand Down Expand Up @@ -103,7 +105,7 @@ namespace tap::ratio {
/// tap::dsp kernels, and the append/emit order is identical, so
/// outputs stay bit-exact (pinned by the scipy-vector tests).
///
/// M7 lever 2 commits the trip counts: the two canonical profiles'
/// M7 lever 2 commits the trip counts: the canonical profiles'
/// taps-per-phase are compile-time facts (constexpr profile), so one
/// dispatch per call hands the walk a constant dot length — the
/// inlined kernels unroll and vectorize against it instead of a
Expand All @@ -114,6 +116,12 @@ namespace tap::ratio {
if (taps == k_taps_economy) {
return process_taps<k_taps_economy>(in, in_frames, out);
}
if (taps == k_taps_super_economy) {
return process_taps<k_taps_super_economy>(in, in_frames, out);
}
if (taps == k_taps_balanced) {
return process_taps<k_taps_balanced>(in, in_frames, out);
}
if (taps == k_taps_transparent) {
return process_taps<k_taps_transparent>(in, in_frames, out);
}
Expand Down
Loading
Loading