28 Commits
Author SHA1 Message Date
ew8bak 544e75798c Merge branch 'wfm' into cpu-optimization 2026-07-18 10:51:50 +03:00
ew8bakandClaude Fable 5 3ad40a00da calcc: port eu2av robustness patches for PureSignal calibration
Three fixes from Thetis-Enhanced (Yurij eu2av), measured on Orion MK2 /
Anvelina PRO3 hardware:

- drop overrange samples (env_TX*hw_scale > 1.0) before the cubic
  xbuilder fit - they distort the fit and produce a wrong rx_scale
- optional median-ratio + MAD outlier rejection before the fit,
  controlled by new SetPSOutlierSigma (0 = off)
- fallback rx_scale estimate from the top amplitude intervals when the
  xbuilder fit fails or is rejected by rxscheck

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 10:47:44 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 c1964817dd wfm: compile wfmd/wfmmod in the Windows build
Makefile.windows arrived from main, which predates the wfm module, so its
SOURCES list omitted wfmd.c/wfmmod.c even though RXA.c/TXA.c reference their
symbols -- the MinGW link failed on the missing objects. Add both to SOURCES,
matching Makefile and Makefile.android.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:03:03 +03:00
Uladzimir Karpenka 81f7750def Merge remote-tracking branch 'origin/main' into cpu-optimization 2026-07-15 10:50:34 +03:00
Uladzimir Karpenka d24b5ae316 Merge remote-tracking branch 'origin/main' into wfm 2026-07-15 10:50:15 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 65cb3c386e wfm: add a wideband FM modulator and demodulator
Model the pair on fmd.c / fmmod.c, but with the parts that a 75 kHz
deviation forces:

  - the demodulator discriminates with arg(x[n] * conj(x[n-1])) rather
    than a PLL; an omegaN in the tens of kHz cannot track 75 kHz.
  - emphasis is a one-pole RC (tau 75 us) on both ends, not fmd's 1/f
    fc_impulse FIR.  A 1/f FIR from f_low = 20 Hz would sit ~+57 dB at
    20 Hz, where broadcast FM specifies flat below the corner.
  - TXA_WFM leaves the shared preemph block off; wfmmod carries its own.
  - wfmmod clamps bp_fc = deviation + f_high to 0.45 * samplerate, since
    +/-90 kHz exceeds Nyquist at the rates the narrowband modes use.

Scope is mono: no 19 kHz pilot, no 38 kHz stereo subcarrier, no RDS.

Both mode enums are appended to so the ABI stays stable for clients.
The JNI bindings are deliberately left alone.

Verified against a wfmmod -> wfmd loopback: the discriminator is exact,
the dc-removal one-pole tracks |H_lp(f)| * ain * sdelta to ratio 1.000,
and a 700 + 1900 Hz two-tone comes back with THD+N ~ 3e-5 % at +/-37.5
kHz deviation.  Note the demodulator aliases unless samplerate exceeds
2 * deviation * |aud|max, so 192 kHz is the practical floor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:43:07 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 f39eea6b01 phrot, cfcomp: cascade in a register; drop idiv from cfcomp's ring walks
xphrot() kept x0[] and y0[] in the struct, but neither carried state between
samples: x0[n] was always the previous stage's output and y0[n] this stage's.
Cascade that single value in a register and leave x1/y1 as the real filter
state. Stores through out[] may alias the struct's doubles, so hoist the
coefficients as well. Eight first-order sections then run without touching
memory except for the state.

xcfcomp() is structurally the same overlap-add loop as xemnr(), and had the
same defect: four ring indices advanced with '% size' per step, ~5100 integer
divisions per call at fsize = 2048, which a profile showed dominating the
block (1820 samples in xcfcomp against 362 in calc_mask and ~520 in the FFTs).
The indices step by one, and iasize >= fsize and oasize >= incr always hold,
so they wrap at most once per loop: walk contiguous runs and wrap between them.

Measured in situ on an Apple M1 Pro, 512-sample buffers, cost of turning the
block on, best of 5:

    phrot    17219 ns -> 7047 ns   2.44x
    cfcomp   28918 ns -> 13637 ns  2.12x

