DSP 및 실시간 유틸리티

std/dsp/는 결정론적 고정소수점 산술, 스펙트럼 변환(DFT/FFT/DCT/STFT/CQT/CWT), 필터(FIR/IIR/biquad/comb), 컨볼루션, 리샘플링, 최소 위상 재구성, 일반적인 라플라스-Z(쌍선형) 변환 도구, 2D/이미징 프리미티브, 오디오 버퍼링, 실시간 타이머 스케줄링을 아우릅니다 — 총 20개의 모듈이며, 모든 필터와 생성기가 만족하는 스트리밍 트레이트도 함께 제공합니다.

c
#include <std/dsp/dsp_all.h>  // master header: pulls in every module below

타입은 정적 메서드로 만들고(Biquad::lowpass(...), FirFilter::new(...), Complex::new(...); namespace std 밖에서는 std::Biquad::lowpass(...)), 그 타입에 대한 연산은 메서드입니다(f.process_block(in[0..n], out[0..n])).

의미 있는 고정소수점 형태를 갖는 모든 변환/필터는 두 가지 구현을 제공합니다 — double 정밀도 경로와, 이에 대응하는 Q8.24(Fixed) 경로(함수/타입 이름에 f 접두어가 붙습니다, 예: fft/ffft, struct Biquad/struct FBiquad) — 하나의 일관된 설계를 따릅니다: 한 번만 계산하면 되는 계수(FFT 트위들 팩터, 필터 계수)는 항상 std/math.h의 실제 초월함수를 사용해 double 정밀도로 유도된 다음 한 번에 Fixed로 양자화되고, 실제 샘플 단위 신호 처리 루프는 전부 Q8.24 산술로 실행됩니다. 이는 표준적인 관행입니다 — 하드웨어 FFT 가속기조차도 트위들 테이블을 오프라인에서 부동소수점으로 미리 계산합니다. 몇몇 더 새롭고 특수한 변환(STFT, CQT, CWT, 최소 위상, 이미징)은 double 전용입니다: 가우시안 윈도우 웨이블릿 커널이나 준동형 켑스트럼에는 자연스러운 고정소수점 형태가 없습니다 — 고정소수점 sin()이 없는 것과 마찬가지입니다.

신호 처리 경로는 단정밀도(float)로도 제공됩니다. 아래 단정밀도를 참고하십시오.


단정밀도 (float)

FFT, 필터, 컨볼루션, 윈도우, STFT, 리샘플링, DCT/DFT, 이미징은 float 데이터도 받습니다. 함수 이름에는 _f32, 타입 이름에는 32가 붙습니다(Complex32, FirFilter32, Biquad32). 각 함수의 동작 규약은 double 버전과 같습니다. float는 메모리 트래픽이 절반이고, 벡터 레지스터 하나에 두 배의 값이 들어갑니다. 유효 숫자는 약 16자리 대신 약 7자리(상대 오차 약 1e-6)입니다.

  • 두 정밀도에 하나의 구현. FFT, 컨볼루션, 리샘플링, 2D 코드는 내부 코어 헤더(fft_core.h, conv_core.h, resample_core.h, imaging_core.h)에 한 번만 작성되어 있습니다. 각 코어는 fft.sc와 fft_f32.sc 등에서 정밀도마다 한 번씩, 두 번 컴파일됩니다. 따라서 float FFT도 radix-4 패스, 16점 코드렛, 캐시 블로킹, 스레드, ISA별 빌드를 그대로 갖습니다. oneMKL이 링크되면 DFTI의 단정밀도 변환을 사용합니다.
  • 정밀도가 필요한 곳은 double. 트위들 팩터와 윈도우 값은 double로 계산한 뒤 float로 한 번 반올림합니다. 필터 계수는 double로 설계하고(Biquad::lowpass 등) Biquad32::from으로 변환합니다. O(n²)인 dct2_f32/dft_f32와 resample_sinc_f32의 sinc 커널은 double로 합산하므로, 입력과 출력만 float입니다.
  • 실시간. fft_serial_f32/ifft_serial_f32는 double 버전과 마찬가지로 메모리 할당이나 잠금을 하지 않습니다. 4096점까지는 컴파일 타임 float 트위들 테이블을 읽고, 그보다 긴 길이는 fft_prepare_f32(maxN)을 먼저 호출합니다. fft_prepare는 double 테이블만 채웁니다.
c
// complex_dsp.h
struct Complex32 {
    float re; float im;
    /* + - * / neg abs arg conj, as Complex */
    static Complex32 new(float re, float im);
    static Complex32 polar(float magnitude, float theta);
    static Complex32 from(Complex c);     // round each part to float
    Complex          to_complex() const;  // back to double (exact)
};

// fft.h
int fft_f32([]Complex32 data);
int ifft_f32([]Complex32 data);
int fft_serial_f32([]Complex32 data);
int ifft_serial_f32([]Complex32 data);
int fft_prepare_f32(unsigned long n);
int fft_real_f32([]const float in, []Complex32 out);
int fft_batch_f32([]Complex32 data, unsigned long n, int inverse);   // data.len / n transforms
int fft_real_batch_f32([]const float in, []Complex32 out, unsigned long n);
int fft_real_half_batch_f32([]const float in, []Complex32 out, unsigned long n);   // and fft_real_half_f32

// dsp.h
float dsp_dot_f32([]const float a, []const float b);   // f32x8 FMA accumulators

// filter.h, biquad.h, comb.h: each filter has process(x), reset() and
// process_block(in, out) on float samples, as its double counterpart
static struct FirFilter32 FirFilter32::new([]const float coeffs, []float history);
static struct IirFilter32 IirFilter32::new([]const float b, []const float a, []float xHistory, []float yHistory);
static struct Biquad32 Biquad32::new(float b0, float b1, float b2, float a1, float a2);
static struct Biquad32 Biquad32::from(struct Biquad bq);   // a double design, rounded once
static struct CombFF32 CombFF32::new([]float history, float gain);   // delay = history.len
static struct CombFB32 CombFB32::new([]float history, float gain);
static struct KarplusStrong32 KarplusStrong32::new([]float buffer, float damping);   // next(), pluck()

// convolution.h, window.h, stft.h, resample.h
int conv_direct_f32([]const float x, []const float h, []float out);
int conv_fft_f32([]const float x, []const float h, []float out);
void window_hann_f32([]float w);       // also _rectangular_, _hamming_, _blackman_f32
void window_apply_f32([]float x, []const float w);
int stft_forward_f32([]const float signal, unsigned long frameSize, unsigned long hopSize, int windowType, []Complex32 out);
int stft_inverse_f32([]const Complex32 frames, unsigned long frameSize, unsigned long hopSize, int windowType, []float outSignal);
void resample_sinc_f32([]const float in, []float out, unsigned long kernelHalfWidth);   // also resample_nearest_f32, resample_linear_f32

// dct.h, dft.h, imaging.h
int dct2_f32([]const float in, []float out);
int idct3_f32([]const float in, []float out);
int dft_f32([]const Complex32 in, []Complex32 out);   // also dft_real_f32, idft_f32
int conv2d_f32([]const float img, unsigned long h, unsigned long w, []const float kernel, unsigned long kh, unsigned long kw, []float out);
int fft2d_f32([]Complex32 img, unsigned long h, unsigned long w);    // and ifft2d_f32

분석 전용 모듈(CQT, CWT, 최소 위상, z-변환 설계)은 double만 지원합니다.

float에서의 고차 IIR

