Workflow¶
Three common starting points are covered below. Reference sections follow.
Scenario 1 — Simple standalone extension¶
You're here if: you have one algorithm to wrap — a filter, a running
statistic, an oscillator — and you want it available as from my_dsp import Engine with full C and Python tests.
A single C object exposed as a Python extension. Good starting point for wrapping an algorithm, DSP primitive, or performance-critical inner loop.
1. Scaffold¶
just-makeit new my_dsp \
--object gain \
--arg-type float \
--return-type float \
--state gain:float:1.0
cd my_dsp
--arg-type and --return-type set the C types for step()'s input and
output. Omit both and they default to float _Complex.
2. Implement¶
Open native/inc/gain/gain_core.h and fill in the gain_step stub:
gain_steps() — the block processor — is already in gain_core.c and loops
over this automatically. You do not edit the Python binding (gain_ext.c).
3. Build and test¶
4. Install¶
Note: Run inside an activated virtual environment. Use
jm-install-deps path/to/venv && source path/to/venv/bin/activateto create one with all build deps included.
python3 -m pip install . # build wheel + install
python3 -m pip install -e . # editable install (Python-only edits take effect immediately)
5. Use from Python¶
import numpy as np
from my_dsp import Gain
g = Gain(gain=2.0)
# single sample
y = g.step(1.0) # → 2.0
# block
x = np.ones(1024, dtype=np.float32)
y = g.steps(x) # → float32 ndarray, all 2.0
# getters / setters
g.set_gain(0.5)
g.get_gain() # → 0.5
# reset to declared defaults
g.reset()
# context manager
with Gain(gain=2.0) as g:
y = g.steps(x)
6. Optional: performance annotations¶
Once the algorithm is working and tested:
Patches step() with JM_FORCEINLINE JM_HOT, writes jm_perf.h and
jm_simd.h, and records the setting so future object and add calls
inherit it. See Performance annotations for the full reference.
Scenario 2 — Python package with multiple extensions¶
You're here if: you have several related but independent algorithms —
Gain, EMA, Biquad — and you want them all in one package with separate
.so files and full test coverage for each.
Multiple C objects in one project, all accessible from a single Python package.
1. Scaffold the first object¶
just-makeit new dsp_toolkit \
--object gain \
--arg-type float \
--return-type float \
--state gain:float:1.0
cd dsp_toolkit && make
2. Add a second object¶
just-makeit object ema \
--arg-type float \
--return-type float \
--state alpha:double:0.1 \
--state prev:float:0.0
object writes all C and Python files for the new standalone object and updates:
- root
CMakeLists.txt—add_subdirectory+target_sources($<TARGET_OBJECTS:…>) - umbrella header
native/inc/dsp_toolkit.h—#include "ema/ema_core.h" src/dsp_toolkit/__init__.py— splices infrom .ema import Emaand adds"Ema"to__all__, preserving any existing user edits
After adding ema, __init__.py looks like:
No manual edits required.
3. Implement both objects¶
gain_step (read-only state):
ema_step (writes back to state — drop const):
static inline float
ema_step(ema_state_t *state, float x)
{
float y = (float)state->alpha * x
+ (float)(1.0 - state->alpha) * state->prev;
state->prev = y;
return y;
}
4. Build and test¶
CTest runs test_gain_core and test_ema_core. pytest runs the full
generated suite for both objects.
5. Install¶
The wheel bundles all compiled DSOs (gain.cpython-*.so, ema.cpython-*.so,
…) alongside the Python package.
6. Use from Python¶
import numpy as np
from dsp_toolkit import Gain, Ema
signal = np.ones(20, dtype=np.float32)
gain = Gain(gain=2.0)
ema = Ema(alpha=0.3)
for x in signal:
y = ema.step(gain.step(x))
7. Add more objects¶
Each object repeats the same pattern: new C files, updated CMake, updated
__init__.py. make picks up the new object automatically.
8. Install¶
The wheel bundles the new DSO alongside all existing ones.
Scenario 3 — Grouped types in a single subpackage module¶
You're here if: you're building a collection of related filter types —
Fir, Biquad, Equalizer — and you want from my_filters.filter import Fir, Biquad rather than a separate top-level import for each.
Use this when multiple related Python types should share one .so and import
from a common subpackage path. Each type still has its own independent C
library; the module is the Python grouping unit only.
1. Scaffold the project and module together¶
--module is repeatable — --module osc --module env scaffolds two modules
in one command. Each module is an empty slot; types are added with
just-makeit object.
Alternatively, scaffold the project first and add the module separately:
2. Add types¶
just-makeit object fir \
--module filter \
--state "coeffs:float[16]" \
--state "delay:float _Complex[16]" \
--state "gain:float:1.0"
just-makeit object biquad \
--module filter \
--arg-type float \
--return-type float \
--state "b0:double:1.0" \
--state "b1:double:0.0" \
--state "b2:double:0.0" \
--state "a1:double:0.0" \
--state "a2:double:0.0" \
--state "w1:double:0.0" \
--state "w2:double:0.0"
Each just-makeit object call:
- Creates the C library (
_core.h,_core.c, C test, C benchmark) - Fully regenerates the module's
filter_ext.c,CMakeLists.txt, and__init__.py
After both objects:
# src/my_filters/filter/__init__.py — generated
from .filter import Fir, Biquad
__all__ = ["Fir", "Biquad"]
Types within a module may have different --arg-type/--return-type. Here
Fir processes float complex and Biquad processes float.
3. Implement¶
Edit native/inc/fir/fir_core.h and native/inc/biquad/biquad_core.h to
fill in the _step stubs, exactly as in Scenarios 1 and 2.
4. Build and test¶
CMake builds one .so (filter.cpython-*.so) inside
src/my_filters/filter/, linking both fir_core and biquad_core OBJECT
libraries. CTest runs test_fir_core and test_biquad_core.
5. Install¶
The wheel contains one .so for the filter subpackage rather than one .so
per type.
6. Use from Python¶
import numpy as np
from my_filters.filter import Fir, Biquad
fir = Fir(gain=1.0)
bq = Biquad(b0=1.0)
Both types are fully independent — separate create/destroy lifecycles,
each with its own step, steps, reset, and context manager support.
7. Add a third type later¶
filter_ext.c, CMakeLists.txt, and __init__.py are all regenerated from
the complete object list. Fir and Biquad are unaffected.
8. Install¶
The wheel is rebuilt with iir included.
Standalone object vs module object — when to use which¶
just-makeit object (no --module) |
just-makeit module + just-makeit object --module |
|---|---|
Each type gets its own .so |
All types share one .so subpackage |
from my_pkg import Gain, Ema |
from my_pkg.filter import Fir, Biquad |
| Good for unrelated algorithms | Good for a cohesive type family |
Simpler; each type is independent at the .so level |
One import namespace for the group |
Both workflows produce a lib<project>.so C library that supports
cmake --install, pkg-config, and CMake find_package.
The edit lifecycle: author → apply/regenerate → implement → test → iterate¶
just-makeit.toml is the manifest. Every CLI verb (object, method,
add, …) writes to it, then materializes files. You can also edit the TOML by
hand. The verbs treat your files differently — this is the
sacred/glue contract:
| File | Class |
|---|---|
<comp>_ext.c |
Glue — always regenerated from the manifest; never hand-edited |
src/<pkg>/<comp>.pyi |
Glue — always regenerated |
CMakeLists.txt |
Glue — always regenerated |
<comp>_core.c |
Sacred — never spliced or re-rendered; created once, rebuilt only by jm regenerate |
<comp>_core.h |
The state struct + inline step() are sacred; method/property declarations refresh from the TOML |
The verbs are splice-free. They never re-render an existing body:
jm method, computedjm property, andjm functionare additive — they inject one declaration into_core.hand append a fresh stub to_core.c. Existing bodies are never touched. A field-backedjm property --fieldinjects one struct member directly.jm add(adding state) is structural — it writes[[obj.state]]to the manifest, then rebuilds the object via the regenerate path. It discards hand-written_core.cbodies and the inlinestep()body in_core.h(see below).jm applyinjects any TOML-declared declaration missing from_core.hand keeps the struct +step()sacred. A state-field change or a signature change is structural →jm regenerate.
So the flow is:
- Author — run a CLI verb, or hand-edit
just-makeit.toml. - Apply / regenerate —
jm applyrefreshes the glue and injects missing declarations; a structural change (new state field, changed signature) needsjm regenerateto rebuild the object. - Implement — fill in the new
step()/steps()/method body in_core.c. - Test —
make test. - Iterate — back to step 1.
You only ever own _core.c and the TOML.
When you change a signature in TOML (an arg type, a method's return type),
or add a state field, the structure of the object changed — rebuild it from
the manifest with jm regenerate:
git stash # _core.c bodies are about to be discarded
just-makeit regenerate gain # deletes every file 'gain' owns, re-runs apply
regenerate deletes every file the component owns and rebuilds it from the
manifest, then asks for a single confirmation (--force skips it). Unlike
jm remove, it leaves the manifest untouched — it is the deliberate-rebuild
half of the contract. It discards hand-written _core.c bodies and the inline
step() body in _core.h, so keep your algorithm in the TOML
impl/create_impl (which the rebuild re-asserts) or stash/commit first.
Works for standalone and module objects.
Lifting an existing C body with --impl¶
When the algorithm already exists in another .c file, --impl lifts it into
the generated stub instead of having you paste it:
just-makeit object gain --arg-type float --return-type float \
--state gain:float:1.0 \
--impl legacy/dsp.c::apply_gain
--impl file::funcname injects the body of funcname. --impl file::N:M
lifts source lines N..M (inclusive, 1-based) instead — useful when there is
no clean function to name; out-of-bounds or inverted ranges error cleanly.
--replace old::new applies string substitutions before injection (e.g.
renaming a struct field). The same keys exist in TOML: impl, impl_file
("path::funcname" or "path::N:M"), create_impl, reset_impl,
destroy_impl. Because _core.c is sacred, lifting is safe — apply never
clobbers what you injected.
Project layout (full)¶
After scaffolding with one object and running just-makeit perf:
my_dsp/
├── CMakeLists.txt
├── Makefile
├── just-makeit.toml
├── pyproject.toml
├── cmake/
│ └── my-dsp.pc.in # pkg-config template
├── native/
│ ├── benchmarks/
│ │ └── bench_gain_core.c # C benchmark
│ ├── inc/
│ │ ├── clib_common.h # common C99 types
│ │ ├── pyex_common.h # Python extension includes
│ │ ├── my_dsp.h # umbrella header
│ │ ├── jm_perf.h # JM_FORCEINLINE / JM_HOT / JM_UNROLL …
│ │ ├── jm_simd.h # width-portable SIMD macros
│ │ └── gain/
│ │ └── gain_core.h # object API ← implement step() here
│ ├── src/
│ │ └── gain/
│ │ ├── CMakeLists.txt
│ │ ├── gain_core.c # steps() loop + any multi-sample logic
│ │ └── gain_ext.c # Python binding ← do not edit
│ └── tests/
│ └── test_gain_core.c # CTest lifecycle test
└── src/
└── my_dsp/
├── __init__.py
├── gain.pyi # type stub
├── benchmarks/
│ ├── __init__.py
│ └── bench_gain.py # perf_counter benchmark script
└── tests/
├── __init__.py
└── test_gain.py # pytest
Generated C API¶
Every object follows this lifecycle. Names are derived from the component
name you pass to just-makeit object:
/* Constructor — one parameter per --state declaration */
engine_state_t *engine_create(double gain);
/* Destructor */
void engine_destroy(engine_state_t *state);
/* Reset — restores every field to its declared default */
void engine_reset(engine_state_t *state);
/* Single sample — inline stub in _core.h; implement here */
static inline float complex
engine_step(const engine_state_t *state, float complex x);
/* Block processor — in _core.c, loops over step() */
void engine_steps(
engine_state_t *state,
const float complex *input,
float complex *output,
size_t n);
/* Generator (--arg-type void) — no input parameter */
static inline float
nco_step(const nco_state_t *state);
void nco_steps(nco_state_t *state, float *output, size_t n);
/* Getter and setter for each --state variable */
double engine_get_gain(const engine_state_t *state);
void engine_set_gain(engine_state_t *state, double val);
Generated Python API¶
from my_project import Engine # standalone object
import numpy as np
obj = Engine(gain=1.0) # keyword arg per --state variable
obj = Engine() # uses declared defaults
y: complex = obj.step(1.0 + 0.5j) # single sample
x = np.ones(1024, dtype=np.complex64)
y = obj.steps(x) # returns new array
obj.steps(x, out=y) # zero-copy: fills y, returns y
obj.get_gain() # getter
obj.set_gain(2.0) # setter
obj.reset() # restores declared defaults
with Engine() as e: # context manager
y = e.steps(x)
# Module subpackage — one .so, one subpackage import
from my_filters.filter import Fir, Biquad
fir = Fir(gain=1.0)
bq = Biquad(b0=1.0)
Types within a module are fully independent — separate lifecycles, each
with its own step, steps, reset, getters/setters, and context
manager.
Extending an object's state¶
Adding state is structural. add writes the new [[gain.state]] entry to
just-makeit.toml, then rebuilds the object from the manifest via the
regenerate path (delete + apply) — the new field reaches the struct, the
constructor, the getter/setter, and reset in one shot. The rebuild discards
hand-written _core.c bodies and the inline step() body in _core.h,
so keep your algorithm in the TOML impl/create_impl (the rebuild
re-asserts it) or git stash first. add prompts for one confirmation before
rebuilding; --force skips it. When the project has a single standalone
object, --object may be omitted.
Benchmarking¶
The C benchmark in native/benchmarks/bench_gain_core.c runs a raw timing
loop — useful for measuring SIMD uplift without Python overhead. The Python
benchmark script runs as a plain script (python bench_gain.py) and reports
ns/call for step() and µs + MSa/s for steps().
Type stubs and doctests¶
Every object gets a .pyi type stub alongside its Python module. The stub
gives IDEs full type information and ships runnable doctests that pass
out-of-the-box — no setup required.
For a standalone object scaffolded with
the generated src/my_dsp/gain.pyi looks like:
import numpy as np
from numpy.typing import NDArray
class Gain:
"""Gain component.
Parameters
----------
gain : float, default 1.0
gain state variable.
Examples
--------
Create with defaults:
>>> from my_dsp import Gain
>>> obj = Gain(1.0)
>>> obj.get_gain()
1.0
Reset restores defaults:
>>> obj.set_gain(0.0)
>>> obj.reset()
>>> obj.get_gain()
1.0
"""
def __init__(self, gain: float = ...) -> None: ...
def reset(self) -> None:
"""Reset state to post-create defaults."""
def step(self, x: float) -> float:
"""Process one input sample."""
def steps(self, x: NDArray[np.float32],
out: NDArray[np.float32] | None = None) -> NDArray[np.float32]:
"""Process a samples array. Returns ndarray, or fills out= if supplied."""
def get_gain(self) -> float:
"""Return current gain."""
def set_gain(self, value: float) -> None:
"""Set gain."""
def destroy(self) -> None:
"""Release C resources immediately."""
def __enter__(self) -> "Gain": ...
def __exit__(self, *args: object) -> None: ...
Running the doctests¶
The Examples block is a valid Python doctest. Run it after pip install .:
Trying:
from my_dsp import Gain
Expecting nothing
ok
Trying:
obj = Gain(1.0)
Expecting nothing
ok
Trying:
obj.get_gain()
Expecting:
1.0
ok
...
3 items passed all tests:
2 tests in gain.Gain
...
The doctest exercises the real C extension — construction, a getter read-back,
a setter-then-reset round-trip. For any state variable whose default value
round-trips exactly (integers and whole-number floats), the Examples section
is generated and passes automatically. Non-round-trip defaults (e.g.
0.1f) are omitted from doctests to avoid floating-point noise.
What gets a stub¶
| Scenario | Stub location |
|---|---|
Standalone object (just-makeit object) |
src/<pkg>/<obj>.pyi |
Module object (just-makeit object --module) |
src/<pkg>/<module>/<module>.pyi |
The stub is regenerated on every just-makeit object, method, property,
and function call. Manual edits to the generated file are overwritten —
put any extra annotations in a separate py.typed marker or alongside file.
Generated tests and benchmarks¶
Every object also gets a Python test file and a benchmark file, placed in
tests/ and benchmarks/ directories next to the package. Both are ready
to run immediately after pip install ..
For the same Gain example, src/my_dsp/tests/test_gain.py contains:
import unittest
import numpy as np
from my_dsp import Gain
# pytest compatibility shim (runs under pytest or plain unittest discover)
...
class TestGain(unittest.TestCase):
def test_create(self):
obj = Gain(1.0)
self.assertIsNotNone(obj)
def test_step_runs(self):
obj = Gain(1.0)
y = obj.step(1.0)
assert isinstance(y, float)
def test_steps_shape_dtype(self):
obj = Gain(1.0)
x = np.ones(64, dtype=np.float32)
y = obj.steps(x)
self.assertEqual(y.shape, (64,))
self.assertEqual(y.dtype, np.float32)
def test_steps_out_param(self):
x = np.ones(64, dtype=np.float32)
buf = np.zeros(64, dtype=np.float32)
obj1 = Gain(1.0)
ret = obj1.steps(x, buf)
self.assertIs(ret, buf)
def test_getter_setter(self):
obj = Gain(1.0)
assert obj.get_gain() == _approx(1.0)
obj.set_gain(2.0)
assert obj.get_gain() == _approx(2.0)
def test_reset(self):
obj = Gain(1.0)
obj.set_gain(2.0)
obj.reset()
assert obj.get_gain() == _approx(1.0)
def test_context_manager(self):
with Gain(1.0) as obj:
y = obj.step(1.0)
assert isinstance(y, float)
def test_destroy(self):
obj = Gain(1.0)
obj.destroy()
with _raises(RuntimeError, match="destroyed"):
obj.step(1.0)
And src/my_dsp/benchmarks/bench_gain.py:
"""Benchmark for Gain.
Run standalone: python src/my_dsp/benchmarks/bench_gain.py
Or via make: make bench
"""
import time
import numpy as np
from my_dsp import Gain
REPS = 1_000
BLOCK_1K = 1_024
BLOCK_64K = 65_536
def _bench(label: str, fn, *args, reps: int = REPS) -> float:
for _ in range(max(1, reps // 10)): # warmup
fn(*args)
t0 = time.perf_counter()
for _ in range(reps):
fn(*args)
return (time.perf_counter() - t0) / reps
def main() -> None:
obj = Gain(1.0)
print("gain")
dt = _bench("step", obj.step, 1.0)
print(f" {'step':<22} {dt * 1e9:9.1f} ns/call")
x1k = np.ones(BLOCK_1K, dtype=np.float32)
dt = _bench("steps 1k", obj.steps, x1k, reps=max(1, REPS // 10))
print(f" {'steps 1k':<22} {dt * 1e6:9.3f} µs ({BLOCK_1K / dt / 1e6:.1f} MSa/s)")
x64k = np.ones(BLOCK_64K, dtype=np.float32)
dt = _bench("steps 64k", obj.steps, x64k, reps=max(1, REPS // 100))
print(f" {'steps 64k':<22} {dt * 1e3:9.3f} ms ({BLOCK_64K / dt / 1e6:.1f} MSa/s)")
if __name__ == "__main__":
main()
These files are the starting point — add domain-specific assertions for your algorithm's actual behaviour. The scaffold tests verify the API contract (construction, type safety, getter/setter round-trips, reset, lifecycle); correctness tests are yours to write.
Run them with:
C library distribution¶
Every just-makeit project is also a first-class C library.
flowchart TD
SRC["**your C source**\ngain_core.c · ema_core.c · …"]
SRC --> CLIB["**libmy_dsp.so**\ncombined shared library"]
SRC --> PY["**Python package**\ngain.cpython-*.so\nema.cpython-*.so"]
CLIB --> C["**C / C++ / Rust / …**\npkg-config · find_package"]
PY --> PYUSER["**Python**\npip install .\nfrom my_dsp import Gain, Ema"]
Each object's core logic compiles once (CMake OBJECT library) and links into both artifacts.
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/usr/local
cmake --install build
gcc $(pkg-config --cflags my-dsp) main.c $(pkg-config --libs my-dsp) -lm -o main
Linux linker note:
--cflagsand--libsmust be split with the source file between them. GNU ld uses--as-neededby default on Debian/Ubuntu, which silently drops any shared library that appears before the object files that reference it. Putting-lmy_dspaftermain.censures the linker sees the undefined symbols first.
See Installing your C library for end users for the full guide: prerequisites, custom prefixes, rpath, and verification.
Configuration¶
just-makeit config # show project + object registry
just-makeit config version 0.2.0 # update version
just-makeit.toml is the source of truth for all scaffolded state.
See the Roadmap for the full plan.