Both are bit-identical: phrot preserves the operation order, and cfcomp only
changes integer index arithmetic. The RX chain is unchanged bit-for-bit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:31:32 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 91df5b1f2d varsamp: build the phase table without malloc0's memset, read h forward
calc_varsamp() gained a transpose of the coefficient table, which showed up
as ~0.3 ms on create_varsamp()/setInRate_varsamp() (1.0 ms -> 1.4 ms). It is
a one-off setup cost, not on the sample path, but it is easy to trim: every
element of hp is written, so malloc0()'s memset of ~1 MB is dead, and h is
cold straight out of fir_bandpass(), so walk it along its fast axis and let
the prefetcher work.

Output is bit-identical; xvarsamp() is unchanged at ~34 us/buffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:13:27 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 12dc701604 analyzer: block the dispatcher on a semaphore, stop spawning a thread per frame
Two pieces of pure overhead, neither of them doing any DSP.

The POSIX QueueUserWorkItem() shim spawned a thread and immediately joined
it. That is a synchronous call -- the parallelism the Windows thread pool
provides is absent here either way -- so it only bought a pthread_create()
and its stack mmap, ~15 us, for every FFT frame. Call the function directly.
(The Windows build is untouched and still gets its thread pool; a real pool
for POSIX would be a separate change, and only pays off for num_stitch > 1.)

The dispatcher thread ran `for (each ss, LO) {...} Sleep(1);`, and Sleep(1)
is usleep(1000), so it woke 1000 times a second to re-read the same flags.
That burned 0.63% of a core even with no samples arriving. Give it a
semaphore instead, signalled by the four Spectrum*() entry points when new
samples land, and by SetAnalyzer/DestroyAnalyzer after they raise
end_dispatcher so the blocking wait always has a way out. SetAnalyzer holds
SetAnalyzerSection while it waits for the dispatcher to quit, and the
dispatcher never takes that section, so signalling from under it is safe.

Measured on an Apple M1 Pro; 16384-point complex FFT, 1024-sample buffers,
~40 pixel frames/s, CPU of all threads via getrusage:

    idle, no samples at all      0.63% of a core  ->  0.00%
    under load                   3.54%            ->  2.65%

Output pixels are bit-identical. Stressed with 3 create/destroy cycles and
24 on-the-fly SetAnalyzer reconfigurations while a second thread fed samples:
no deadlock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:59:20 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 645cbbb2d1 varsamp: transpose the coefficients into phases, vectorize the tap loop
hshift() rebuilds the whole interpolated tap set on every output sample,
reading h[hidx + m*R] for m = 0..rsize-1. With R = 1024 (what rmatch asks
for) that strides 8 KB at a time through a 1.1 MB table, so every one of
the 2*rsize reads is its own cache line. It cost more than the filter it
was feeding: 258 ns per output sample against 146 ns for the tap loop.

Store the coefficients transposed instead, hp[p*rsize + m] = h[p + m*R],
so the two phases hshift() interpolates between are each contiguous. R+1
phases are needed since it reads hidx and hidx+1, and h_offset is kept in
[0,1) by the caller so hidx <= R-1. The untransposed h is freed; the
impulse cache hands back a copy, so varsamp owns it. Net memory is
unchanged.

The tap loop had the same wrap test per tap as resample.c did, so split it
at the wrap and carry four independent accumulator pairs; the ring is split
into I/Q so the taps load unit-stride.

Note a->hs is rewritten by hshift() inside the sample loop, so it must not
be hoisted behind a restrict pointer in xvarsamp().

Measured on an Apple M1 Pro, 512-sample buffers, best of 5:

    48k -> 48k   varmode=0    138580 ns -> 37370 ns   3.71x
    48k -> 48k   varmode=1    138390 ns -> 32333 ns   4.28x
    48k -> 44.1k varmode=1    141473 ns -> 38220 ns   3.70x
    44.1k -> 48k varmode=1    152307 ns -> 45263 ns   3.36x