IirFilter32는 직접형 필터라서, 차수가 높을수록 계수의 float 반올림 오차가 커집니다. 2차보다 가파른 필터는 Biquad32 섹션을 FilterChain으로 직렬 연결합니다.


fixed — Q8.24 고정소수점 산술

c
#include <std/dsp/fixed.h>

Fixed는 Q8.24 고정소수점 타입입니다: 정수부 8비트, 소수부 24비트로, 32비트 부호 있는 int로 저장됩니다(서브 샘플 정밀도를 위해 24비트 오디오 샘플 깊이와 일치합니다). int 위의 newtype이므로, 컴파일러는 동일한 비트 표현을 사용하면서도 이를 별개 타입으로 취급합니다.

상수

상수 값 의미
FIXED_ONE 16777216 (2^24) 1.0
FIXED_HALF 8388608 0.5
FIXED_PI 52707178 ≈ π

변환

c
Fixed  fixed_from_int(int x);       // x << 24
Fixed  fixed_from_float(double f);  // (Fixed)(f * 16777216.0)
int    fixed_to_int(Fixed x);       // x >> 24 (산술 시프트, -inf 방향으로 반올림)
double fixed_to_float(Fixed x);     // x / 16777216.0

산술

c
Fixed fixed_add(Fixed a, Fixed b);
Fixed fixed_sub(Fixed a, Fixed b);
Fixed fixed_mul(Fixed a, Fixed b);   // (a * b) >> 24, 64비트 중간값
Fixed fixed_div(Fixed a, Fixed b);   // (a << 24) / b, 64비트 중간값
Fixed fixed_abs(Fixed x);
Fixed fixed_neg(Fixed x);
Fixed fixed_sqrt(Fixed x);           // 뉴턴-랩슨, 4회 반복; x는 0 이상이어야 함

예제

c
#include <std/dsp/fixed.h>
#include <std/io.h>

int main() {
    Fixed a = fixed_from_float(3.5);
    Fixed b = fixed_from_float(2.0);
    Fixed product = fixed_mul(a, b);       // 7.0
    Fixed root    = fixed_sqrt(product);   // ~2.6458

    print("3.5 * 2.0 = ");
    println_float(fixed_to_float(product));
    print("sqrt of that ~= ");
    println_float(fixed_to_float(root));
    return 0;
}

dsp — 배열 프리미티브

c
#include <std/dsp/dsp.h>

std/dsp/의 나머지 부분이 공유하는 기본적인 배열 수준 빌딩 블록입니다. dsp_dot_f64(Fixed 경로와 무관한 double 배열 프리미티브 — 아래 참고)를 제외하면 모두 힙 할당 없이 Fixed 슬라이스([]Fixed) 위에서 동작합니다.

c
Fixed dsp_dot([]const Fixed a, []const Fixed b);           // a[i]*b[i]의 합
void dsp_scale([]Fixed a, Fixed scale);            // a[i] *= scale
unsigned long dsp_add([]const Fixed a, []const Fixed b, []Fixed out); // out[i] = a[i]+b[i]
void dsp_clip([]Fixed a, Fixed lo, Fixed hi);      // [lo, hi]로 클램프
Fixed dsp_peak([]const Fixed a);                          // max |a[i]|
Fixed dsp_rms([]const Fixed a);                           // sqrt(mean(a[i]^2))

// SIMD FMA로 가속되는 두 *double* 배열의 내적 (simd.h 참고) --
// simd_fma_f64x4를 통해 반복당 4개 원소씩, n % 4에 대한 스칼라 나머지 처리.
// Fixed 대응 버전은 없다: Q8.24 곱셈에는 원소마다 64비트 확장 시프트가
// 필요한데, std::simd의 정수 FMA는 이를 제공하지 않는다. convolution.h의
// conv_direct가 커널을 뒤집어 연속적인 접근 패턴으로 만든 뒤 내부적으로
// 사용한다; 직접 사용하는 것도 가능하다.
double dsp_dot_f64([]const double a, []const double b);

// q < nOut에 대해 out[q] = dot(a, x[q .. q+n)): 여러 출력에 대한 FIR/상관.
// 한 번에 출력 32개(float는 64개), 탭마다 값을 네 개의 누산 벡터에
// 브로드캐스트: 수평 합이 없고, 곱셈-덧셈마다 로드가 하나입니다.
int dsp_sliding_dot_f64([]const double a, []const double x, []double out);
int dsp_sliding_dot_f32([]const float a, []const float x, []float out);

TIP

이전 버전의 이 모듈에 있던 dsp_moving_avg/dsp_iir_lp는 제거되었습니다 — filter.h의 범용 스트리밍 FirFilter/IirFilter(1탭 이동 평균은 그저 numTaps=1인 FirFilter::new이고, 1차 IIR 저역통과는 1극 a[]를 가진 IirFilter::new입니다)와 biquad.h의 쿡북 설계 함수들로 대체되었으며, 둘 다 이전의 장난감 수준 구현이 아니라 실제로 검증된 DSP 구현입니다.


complex_dsp — 복소수

c
#include <std/dsp/complex_dsp.h>

연산자 오버로딩을 갖춘 값 타입 복소수로, 아래의 스펙트럼 모듈들(FFT 버터플라이, 트위들 팩터, 주파수 영역 곱셈)을 위해 특별히 만들어졌습니다 — std/complex.h의 C99 스타일 [re, im] 출력 배열 API와는 다릅니다. w * a + b는 포인터를 써가며 이어지는 cmul_d/cadd_d 호출 체인 대신 수식이 읽히는 그대로 읽힙니다.

c
struct Complex {
    double re; double im;
    Complex operator+(Complex other) const;
    Complex operator-(Complex other) const;
    Complex operator*(Complex other) const;
    Complex operator/(Complex other) const;
    Complex neg() const;    // 단항 부호 반전 (연산자 오버로드는 인자 개수로 구분되지 않음)
    double  abs() const;    // sqrt(re^2 + im^2)
    double  arg() const;    // atan2(im, re)
    Complex conj() const;   // (re, -im)

    static Complex new(double re, double im);
    static Complex polar(double magnitude, double theta);  // (mag*cos(theta), mag*sin(theta))
};

struct FComplex {   // Q8.24 대응 버전 -- Fixed re/im, operator/를 제외하면 형태 동일
    Fixed re; Fixed im;
    FComplex operator+(FComplex other) const;
    FComplex operator-(FComplex other) const;
    FComplex operator*(FComplex other) const;
    FComplex neg() const;
    Fixed     abs() const;
    FComplex  conj() const;

    static FComplex new(Fixed re, Fixed im);
    static FComplex from(Complex c);   // 설계 시점의 double 결과를 Q8.24로 양자화
    Complex         to_complex() const;
};

a * b + c는 이름 붙은 지역 변수들로 잘 동작하며, 임시값에 연산자 호출을 바로 체이닝하는 것도 마찬가지입니다 — Complex::new(x, 0.0) * zInv는 컴파일되어 올바르게 평가됩니다; 그 임시값은 이름 붙은 변수에 먼저 바인딩할 필요 없이 호출을 위해 숨겨진 스택 슬롯에 만들어집니다.


dft — 이산 푸리에 변환 (직접 계산, O(n²))

c
#include <std/dsp/dft.h>

직접 합산 방식 DFT입니다: X[k] = sum x[n] * e^(-i*2*pi*k*n/N). 2의 거듭제곱뿐 아니라 임의의 N에 대해 동작합니다 — 임의 길이의 변환에는 이것을 사용하고, fft.h 자체의 정확성 검증이 대조하는 독립적인 기준으로도 사용됩니다. 회전 인자는 항마다 cos/sin을 부르지 않고 n개의 정확한 단위근 테이블에서 k·n mod N 인덱스로 읽으며, 256점부터 출력 빈을 스레드 풀에 나눕니다.

c
int dft([]const Complex in, []Complex out);       // 순변환, 복소수 -> 복소수
int dft_real([]const double in, []Complex out);          // 순변환, 실수 -> 복소수
int idft([]const Complex in, []Complex out);      // 역변환 (1/N 포함)
int fdft([]const FComplex in, []FComplex out);    // Q8.24 순변환
int fidft([]const FComplex in, []FComplex out);   // Q8.24 역변환

fft — 고속 푸리에 변환 (radix-2 Cooley-Tukey)

c
#include <std/dsp/fft.h>

제자리(in-place), 반복적인 시간 데시메이션 FFT입니다: dft.h의 O(n²) 대신 O(n log n)이지만, 그 대가로 n이 2의 거듭제곱이어야 합니다(그렇지 않으면 함수들은 0을 반환하고 data를 수정하지 않은 채로 둡니다 — 임의 길이에는 dft.h를 사용합니다).

실행 방식:

  • 트위들 테이블. 트위들 계수는 모든 스레드가 공유하는 테이블 하나에서 옵니다. 어떤 길이가 처음 필요할 때 만들어져 유지되며, 커지기만 하므로 읽는 쪽은 락을 잡지 않습니다.
  • 합쳐진 패스. 생성된 16점 코드렛이 처음 네 단계를 레지스터 안에서 실행합니다. 나머지 단계는 radix-8 패스(데이터를 한 번 훑을 때마다 세 단계, W8 상수는 코드에 접어 넣음. SPIRAL/FFTE의 radix-8/16 분해와 같은 방식)로 합치고, 남는 단계는 radix-4나 radix-2로 처리합니다. 4096점 변환은 radix-2 11번 대신 코드렛과 패스 3번입니다. 4096점까지는 한 블록으로 실행하고, 더 긴 변환은 2048점까지의 단계를 블록 단위로 실행해 블록 하나가 그 단계들 내내 L1/L2에 머뭅니다.
  • SIMD 폭. 레지스터에 들어가는 경우 버터플라이는 벡터 하나에 복소수 네 개씩 처리합니다(AVX2의 float, AVX-512의 두 정밀도). 그 밖에는 두 개씩입니다(AVX2의 double, SSE2, Neon).
  • 비트 역순. 4096점까지는 비트 역순 교환을 컴파일 타임 인덱스 쌍 목록에서 가져오므로, 원소마다 인덱스를 계산하거나 예측하기 어려운 분기가 없습니다. 그보다 크면 점진적으로 계산하며 스레드에 나눕니다.
  • 스레드. 32768점 이상의 변환은 스레드 풀에 나뉘어 실행됩니다. fft_batch와 fft_real_batch는 여러 변환을 풀에 분산합니다.
  • 실수 입력. fft_real은 n/2점 복소 변환 하나로 n점 전체 스펙트럼을 계산합니다. 분리 단계는 양 끝에서 빈 두 개씩 벡터로 처리합니다. fft_real_half / fft_real_half_batch는 FFTW나 NumPy처럼 중복되지 않는 n/2 + 1개 빈만 돌려주므로, 배치가 쓰는 양이 절반이 됩니다.
  • SIMD 수준. 버터플라이는 CPU SIMD 수준별로 빌드됩니다. AVX2에서는 복소 곱셈에 FMA가 더해지고, AVX-512에서는 폭이 두 배가 됩니다.
  • 벤더 FFT. oneMKL이 링크되어 있으면([build] blas의 Intel 선택), 65,536점 미만의 fft/ifft/fft_real, fft_batch/fft_real_batch의 모든 행, fft2d를 그 DFTI 변환이 실행합니다. 이 페이지의 7800X3D에서 fft2d와 배치 변환이 절반으로 줄어듭니다. fft_vendor()로 확인할 수 있습니다. FFTW 기반 라이브러리는 AOCL-FFTW를 포함해 GPL이므로 쓰지 않습니다.
  • Apple: FIR은 vDSP, FFT는 자체 구현. macOS에서 Accelerate를 링크한 프로그램(그곳에서 safeguard의 기본값)은 dsp_sliding_dot_f64/_f32를 — 따라서 그 위에 만든 FirFilter::process_block, conv_direct 등 FIR 경로도 — vDSP_convD/vDSP_conv로 실행합니다. M1 Pro에서 std의 NEON 커널보다 스레드 유무와 관계없이 1.2–1.4배 빨랐습니다(벤치마크). vDSP의 컨볼루션은 메모리를 할당하거나 락을 잡거나 스레드를 띄우지 않으므로 FirFilter::process_block의 실시간 특성은 그대로입니다. FFT는 std 자체 변환을 씁니다: oneMKL은 Apple Silicon용이 없고, std의 FFT가 vDSP보다 빠르며 vDSP의 스레드 double 배치만 예외입니다. Accelerate가 없으면 std 커널이 돕니다(Apple 링커는 약한 참조도 링크 시점에 해석하므로 safeguard가 -U를 넘깁니다).

실시간 사용

오디오 콜백에서는 미리 한 번 fft_prepare(maxN)을 호출한 뒤 fft_serial/ifft_serial을 씁니다. 이들은 메모리 할당, 락, 스레드 풀 사용, 벤더 라이브러리 호출을 전혀 하지 않습니다. oneMKL이 링크되어 있으면 fft/ifft는 실시간 코드용이 아닙니다: 길이마다 첫 호출이 DFTI 디스크립터를 만듭니다.

NVIDIA GPU에서는 fft_cuda.h가 같은 변환과 2D 변환을 cuFFT로 실행하며, 길이 제한이 없습니다. AMD GPU에서는 fft_rocm.h가 같은 일을 hipFFT로 합니다.

c
int dsp_is_pow2(unsigned long n);                                    // n >= 1이고 2의 거듭제곱이면
int fft([]Complex data);                      // 제자리 순변환, 1=성공/0=2의거듭제곱아님
int ifft([]Complex data);                     // 제자리 역변환 (1/N 포함)
int fft_serial([]Complex data);              // 호출한 스레드에서만 fft
int ifft_serial([]Complex data);
int fft_prepare(unsigned long n);                             // n 이하 길이의 트위들 테이블 생성
int fft_real([]const double in, []Complex out); // 실수 -> n점 전체 스펙트럼
// 연속으로 저장된 'batch'개 변환 (행 r은 data[r*n]), 스레드 사용
int fft_batch([]Complex data, unsigned long n, int inverse);   // data.len / n transforms
int fft_real_batch([]const double in, []Complex out, unsigned long n);   // batch = in.len / n
// 같은 작업을 제자리에서: 행 r의 앞쪽 n/2칸에 실수 n개가 들어 있음
int fft_real_batch_inplace([]Complex data, unsigned long n);
// 빈 0 .. n/2만(값 n/2 + 1개; 행 r은 out[r*(n/2+1)]부터)
int fft_real_half([]const double in, []Complex out);
int fft_real_half_batch([]const double in, []Complex out, unsigned long n);
int ffft([]FComplex data);                    // Q8.24 순변환
int fifft([]FComplex data);                   // Q8.24 역변환

예제

c
#include <std/dsp/fft.h>
#include <std/io.h>