hshift() is numerically identical -- same coefficients, different layout.
Only the reassociated tap sum rounds differently: worst deviation 7.2e-16
over four rate configurations, an SNR of 344 dB. Driven end to end through
rmatch's public API, output SNR is 306 dB and total energy matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:19:36 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 fd2ba84e7d emnr: linear-time aepf, drop idiv from ring walks, halve getKey's logs
Three separate costs, found with a sampling profile of the RX chain with
emnr forced on:

aepf() averaged mask[] over a window of N = 2*psi + 1 = 41 bins by walking
the window for every one of the 2049 bins, i.e. O(msize*N). Its three spans
are all symmetric windows clipped at the array ends, so take each from a
prefix sum instead: one subtraction per output, O(msize).

xemnr() advanced four ring indices with a '% size' per step. iasize is 4096
and oasize 1024 here, and neither is known to the compiler, so each step was
a real integer division -- ~8700 of them per frame. The indices step by one
and, since iasize >= fsize and oasize >= incr always hold, wrap at most once
per loop, so walk contiguous runs and wrap between them.

calc_gain() called getKey() twice per bin with the same gamma, so the gamma
row index and its log10 were computed twice. Split getKey into keyIndex() +
keyLerp() and locate gamma once. The remaining logs go through wdsp_log10()
(new fastmath.h), accurate to 2e-13 against libm and ~2.4x its throughput;
gamma and xi are bracketed against the table limits first, so the argument
is always positive and normal. Also clamp the row index so the second
bilinear corner cannot address the next row of the 241x241 table.

Measured in situ on an Apple M1 Pro, 512-sample buffers, cost of turning
emnr on, best of 5:

    baseline                       73661 ns
    + aepf, ring walks             48927 ns   1.51x
    + getKey                       37852 ns   1.95x

Output is not bit-identical, as the prefix sum and the reassociated logs
round differently. Over 300 buffers with emnr alone the worst deviation is
4.0e-09, an SNR of 196 dB; perturbing a single input sample of the unmodified
code by one ulp diverges it from itself by 1.4e-08 (186 dB), so this change
disturbs the chain less than the last bit of the input does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:08:20 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 3faad14fc1 anr, anf: vectorize the LMS tap and update loops
Both loops indexed the delay line as (in_idx + j + delay) & mask, one
masked index per tap, which made the address non-affine and stopped the
vectorizer. The window wraps at most once, so split it at the wrap and
walk two contiguous runs instead.

As in resample.c, the y/sigma reduction cannot be reassociated without
-ffast-math (which this library must not enable, see linux_port.h), so
carry four independent accumulator pairs to break the FMA dependency
chain and let the vectorizer in.

in_buff and out_buff alias in RXA -- both are midbuff -- so only the
private d/w arrays are marked restrict.

Measured in situ on an Apple M1 Pro, 512-sample buffers, cost of turning
the block on, best of 5:

    anr    59303 ns -> 20253 ns   2.93x
    anf    55073 ns -> 20511 ns   2.68x

Summation order changes, so output is not bit-identical: over 300 buffers
of the full RX chain the worst deviation is 2.4e-07, an SNR of 153 dB.
An LMS filter is an adaptive feedback loop, so its trajectory is
chaotic. As a control, perturbing a single input sample of the unmodified
code by one ulp diverges it from itself by 6.6e-07, an SNR of 144.6 dB --
i.e. this change disturbs the filter less than the last bit of the input
does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:52:33 +03:00
Uladzimir KarpenkaandClaude Opus 4.8 9960a1d0e3 resample: vectorize the polyphase tap loop
The tap loop wrapped the ring with a test on every tap:

    if ((idx_out = idx_in + j) >= ringsize) idx_out -= ringsize;

which made the address non-affine and stopped the vectorizer. Split the
walk at the wrap point instead, so both halves are unit-stride, and split
the complex ring into separate I/Q arrays so the taps load contiguously
rather than through a de-interleaving ld2.

The dot product also could not be vectorized as written: reassociating an
fp reduction needs -ffast-math, which this library must not enable (it
relies on IEEE semantics for 0/0 = NaN and x/0 = Inf, see linux_port.h).
Carry four independent accumulator pairs instead, which both breaks the
FMA dependency chain and lets the vectorizer in on any compiler.