int main() {
    struct Complex data[8];
    for (int i = 0; i < 8; i++) data[i] = Complex::new((double)i, 0.0);

    fft(data[0..8]);     // in-place forward transform
    ifft(data[0..8]);    // in-place inverse — round-trips back to the original signal

    print("data[1].re after round-trip (expect ~1.0): ");
    println_float(data[1].re);
    return 0;
}

convolution — 선형 컨볼루션

c
#include <std/dsp/convolution.h>

y[n] = sum_k x[k]*h[n-k], 출력 길이는 len_x + len_h - 1입니다. 결과는 같지만 비용 트레이드오프가 다른 두 가지 구현이 있습니다:

c
// O(len_x * len_h) -- 짧은 커널에 가장 적합. 출력 하나가 SIMD 내적 하나이며,
// 긴 입력은 스레드 풀에 나뉩니다.
int conv_direct([]const double x, []const double h, []double out);
int fconv_direct([]const Fixed x, []const Fixed h, []Fixed out);

// fft.h를 통한 O(n log n) -- 직접 컨볼루션의 O(len_x*len_h) 비용이 부담이
// 되는 긴 입력/커널에 가장 적합. double 정밀도 전용. 커널이 신호의 1/16
// 이하면 overlap-add(커널의 약 8배 블록을 fft_batch로), 그 밖에는 x와 h를
// 복소 FFT 하나에 함께 담아 변환합니다.
int conv_fft([]const double x, []const double h, []double out);

세 함수 모두 out은 len_x + len_h - 1 샘플을 담을 공간이 있어야 합니다. conv_direct는 커널을 한 번 뒤집어, 창이 x 안에 완전히 들어가는 출력들을 dsp_sliding_dot_f64 한 번으로 계산합니다. 이 함수는 출력 32개(float는 64개)를 한꺼번에, 탭마다 값을 네 개의 누산 벡터에 브로드캐스트해 계산하므로 수평 합이 없습니다. 가장자리 출력은 dsp_dot_f64를 씁니다.


window — 윈도우 함수

c
#include <std/dsp/window.h>

FFT 이전 프레임을 테이퍼링하거나(스펙트럼 누설 감소) FIR 설계(윈도우 방법)를 위한 대칭 N포인트 분석 윈도우입니다. 네 함수 모두 호출자가 제공한 배열을 채우며, 할당은 하지 않습니다.

c
void window_rectangular([]double w);  // w[i] = 1 (테이퍼링 없음 -- 누설 기준선)
void window_hann([]double w);         // 0.5*(1 - cos(2*pi*i/(n-1)))
void window_hamming([]double w);       // 0.54 - 0.46*cos(2*pi*i/(n-1))
void window_blackman([]double w);      // 0.42 - 0.5*cos(2*pi*i/(n-1)) + 0.08*cos(4*pi*i/(n-1))
void window_apply([]double x, []const double w);  // x[i] *= w[i], 제자리 연산

filter — 범용 스트리밍 FIR / IIR

c
#include <std/dsp/filter.h>

한 번에 한 샘플씩 처리하는 차분 방정식 필터로, 자체 이력/상태를 내부에 유지하므로 호출자가 (예를 들어 std::Reactor 태스크 안에서) convolution.h처럼 전체 신호를 메모리에 담아둘 필요 없이 실시간 샘플을 하나씩 넣어줄 수 있습니다. 메모리 할당이나 스레드를 전혀 쓰지 않습니다. FirFilter::process는 링 버퍼를 탭마다 나머지 연산을 하지 않고 두 개의 연속 구간으로, 네 개의 부분합으로 순회합니다. FirFilter::process_block(256탭까지)은 이력과 새 샘플 최대 512개를 스택의 창으로 복사하고 그 위에서 dsp_sliding_dot_f64를 실행합니다. 출력마다 내적을 하나씩 하는 대신 벡터 하나에 여러 출력을 계산합니다.

text
FIR ("피드포워드"): y[n] = sum_{k=0}^{M-1} b[k]*x[n-k]
IIR ("피드백"):      y[n] = sum b[k]*x[n-k] - sum_{k=1}^{N-1} a[k]*y[n-k]   (a[0] = 1)
c
struct FirFilter {
    const &double coeffs;   // b[0..numTaps-1], caller-owned
    unsigned long numTaps;
    &double       history;  // caller-provided ring buffer, numTaps elements
    unsigned long pos;

    double process(double x);
    void   reset();          // zeros the history
    void   process_block([]const double in, []double out);   // out may alias in

    static struct FirFilter new([]const double coeffs, []double history);   // numTaps = min(coeffs.len, history.len)
};

struct IirFilter {           // general-order, direct form I
    const &double b; unsigned long numB;   // feedforward
    const &double a; unsigned long numA;   // feedback, a[0] assumed 1
    &double xHistory; &double yHistory;    // numB / numA elements, zeroed
    unsigned long xPos; unsigned long yPos;

    double process(double x);
    void   reset();
    void   process_block([]const double in, []double out);

    static struct IirFilter new([]const double b, []const double a,
                                []double xHistory, []double yHistory);
};

FFirFilter와 FIirFilter(FFirFilter::new / FIirFilter::new로 만듭니다)는 double 대신 Fixed를 쓰는, 동일한 형태의 Q8.24 대응 버전입니다. 모두 SampleFilter입니다.

예제 — 3탭 이동 평균

c
#include <std/dsp/filter.h>
#include <std/io.h>

int main() {
    double coeffs[3] = { 0.333333, 0.333333, 0.333333 };
    double history[3] = { 0.0, 0.0, 0.0 };
    struct FirFilter mavg = FirFilter::new(coeffs[0..3UL], history[0..3UL]);

    double step[6] = { 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 };
    double out[6];
    mavg.process_block(step[0..6UL], out[0..6UL]);
    // out ~= [0.333, 0.667, 1, 1, 1, 1]
    println_float(out[2]);
    return 0;
}

biquad — 2차 IIR 섹션 + RBJ 쿡북

c
#include <std/dsp/biquad.h>

struct Biquad는 단일 Direct-Form-II-Transposed 2차 섹션입니다(a0은 항상 1로 정규화됨):

text
y[n] = b0*x[n] + z1
z1'  = b1*x[n] - a1*y[n] + z2
z2'  = b2*x[n] - a2*y[n]

더 높은 차수의 필터를 만들려면 여러 개를 FilterChain으로 종속 연결(cascade)합니다. 체인은 블록 전체에 대해 각 섹션의 process_block을 차례로 실행합니다.

c
struct Biquad {
    double b0, b1, b2, a1, a2, z1, z2;
    double process(double x);
    void   reset();
    // 블록 단위 process(), 상태는 레지스터에 유지; in == out 허용, 실시간 안전.
    // 분산 룩어헤드(scattered look-ahead, Parhi & Messerschmitt)를 쓴 직접형 I:
    // y[i] = q[i] + (a1^2 - 2 a2) y[i-2] - a2^2 y[i-4] (q는 입력만으로 계산)이므로
    // 짝수와 홀수 출력이 서로 독립인 두 사슬이 되고, 2레인 벡터 하나로 함께
    // 계산합니다. 추가되는 극점은 필터 자신의 극점을 뒤집은 것(반지름이 같음)이라
    // 안정성이 유지됩니다. process()와는 반올림 차이만 납니다.
    void   process_block([]const double in, []double out);

    // freqHz에서의 Z영역 주파수 응답: 크기(선형)와 위상(라디안).
    double response_mag(double fs, double freqHz) const;
    double response_phase(double fs, double freqHz) const;

    static struct Biquad new(double b0, double b1, double b2, double a1, double a2);