Measured on an Apple M1 Pro, 512-sample DSP buffers, best of 5:

  xresample, decimation to 48 kHz     before      after    speedup
    192k -> 48k  (561 taps)          342.0 us    84.2 us     4.06x
    384k -> 48k  (1121 taps)         708.9 us   175.1 us     4.05x
    576k -> 48k  (1681 taps)        1074.4 us   266.9 us     4.03x
    768k -> 48k  (2241 taps)        1438.9 us   360.0 us     4.00x

  full xrxa() chain, 576k input      1148.2 us   337.6 us     3.40x
                                     10.76%       3.16%   of one core

  xresampleF (float, host audio)                          2.6x - 3.4x

Summation order changes, so the double path is not bit-identical: over
400 buffers of the full RX chain the worst deviation is 1.1e-12, an SNR
of 251 dB. The float path is bit-identical, as the cast to float absorbs
the difference. With the resampler bypassed (48k in, 48k out) the chain
is unchanged bit-for-bit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:44:08 +03:00
ew8bak bb7e0b3df6 add gitignore third_party 2026-06-12 21:44:25 +03:00
ew8bakandClaude Sonnet 4.6 4313006fa6 Auto-detect Android NDK and javac paths in Makefile.android
Remove hardcoded /home/vladimir and /opt paths. NDK is now resolved via
ANDROID_NDK_HOME, ANDROID_SDK_ROOT/ANDROID_HOME, or the default SDK
location for Linux/macOS (latest version picked automatically). javac is
resolved via JAVA_HOME, system PATH, or Android Studio JBR on Linux/macOS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:31:15 +03:00
ew8bakandClaude Sonnet 4.6 4f44118299 Remove committed build artifacts from third_party and fix Android FFTW cross-compile
- Untrack all .o and .a files under third_party/ (rnnoise, libspecbleach)
- Add third_party/**/*.o and third_party/**/*.a to .gitignore
- Fix FFTW configure cross-compilation: add $(strip ...) around fftw_host
  macro call and explicit --build flag so configure detects cross-compile
  correctly (GNU make backslash-continuation in define blocks inserts a
  leading space, causing --host= arm-... to be parsed as empty --host)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:27:13 +03:00
ew8bakandClaude Sonnet 4.6 edd8991a3c Enable AVX2 for FFTW Windows build to suppress SSE2-only warning
FFTW emits '#warning Only SSE and SSE2 are available' when cross-compiling
with a generic x86_64 target: the configure test for AVX intrinsics fails
without an explicit -mavx flag, so FFTW silently falls back to SSE2.

Fix: pass CFLAGS="-march=haswell" to configure, which enables SSE2/AVX/AVX2/FMA.
Add --enable-avx2 alongside existing --enable-avx.
FFTW_MARCH is overridable for older CPU targets:
  make -f Makefile.windows FFTW_MARCH=-march=sandybridge  # AVX only, 2011+
  make -f Makefile.windows FFTW_MARCH=-march=core2        # SSSE3, 2007+

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:39:24 +03:00
ew8bakandClaude Sonnet 4.6 a3385bd1f9 Fix parallel build race: DLL link must wait for fftw-float install
With -jN, the DLL link step could start before fftw-float finished
installing libfftw3f.dll.a, since $(DLL) only had an implicit ordering
through .o compilation (which needs FFTW_LIB_D but not FFTW_LIB_F).

Add both FFTW_LIB_D and FFTW_LIB_F as explicit prerequisites of $(DLL).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:35:04 +03:00
ew8bakandClaude Sonnet 4.6 b7d156ea40 Compile rnnoise and libspecbleach directly into obj_win/ for Windows
Sub-make of third_party/ reported 'up to date' when Linux ELF objects
were present from a prior host build, causing the linker to receive
ELF archives instead of PE/COFF and producing undefined reference errors.