    // Robert Bristow-Johnson의 "Audio EQ Cookbook" 공식. fs = 샘플레이트,
    // f0 = 컷오프/중심 주파수, q = Q 계수 (높을수록 더 좁고 공진이 강함).
    static struct Biquad lowpass(double fs, double f0, double q);
    static struct Biquad highpass(double fs, double f0, double q);
    static struct Biquad bandpass(double fs, double f0, double q);   // 0dB 피크 게인 일정
    static struct Biquad notch(double fs, double f0, double q);
    static struct Biquad allpass(double fs, double f0, double q);
    static struct Biquad peaking(double fs, double f0, double q, double dbGain);
    static struct Biquad lowshelf(double fs, double f0, double s, double dbGain);   // s = 셸프 기울기, 기본값 1.0
    static struct Biquad highshelf(double fs, double f0, double s, double dbGain);

    // 쌍선형 변환을 통한 범용 아날로그-디지털 2차 섹션 변환:
    // H(s) = (B2 s^2 + B1 s + B0) / (A2 s^2 + A1 s + A0) -> 대응하는 디지털 biquad로.
    static struct Biquad bilinear(double B0, double B1, double B2,
                                  double A0, double A1, double A2, double fs);
};

struct FBiquad {   // Q8.24 대응 버전
    Fixed b0, b1, b2, a1, a2, z1, z2;
    Fixed process(Fixed x);
    void  reset();
    void  process_block([]const Fixed in, []Fixed out);

    static struct FBiquad from(struct Biquad bq);   // double로 설계된 biquad를 한 번 양자화
};

Biquad32(float)는 같은 메서드에 더해 Biquad32::new와 Biquad32::from(bq)를 가집니다. 단정밀도를 참고하십시오.

예제

c
#include <std/dsp/biquad.h>
#include <std/io.h>

int main() {
    struct Biquad lp = Biquad::lowpass(48000.0, 1000.0, 0.707);

    // Unity DC gain — the standard invariant a lowpass must satisfy.
    print("|H(0)| (expect 1.0): ");
    println_float(lp.response_mag(48000.0, 0.0));

    double y = 0.0;
    for (int i = 0; i < 200; i++) y = lp.process(1.0);
    print("settled output for constant input 1.0 (expect ~1.0): ");
    println_float(y);

    struct Biquad32 lp32 = Biquad32::from(lp);   // 같은 설계를 float로 한 번 반올림
    struct FBiquad  lpq  = FBiquad::from(lp);    // ... 또는 Q8.24로 한 번 양자화
    return 0;
}

dct — 이산 코사인 변환 (DCT-II / DCT-III)

c
#include <std/dsp/dct.h>

dct2는 JPEG/MPEG에서 말하는 "그 DCT"입니다 — 실수 입력, 실수 출력, 에너지를 압축합니다. idct3는 그 정확한 역변환입니다. dft.h처럼 직접 O(n²) 합산 방식입니다: 코사인은 4n개짜리 테이블에서 읽고, 512점부터 출력을 스레드 풀에 나눕니다.

c
int dct2([]const double in, []double out);    // X[k] = sum x[n]*cos(pi/N*(n+0.5)*k)
int idct3([]const double in, []double out);   // dct2의 정확한 역변환
int fdct2([]const Fixed in, []Fixed out);      // Q8.24 순변환
int fidct3([]const Fixed in, []Fixed out);     // Q8.24 역변환

stft — 단시간 푸리에 변환 + 다중 해상도 STFT 손실

c
#include <std/dsp/stft.h>

stft_forward는 윈도우가 적용된 프레임을 신호를 따라 슬라이드시키며 각각을 FFT하여(frameSize는 2의 거듭제곱이어야 함) 스펙트로그램을 만듭니다. 프레임들은 fft_real_batch_inplace로 제자리에서 실수 변환으로 함께 변환되며(복소 변환의 절반 작업), 호스트에서는 스레드 풀에서 실행됩니다. 스트리밍에서는 프레임을 하나씩 윈도우 처리하고 fft_serial을 씁니다. stft_inverse는 윈도우가 적용된 오버랩-애드 방식으로 신호를 재구성하며, 누적된 분석 윈도우 제곱값으로 정규화합니다 — 윈도우/홉 조합이 상수 오버랩-애드 조건을 만족할 때(예: 50%/75% 오버랩의 Hann) 정확합니다.

c
#define STFT_WIN_RECTANGULAR 0
#define STFT_WIN_HANN 1
#define STFT_WIN_HAMMING 2
#define STFT_WIN_BLACKMAN 3

unsigned long stft_num_frames(unsigned long signalLen, unsigned long frameSize, unsigned long hopSize);

// out: stft_num_frames(...) * frameSize개의 Complex 값을 담을 공간.
int stft_forward([]const double signal, unsigned long frameSize, unsigned long hopSize, int windowType, []Complex out);

// outSignal: (numFrames-1)*hopSize + frameSize개 샘플을 담을 공간, 호출자가
// 0으로 초기화 (stft_inverse가 그 안에 누적함).
int stft_inverse([]const Complex frames, unsigned long frameSize, unsigned long hopSize, int windowType, []double outSignal);

mr_stft_loss는 신경 보코더 문헌(Yamamoto 등, Parallel WaveGAN)에서 말하는 "다중 해상도 STFT 손실"을 계산합니다: numResolutions개의 각 프레임 크기(각각 2의 거듭제곱, hop = frameSize/4, Hann 윈도우)에 대해, 같은 길이의 두 신호 사이의 스펙트럼 수렴도와 평균 L1 로그 크기 오차를 계산한 뒤 여러 해상도에 걸쳐 평균 냅니다:

c
double mr_stft_loss([]const double x, []const double y, []const unsigned long frameSizes);

값이 낮을수록 더 유사합니다; 동일한 신호라면 0입니다.


resample — 업샘플링 / 다운샘플링

c
#include <std/dsp/resample.h>

inLen 샘플 버퍼를 호출자가 지정한 크기의 outLen 샘플 버퍼로 매핑합니다 — outLen > inLen이면 업샘플링, outLen < inLen이면 다운샘플링이며, 어느 방향이든 같은 함수를 사용합니다.

c
void resample_nearest([]const double in, []double out);
void resample_linear([]const double in, []double out);

// 윈도우가 적용된 sinc(대역 제한): 가장 품질이 높은 옵션. 다운샘플링할
// 때는 커널의 컷오프가 새로운(더 낮은) 나이퀴스트 레이트에 맞춰 자동으로
// 스케일되므로, 앤티에일리어싱 필터 역할도 겸합니다. kernelHalfWidth의
// 기본값으로는 8-16이 적당합니다. 탭마다의 가중치는 각의 차 공식으로
// 계산하고, 탭 각도 테이블은 호출마다 한 번 만듭니다. 그래서 탭끼리
// 독립이 되어 벡터 하나에 네 개씩 실행되며, 출력마다의 각도 네 개는 출력에서
// 출력으로 회전으로 넘기고 64개마다 정확히 다시 계산합니다. 출력은 스레드
// 풀에서 실행됩니다.
void resample_sinc([]const double in, []double out, unsigned long kernelHalfWidth);

// Q8.24 nearest/linear. sinc 대응 버전은 없다 -- 윈도우 적용된 sinc
// 커널은 런타임의 소수 위치에 대해 탭마다 sin/cos를 계산해야 하는데,
// 이는 일반적인 설계 시점-float/런타임-fixed 분리 방식에 맞지 않는다.
void fresample_nearest([]const Fixed in, []Fixed out);
void fresample_linear([]const Fixed in, []Fixed out);

ztransform — 범용 N차 쌍선형 변환

c
#include <std/dsp/ztransform.h>

biquad.h의 Biquad::bilinear / Biquad::response_mag / response_phase를 임의 차수로 일반화한 것입니다 — biquad들의 종속 연결이 아니라 더 높은 차수의 IIR 섹션 하나를 직접 설계/분석할 때 사용합니다. 다항식 근 찾기 기능은 포함되어 있지 않습니다(별도의 반복적인 고유값/Durand-Kerner 알고리즘이 필요합니다); 대신 계수 배열을 직접 다룹니다.

c
// B, A: order+1개의 아날로그(s영역) 계수, s의 오름차순 거듭제곱.
// bOut, aOut: 음의 z 거듭제곱 표기법(aOut[0] = 1)에서의 order+1개
// 디지털 계수 -- filter.h의 IirFilter::new에 그대로 넣으면 된다.
int bilinear_nth_order([]const double B, []const double A, double fs, []double bOut, []double aOut);   // order = B.len - 1

double ztransform_response_mag([]const double b, []const double a, double fs, double freqHz);
double ztransform_response_phase([]const double b, []const double a, double fs, double freqHz);

comb — 콤 필터 + Karplus-Strong 현악기 합성

c
#include <std/dsp/comb.h>

CombFF(피드포워드, y[n] = x[n] + gain*x[n-delay])는 균등한 간격의 노치를 만들고; CombFB(피드백, y[n] = x[n] + gain*y[n-delay])는 균등한 간격의 공진 피크를 만듭니다(대부분의 인공 리버브의 기반입니다 — 서로 다른 딜레이/게인을 가진 피드백 콤들의 뱅크).

c
struct CombFF {
    &double history; unsigned long delay; unsigned long pos; double gain;
    double process(double x);
    void   reset();
    void   process_block([]const double in, []double out);   // out may alias in

    // 'history' is the caller's ring buffer (its length is the delay), zeroed.
    static struct CombFF new([]double history, double gain);   // delay = history.len
};
struct CombFB {
    &double history; unsigned long delay; unsigned long pos; double gain;
    double process(double x);
    void   reset();
    void   process_block([]const double in, []double out);

    static struct CombFB new([]double history, double gain);   // delay = history.len
};

KarplusStrong은 고전적인 발현(plucked-string)/타악기 합성 알고리즘입니다(Karplus & Strong, 1983): 피드백 경로가 단순 스칼라 게인이 아니라 2탭 평균 저역통과인 피드백 콤입니다 — 이 평균화가 루프를 돌 때마다 고주파 에너지를 점진적으로 제거하여, 현악기 특유의 밝게 시작했다가 감쇠하는 음색을 만들어냅니다. 입력을 받지 않으므로 필터가 아니라 SampleGenerator입니다: 메서드는 process()와 reset()이 아니라 next()와 pluck()입니다.

c
struct KarplusStrong {
    &double buffer;         // ring buffer; also the delay line and the pitch period
    unsigned long length; unsigned long pos;
    double damping;         // 0..1, closer to 1 = slower decay (typical 0.99-0.999)

    double next();          // the next output sample: no input, self-sustaining
    // Refill the delay line with a new excitation (n samples copied in; size
    // it to 'length' for a fresh pluck, the rest keeps its old contents).
    void   pluck([]const double excitation);

    // 'buffer' is the caller's delay line (its length is the period); seed it (or
    // call pluck) before next().
    static struct KarplusStrong new([]double buffer, double damping);   // length = buffer.len
};

FCombFF, FCombFB, FKarplusStrong은 Q8.24 대응 버전이고 CombFF32, CombFB32, KarplusStrong32은 float 대응 버전이며, 각각 자신의 ::new로 만듭니다.

예제 — 발현 현악기

c
#include <std/dsp/comb.h>
#include <std/io.h>

int main() {
    double excitation[8] = { 0.5, -0.3, 0.8, -0.6, 0.2, -0.9, 0.4, -0.1 };  // noise burst
    double buf[8];
    struct KarplusStrong ks = KarplusStrong::new(buf[0..8UL], 0.995);
    ks.pluck(excitation[0..8UL]);

    for (int i = 0; i < 16; i++) {
        double sample = ks.next();
        println_float(sample);   // decaying, self-sustained "pluck"
    }

    double block[32];
    generate_block(&ks, block[0..32UL]);   // 다음 32샘플: 모든 SampleGenerator에 동작
    return 0;
}

스트리밍 트레이트: 모든 필터에 대한 하나의 인터페이스

c
#include <std/dsp/sample_filter.h>   // filter.h, biquad.h, comb.h도 포함합니다

위의 스트리밍 필터들은 같은 형태를 공유합니다: process()는 샘플 하나, process_block()은 버퍼 하나(out은 in과 겹쳐도 됩니다), reset()은 상태를 무음으로 되돌립니다. sample_filter.h는 각 기능을 하나씩 작은 트레이트으로 이름 붙이고, 제네릭 코드가 요구하는 역할로 합성합니다. 표 다음의 헬퍼들은 한 번만 작성되고, 사용하는 것만 요구하며, 컴파일 타임에 해석되므로 손으로 튜닝한 process_block을 감싸는 제네릭 래퍼도 실행 시간 비용이 없습니다.

트레이트 구성 만족하는 타입
SampleProcessor process(x): 샘플 하나 입력, 하나 출력 아래의 모든 필터
BlockProcessor process_block(in, out)(슬라이스) 아래의 모든 필터
Resettable reset(): 무음 상태로 아래의 모든 필터
SampleFilter SampleProcessor + BlockProcessor + Resettable double: FirFilter, IirFilter, Biquad, CombFF, CombFB; float: FirFilter32, IirFilter32, Biquad32, CombFF32, CombFB32; Q8.24: FFirFilter, FIirFilter, FBiquad, FCombFF, FCombFB; 그리고 FilterChain<S, A, B>
SampleGenerator next(): 입력 없이 샘플 하나 출력 KarplusStrong, KarplusStrong32, FKarplusStrong

하나의 트레이트 집합이 세 가지 샘플 타입을 모두 다룹니다. 트레이트는 메서드 이름으로 만족되므로 float나 Fixed 필터도 double 필터와 같은 방식으로 부합합니다. 헬퍼는 샘플 타입 S를 자신의 타입 매개변수로 받으며, 버퍼로부터 추론됩니다.

함수 바운드 하는 일
filter_process_each(&f, in, out) SampleProcessor out[i] = f.process(in[i]): 더 빠른 블록 형태가 없는 필터의 process_block
filter_impulse_response(&f, out[0..n]) SampleProcessor + Resettable 리셋 상태에서 1, 0, 0, ...에 대한 응답의 처음 n샘플; f는 리셋된 상태로 남습니다. FIR의 임펄스 응답은 그 탭입니다
filter_step_response(&f, out[0..n]) SampleProcessor + Resettable 1, 1, 1, ...에 대해 같은 것
generate_block(&g, out[0..n]) SampleGenerator out[i] = g.next()
sample_one() S 타입 샘플로서의 1.0(Fixed이면 FIXED_ONE)

직접 만든 필터는 세 메서드를 가지면 부합합니다. 그러면 위의 헬퍼, FilterChain, 그리고 모든 generic<F: SampleFilter> 코드와 함께 동작합니다:

c
#include <std/dsp/filter.h>
#include <std/io.h>