Fix: compile rnnoise and libspecbleach sources directly into obj_win/
with explicit pattern rules, producing lib_win/librnnoise.a and
lib_win/libspecbleach.a. These are fully isolated from third_party/
and never conflict with the Linux build artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:17:32 +03:00
ew8bakandClaude Sonnet 4.6 d997990ceb Fix two Windows cross-compilation errors in analyzer.c
WDSP_FPE_GUARD: the macro is defined only in linux_port.h under
#if defined(linux)||defined(__APPLE__). On the _WIN32 path it was
completely undefined. Added a fallback no-op definition in comm.h
guarded by #ifndef so it applies to Windows (and any future platform
that doesn't include linux_port.h).

volatile int* vs volatile LONG*: Win32 Interlocked functions expect
volatile LONG* (= volatile long*). The dispatcher field is volatile int.
On Windows LLP64 both are 32-bit so the operation is correct, but GCC 14
promotes this mismatch from warning to error. Suppressed with
-Wno-incompatible-pointer-types in Makefile.windows, consistent with
how MSVC handles it silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:54:56 +03:00
ew8bakandClaude Sonnet 4.6 a77dbbb6e5 Fix Windows.h case on Linux: use lowercase windows.h
On Linux (case-sensitive filesystem) MinGW-w64 installs the header as
windows.h (lowercase) while comm.h included <Windows.h> (capital W),
causing a fatal compile error when cross-compiling for Windows.

Changed to <windows.h> which works on both Linux/MinGW-w64 and native
Windows/MSVC (Windows header includes are case-insensitive on NTFS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:43:45 +03:00
ew8bakandClaude Sonnet 4.6 90c906eb3a Fix missing Windows.h: require full mingw-w64 package, not just compiler
gcc-mingw-w64-x86-64 on Debian/Ubuntu ships only the compiler binary,
without Windows API headers — causing fatal error: Windows.h not found.
The fix is to install the mingw-w64 meta-package which includes headers,
CRT and runtime libraries.

Added a check-tools probe that compiles #include <windows.h> and prints
a clear error with the correct package name if headers are missing.
Updated README with a warning about the incomplete package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:33:13 +03:00
ew8bakandClaude Sonnet 4.6 f29970542b Pass CC explicitly to FFTW configure to suppress cross-tools warning
Without CC=, autoconf issues "using cross tools not prefixed with host
triplet" because it cannot match the detected compiler against --host.
Passing CC=$(MINGW_PREFIX)-gcc directly resolves the ambiguity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:26:39 +03:00
ew8bakandClaude Sonnet 4.6 69ea631daa Fix parallel build race condition in Makefile.windows
With -jN, make started third-party and fftw targets in parallel.
libspecbleach depended on FFTW_HEADER which has no explicit rule
(it is a side effect of make install), causing 'No rule to make target'.
Object compilation also could start before FFTW headers were ready.

Fix: depend on FFTW_LIB_D (has an explicit rule) instead of FFTW_HEADER,
and add FFTW_LIB_D as order-only prerequisite for .o compilation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:24:39 +03:00
ew8bakandClaude Sonnet 4.6 02926b36ce Auto-download FFTW source for Android build
Makefile.android now downloads fftw-3.3.11.tar.gz automatically instead
of requiring the user to place sources in third_party/fftw/ manually.
The FFTW_SRC variable can still be overridden to use an existing copy.
Added distclean target. Updated README accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:15:59 +03:00
ew8bakandClaude Sonnet 4.6 5bbf9698a8 Build FFTW from source instead of downloading pre-built DLLs
FFTW 3.3.11 has no pre-built Windows binaries, so Makefile.windows now
downloads the source tarball and cross-compiles it twice with MinGW-w64:
once for double precision and once for float (--enable-float), installing
both into third_party/fftw-win64/. Links with -lfftw3/-lfftw3f via
libtool-generated import libs instead of dlltool-generated ones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:13:35 +03:00
ew8bakandClaude Sonnet 4.6 ec93fbfcbb Add Windows cross-compilation support via MinGW-w64
Makefile.windows builds libwdsp.dll and libwdsp.a for 64-bit Windows
on Linux. FFTW 3.3.11 Windows binaries are downloaded automatically
from fftw.org and import libraries are generated with dlltool.
README updated with Windows build instructions and FFTW version bump.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:46:51 +03:00
Uladzimir Karpenka 89c8a0e2b5 first commit 2026-06-01 15:58:45 +03:00