// 1극 DC 차단기: y[n] = x[n] - x[n-1] + r*y[n-1].
struct DcBlocker {
    double r; double x1; double y1;
    double process(double x);
    void   process_block([]const double in, []double out);
    void   reset();
};
double DcBlocker::process(double x) {
    double y = x - self.x1 + self.r * self.y1;
    self.x1 = x;
    self.y1 = y;
    return y;
}
void DcBlocker::process_block([]const double in, []double out) {
    filter_process_each(self, in, out);
}
void DcBlocker::reset() { self.x1 = 0.0; self.y1 = 0.0; }

// 모든 SampleFilter: 단위 계단 입력 n샘플 뒤의 출력.
generic<F: SampleFilter>
double settled(&F f) {
    double s[256];
    filter_step_response(f, s[0..256UL]);
    return s[255];
}

int main() {
    struct DcBlocker dc;
    dc.r = 0.995;
    dc.reset();

    double taps[3] = { 0.25, 0.5, 0.25 };
    double hist[3] = { 0.0, 0.0, 0.0 };
    struct FirFilter fir = FirFilter::new(taps[0..3UL], hist[0..3UL]);

    println_float(settled(&dc));    // ~0.28이며 계속 감소: DC 차단기는 DC를 제거합니다
    println_float(settled(&fir));   // 1.0: 탭의 합
    return 0;
}

FilterChain: 필터의 직렬 연결

c
generic<S, A: SampleFilter, B: SampleFilter>
struct FilterChain {
    A first;
    B second;

    S    process(S x);     // second.process(first.process(x))
    void reset();          // 두 단 모두
    // 각 단이 자신의 process_block으로 블록 전체를 처리하고(각자의 빠른 경로 유지),
    // 'out'을 중간 버퍼로 씁니다: 별도 스크래치 없음, out은 in과 겹쳐도 됨.
    void process_block([]const S in, []S out);

    static struct FilterChain<S, A, B> of(A first, B second);
};

FilterChain은 바운드가 있는 제네릭 구조체입니다: S는 샘플 타입(double, float, Fixed)이고 두 단은 서로 다른 필터 타입이어도 됩니다. 세 메서드를 가지므로 체인 자체가 SampleFilter이고, 체인은 중첩됩니다: 단이 몇 개이든 체인의 체인으로 만들 수 있습니다. of()는 단들을 체인 안으로 복사하며, .first와 .second로 접근합니다. 이력을 호출자의 버퍼에 두는 단(FIR, IIR, comb)은 여전히 그 버퍼를 쓰므로, 원본을 체인의 복사본과 동시에 실행하지 마십시오.

c
#include <std/dsp/biquad.h>
#include <std/dsp/filter.h>
#include <std/io.h>

int main() {
    // 80 Hz - 8 kHz 대역: biquad 두 개로 만든 4차 응답.
    struct Biquad hp = Biquad::highpass(48000.0, 80.0, 0.7071);
    struct Biquad lp = Biquad::lowpass(48000.0, 8000.0, 0.7071);
    struct FilterChain<double, struct Biquad, struct Biquad> band = FilterChain::of(hp, lp);

    // 체인은 SampleFilter이므로 다른 체인의 한 단이 될 수 있습니다.
    double taps[3] = { 0.25, 0.5, 0.25 };
    double hist[3] = { 0.0, 0.0, 0.0 };
    struct FirFilter smooth = FirFilter::new(taps[0..3UL], hist[0..3UL]);
    struct FilterChain<double, struct FilterChain<double, struct Biquad, struct Biquad>, struct FirFilter> eq =
        FilterChain::of(band, smooth);

    double buf[256];
    for (int i = 0; i < 256; i++) buf[i] = (i % 32 < 16) ? 1.0 : -1.0;   // 사각파
    eq.process_block(buf[0..256UL], buf[0..256UL]);                              // 제자리 처리
    println_float(buf[255]);
    return 0;
}

minphase — 최소 위상 재구성

c
#include <std/dsp/minphase.h>

임펄스 응답(예: 목표 크기 응답으로부터 설계된 선형 위상 FIR)이 주어지면, 동일한 크기 스펙트럼을 가지면서 가능한 최소 군지연(group delay)을 갖는 다른 임펄스 응답을 만들어냅니다 — 동일한 주파수 응답에 대해 선형 위상 필터의 지연 시간을 절반으로 줄입니다. 고전적인 준동형(실수 켑스트럼) 알고리즘입니다: 로그 크기 스펙트럼 -> 켑스트럼 -> 비인과 절반을 인과 절반 위로 접기(이것이 결과를 최소 위상으로 강제하는 부분) -> 스펙트럼 영역을 다시 거쳐 지수화.

c
// x: n개 샘플 (n <= fftLen); fftLen은 2의 거듭제곱이어야 하며, 정확한
// 켑스트럴 리프터링을 위해 n보다 충분히 커야 함 (경험적으로 4-8배 정도가
// 적당함). outMinPhase: fftLen개 샘플을 담을 공간 -- 에너지가 시작 부분에
// 집중되며, 호출자는 보통 처음 n개 정도의 샘플만 남겨서 사용함.
int minimum_phase([]const double x, unsigned long fftLen, []double outMinPhase);

cqt — 상수-Q 변환

c
#include <std/dsp/cqt.h>

DFT/FFT의 균일한 주파수 간격과 달리, CQT는 모든 빈(bin)에서 일정한 비율 fk/bandwidth("Q")를 유지하는 로그 간격 중심 주파수 fk = fmin * 2^(k/binsPerOctave)를 사용합니다 — 음악적 음정과 인간의 청각이 대략 주파수에 대해 로그적이라는 사실과 맞아떨어지며, 이것이 크로마그램과 음정 검출에서 (단순 DFT가 아니라) CQT가 표준적으로 선택되는 이유입니다. 직접 상관(direct-correlation) 형태입니다(Brown 1991): 각 빈 고유 길이의 Hann 윈도우가 적용된 복소 지수 커널이 신호의 시작 부분과 직접 상관되며 — FFT도, 커널 행렬의 사전 계산도 없이 — 호출당 O(numBins * 평균 커널 길이)입니다. 윈도우와 지수는 복소 회전으로 진행하고 64샘플마다 정확히 다시 계산하며, 빈은 스레드 풀에서 실행됩니다.

c
// signal은 최소한 가장 낮은(가장 긴 커널을 갖는) 빈에 필요한 만큼의
// 샘플을 가져야 한다 -- 대략 Q*fs/fmin, Q = 1/(2^(1/binsPerOctave)-1) --
// 그렇지 않으면 해당 빈의 커널이 signalLen에 맞춰 조용히 잘린다
// (오류가 아니라 해당 빈의 해상도 저하). out: numBins개의 Complex 값을
// 담을 공간.
void cqt_forward([]const double signal, double fs, double fmin, unsigned long binsPerOctave, []Complex out);

cwt — 연속 웨이블릿 변환 (Morlet)

c
#include <std/dsp/cwt.h>

CQT가 로그 간격 주파수 빈을 선택하는 반면, CWT는 (호출자가 선택하는, 보통 로그 간격인) 스케일 집합을 직접 선택하고 스케일/이동된 Morlet 웨이블릿을 모든 샘플 위치에서 신호와 상관시켜, 프레임당 값 하나가 아니라 완전한 시간-스케일 "스칼로그램"을 만들어냅니다. 스케일 s는 대략 f = w0*fs / (2*pi*s) Hz의 중심 주파수에 대응합니다. 직접 상관 방식이며, 커널 지지(support)는 가우시안 포락선의 표준편차 ±4배로 잘립니다. 스케일마다 웨이블릿을 한 번 계산하고, 각 출력은 그것과의 SIMD 내적 두 번이며, (스케일, 위치) 행은 스레드 풀에서 실행됩니다.

c
// scales: numScales개 항목 (각각 0보다 커야 함). w0: Morlet 중심 주파수
// 파라미터 (5-6이 표준). out: numScales*signalLen개의 Complex 값을 담을
// 공간, 스케일 기준 행 우선(row-major) 순서.
int cwt_morlet([]const double signal, []const double scales, double w0, []Complex out);

imaging — 2D DSP

c
#include <std/dsp/imaging.h>

위의 1D 빌딩 블록들에 대응하는 2D 버전입니다: conv2d는 제로 패딩된("same" 크기) 2D 컨볼루션으로, 커널 탭마다 행 단위 SIMD 곱셈-덧셈으로 계산하고 행은 스레드 풀에서 실행합니다; fft2d/ifft2d는 fft.h의 1D 변환을 모든 행에, 그다음 모든 열에 적용합니다(2D DFT는 분리 가능(separable)하므로 정확합니다). 행은 fft_batch로 처리하고, 열은 8개씩 연속 버퍼로 모아 변환하며 열 묶음들은 스레드 풀에서 실행됩니다; 커널 생성 함수들은 거의 모든 이미지 파이프라인에 필요한 두 가지 컨볼루션 커널을 다룹니다.

c
// img: h*w개, 행 우선; kernel: kh*kw개, 행 우선 (kh/kw는 홀수여야 함);
// out: h*w개의 double을 담을 공간. 범위를 벗어난 커널 탭은 0으로 취급.
int conv2d([]const double img, unsigned long h, unsigned long w, []const double kernel, unsigned long kh, unsigned long kw, []double out);

int fft2d([]Complex img, unsigned long h, unsigned long w);   // 제자리 연산, h와 w는 2의 거듭제곱이어야 함
int ifft2d([]Complex img, unsigned long h, unsigned long w);

int gaussian_kernel([]double kernel, unsigned long size, double sigma);  // 정규화됨 (합이 1)
int sobel_x_kernel([]double kernel);   // 고정 3x3, 9개의 double을 담을 공간
int sobel_y_kernel([]double kernel);

AudioBuffer — 락프리 다중 채널 링 버퍼

c
#include <std/dsp/audio_buffer.h>

인터리빙된 다중 채널 Q8.24(Fixed) 프레임을 인라인으로 저장하는 단일 생산자/단일 소비자 링입니다 — 힙을 쓰지 않습니다. 용량은 최대 AUDIO_MAX_CHANNELS(8)채널의 AUDIO_BUF_FRAMES(512)프레임이므로 구조체가 큽니다(16 KB). 작은 타깃에서는 정적 저장소에 둡니다.

구조체

c
struct AudioBuffer {
    Fixed         data[AUDIO_BUF_FRAMES * AUDIO_MAX_CHANNELS];   // interleaved frames
    unsigned long cap_frames;
    unsigned long channels;
    volatile unsigned long head;   // write cursor (frames)
    volatile unsigned long tail;   // read cursor (frames)

    unsigned long readable_frames() const;
    unsigned long writable_frames() const;
    unsigned long write_frames([]const Fixed src);   // returns frames written
    unsigned long read_frames([]Fixed dst);    // returns frames read
    unsigned long peek_frames([]Fixed dst) const;
    unsigned long mix_frames([]const Fixed src);
    void          clear();
    int           is_empty() const;
    int           is_full() const;

    // 'channels'개 채널의 비어 있고 0으로 채운 버퍼 (AUDIO_BUF_FRAMES 프레임).
    static struct AudioBuffer new(unsigned long channels);
};

예제 — 생산자 / 소비자

c
#include <std/dsp/audio_buffer.h>
#include <std/io.h>

static struct AudioBuffer g_ab;

int main() {
    g_ab = std::AudioBuffer::new(2UL);        // stereo

    Fixed frame[2];
    unsafe {
        frame[0] = std::fixed_from_float(0.5f);
        frame[1] = std::fixed_from_float(-0.25f);
    }
    g_ab.write_frames(frame[0..1]);           // producer
    std::print("readable: ");
    std::println_int((long long)g_ab.readable_frames());   // 1

    Fixed out[2];
    g_ab.read_frames(out[0..1]);              // consumer
    unsafe {
        std::print("L = "); std::println_float((double)std::fixed_to_float(out[0]));   //  0.5
        std::print("R = "); std::println_float((double)std::fixed_to_float(out[1]));   // -0.25
    }
    return 0;
}

INFO

mix_frames는 현재 쓰기 위치에서 기존 버퍼 내용에 frames를 더할 뿐, head를 전진시키지는 않습니다. 이는 하나의 출력 버퍼로 커밋하기 전에 여러 소스를 믹싱하기 위한 것입니다. 커서를 전진시키려면 이후에 write_frames를 호출합니다.


TimerWheel — O(1) 실시간 타이머

c
#include <std/dsp/timer_wheel.h>

최대 WHEEL_TIMERS(64)개의 동시 타이머를 지원하는 타이머 스케줄러입니다. tick()은 시계를 1만큼 진행시키고 새 tick에 만료되는 모든 타이머를 실행합니다. 하나의 휠은 멀티테넌트이므로 — 서로 무관한 하위 시스템이 서로 다른 컨텍스트 타입으로 공유합니다 — 각 슬롯은 콜백을 타입 소거된 형태로 저장합니다. add와 add_periodic은 짝이 맞는 fn void(?&T) / ?&T 쌍을 받는 자기 타입 매개변수를 가진 메서드이므로 호출은 항상 타입이 올바릅니다.

API

c
#define WHEEL_SLOTS   256
#define WHEEL_TIMERS  64

struct TimerWheel {
    struct WheelTimer timers[WHEEL_TIMERS];
    unsigned long     current_tick;

    void          init();            // in place: all slots free, tick 0. Call before anything else.

    // One-shot at absolute tick `tick`. Returns the timer id (1-based) or 0 if full.
    generic<T> int add(unsigned long tick, fn void(?&T) cb, ?&T ctx);
    // Every `period` ticks, first at now() + period. Returns the id or 0 if full.
    generic<T> int add_periodic(unsigned long period, fn void(?&T) cb, ?&T ctx);

    void          cancel(int id);    // ignores invalid ids
    int           tick();            // advance one tick; returns how many timers fired
    unsigned long now() const;
};

예제

c
#include <std/dsp/timer_wheel.h>
#include <std/io.h>

struct Led { int toggles; };
static int g_done = 0;

static void blink(?&Led led) {
    match (led) { case some(l): l.toggles = l.toggles + 1; case null: { } }
}
static void stop(?&int unused) { g_done = 1; }

static struct TimerWheel g_tw;   // ~2 KB of slots: keep it off small stacks

int main() {
    g_tw.init();
    struct Led led;
    led.toggles = 0;
    unsafe { g_tw.add_periodic(10UL, blink, (?&Led)&led); }  // every 10 ticks
    g_tw.add(55UL, stop, null);                               // once, at tick 55
    while (!g_done) { g_tw.tick(); }
    std::print("blinks: ");
    std::println_int(led.toggles);   // 5 (ticks 10, 20, 30, 40, 50)
    return 0;
}

WARNING

타이머 만료는 expires == current_tick으로 확인됩니다. tick()이 예상보다 드물게 호출되면(예: 인터럽트 지연 스파이크 이후) 타이머가 누락될 수 있습니다. 중요한 데드라인에는 대신 expires <= current_tick으로 폴링하거나, 하드웨어 타이머 ISR로 tick()을 구동합니다.