diff --git a/README.md b/README.md index 5edea985..6a923294 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ limiting, expansion, gating, transient shaping) and `audioroute.Splitter` (fan one stream out to parallel branches) come from micropython-vst3's audio engine, which had them and CircuitPython does not. `audiomath` (multiply one stream by another — ring and amplitude modulation; and divide one down in -frequency — the analog octave divider), `audioecho` (a delay with a filter, +frequency — the analog octave divider; and `cast_into`, which turns a float +block into the int16 a `RawSample` plays without allocating), `audioecho` (a delay with a filter, a soft-clip and a cross-feed inside its feedback loop), `audioshaper` (a waveshaper whose curve is data, applied above the sample rate, and a sample-and-hold at an exact rational ratio), diff --git a/circuitpython.mk b/circuitpython.mk index 175f0b81..b41fb94f 100644 --- a/circuitpython.mk +++ b/circuitpython.mk @@ -42,6 +42,7 @@ AUDIODSP_CP_SOURCES += $(addprefix $(AUDIODSP_CP_SRC)/shared/, \ audiodsp_dynamics.c \ audiodsp_splitter.c \ audiodsp_midside.c \ + audiodsp_cast.c \ audiodsp_multiply.c \ audiodsp_suboctave.c \ audiodsp_feedback_delay.c \ diff --git a/micropython.cmake b/micropython.cmake index 523d20d9..d75f2de8 100644 --- a/micropython.cmake +++ b/micropython.cmake @@ -47,6 +47,7 @@ target_sources(usermod_mpaudio INTERFACE ${MPAUDIO_SRC_DIR}/shared/audiodsp_splitter.c ${MPAUDIO_SRC_DIR}/shared/audiodsp_midside.c ${MPAUDIO_SRC_DIR}/shared/audiodsp_remix.c + ${MPAUDIO_SRC_DIR}/shared/audiodsp_cast.c ${MPAUDIO_SRC_DIR}/shared/audiodsp_multiply.c ${MPAUDIO_SRC_DIR}/shared/audiodsp_suboctave.c ${MPAUDIO_SRC_DIR}/shared/audiodsp_feedback_delay.c diff --git a/micropython.mk b/micropython.mk index 1eec2efe..a3cc9002 100644 --- a/micropython.mk +++ b/micropython.mk @@ -83,6 +83,7 @@ SRC_USERMOD_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_dynamics.c SRC_USERMOD_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_splitter.c SRC_USERMOD_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_midside.c SRC_USERMOD_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_remix.c +SRC_USERMOD_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_cast.c SRC_USERMOD_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_multiply.c SRC_USERMOD_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_suboctave.c SRC_USERMOD_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_feedback_delay.c diff --git a/setup.py b/setup.py index bbcfb7bb..6ecb40f7 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ "src/shared/audiodsp_splitter.c", "src/shared/audiodsp_midside.c", "src/shared/audiodsp_remix.c", + "src/shared/audiodsp_cast.c", "src/shared/audiodsp_multiply.c", "src/shared/audiodsp_suboctave.c", "src/shared/audiodsp_feedback_delay.c", diff --git a/src/audiomath/module.c b/src/audiomath/module.c index 6ac597c1..adb91821 100644 --- a/src/audiomath/module.c +++ b/src/audiomath/module.c @@ -11,8 +11,10 @@ #include "audiomath/SubOctave.h" #include "cp_compat/audiodsp_build.h" +#include "shared/audiodsp_cast.h" #include "shared/audiodsp_remix.h" +#include "py/binary.h" #include "py/obj.h" #include "py/runtime.h" @@ -54,6 +56,57 @@ static mp_obj_t audiomath_remix_s16(size_t n_args, const mp_obj_t *args) { static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiomath_remix_s16_obj, 3, 4, audiomath_remix_s16); +// cast_into(dst, src, scale=32767.0): a float block into an int16 buffer the +// caller owns, scaled, rounded half away from zero and saturated +// (shared/audiodsp_cast.h). Nothing is allocated, so a host that keeps its +// blocks in float can feed a RawSample every block without touching the heap. +static mp_obj_t audiomath_cast_into(size_t n_args, const mp_obj_t *pos_args, + mp_map_t *kw_args) { + enum { ARG_dst, ARG_src, ARG_scale }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_dst, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_src, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_scale, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), + allowed_args, args); + mp_buffer_info_t dst; + mp_get_buffer_raise(args[ARG_dst].u_obj, &dst, MP_BUFFER_WRITE); + if (dst.typecode != 'h' && dst.typecode != 'b' && dst.typecode != 'B' + && dst.typecode != BYTEARRAY_TYPECODE) { + mp_raise_TypeError(MP_ERROR_TEXT("dst must be int16")); + } + mp_buffer_info_t src; + mp_get_buffer_raise(args[ARG_src].u_obj, &src, MP_BUFFER_READ); + size_t width; + if (src.typecode == 'f') { + width = sizeof(float); + } else if (src.typecode == 'd') { + width = sizeof(double); + } else { + mp_raise_TypeError(MP_ERROR_TEXT("src must be float32 or float64")); + } + size_t count = src.len / width; + if (dst.len != count * sizeof(int16_t)) { + mp_raise_ValueError( + MP_ERROR_TEXT("dst and src must have the same length")); + } + mp_float_t scale = args[ARG_scale].u_obj == MP_OBJ_NULL + ? (mp_float_t)32767.0 + : mp_obj_get_float(args[ARG_scale].u_obj); + if (width == sizeof(float)) { + audiodsp_cast_s16_f32((int16_t *)dst.buf, (const float *)src.buf, + count, (float)scale); + } else { + audiodsp_cast_s16_f64((int16_t *)dst.buf, (const double *)src.buf, + count, (double)scale); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(audiomath_cast_into_obj, 2, + audiomath_cast_into); + static const mp_rom_map_elem_t audiomath_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiomath) }, AUDIODSP_BUILD_GLOBALS, @@ -61,6 +114,7 @@ static const mp_rom_map_elem_t audiomath_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_SubOctave), MP_ROM_PTR(&audiomath_suboctave_type) }, { MP_ROM_QSTR(MP_QSTR_remix_s16), MP_ROM_PTR(&audiomath_remix_s16_obj) }, + { MP_ROM_QSTR(MP_QSTR_cast_into), MP_ROM_PTR(&audiomath_cast_into_obj) }, }; static MP_DEFINE_CONST_DICT(audiomath_module_globals, audiomath_module_globals_table); diff --git a/src/circuitpython_spike/shared-bindings/audiomath/__init__.c b/src/circuitpython_spike/shared-bindings/audiomath/__init__.c index 4a904e36..7716fcf4 100644 --- a/src/circuitpython_spike/shared-bindings/audiomath/__init__.c +++ b/src/circuitpython_spike/shared-bindings/audiomath/__init__.c @@ -4,9 +4,12 @@ #include +#include "py/binary.h" #include "py/obj.h" #include "py/runtime.h" +#include "shared/audiodsp_cast.h" + #include "shared-bindings/audiomath/__init__.h" #include "shared-bindings/audiomath/Multiply.h" #include "shared-bindings/audiomath/SubOctave.h" @@ -29,13 +32,76 @@ //| it lives here rather than beside the granular pitch shifter it does not //| resemble. //| +//| `cast_into` is not a node: it turns a float block into the int16 buffer a +//| `audiocore.RawSample` plays, without allocating. +//| //| """ +//| def cast_into( +//| dst: WriteableBuffer, src: ReadableBuffer, scale: float = 32767.0 +//| ) -> None: +//| """Write ``src * scale`` into ``dst`` as int16, rounded half away from +//| zero and clamped to -32768..32767; NaN becomes 0. +//| +//| :param WriteableBuffer dst: int16 samples (or their bytes), as many as +//| ``src`` has +//| :param ReadableBuffer src: float32 or float64 samples +//| :param float scale: what 1.0 becomes before rounding""" +//| ... +//| +static mp_obj_t audiomath_cast_into(size_t n_args, const mp_obj_t *pos_args, + mp_map_t *kw_args) { + enum { ARG_dst, ARG_src, ARG_scale }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_dst, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_src, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_scale, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), + allowed_args, args); + mp_buffer_info_t dst; + mp_get_buffer_raise(args[ARG_dst].u_obj, &dst, MP_BUFFER_WRITE); + if (dst.typecode != 'h' && dst.typecode != 'b' && dst.typecode != 'B' + && dst.typecode != BYTEARRAY_TYPECODE) { + mp_raise_TypeError(MP_ERROR_TEXT("dst must be int16")); + } + mp_buffer_info_t src; + mp_get_buffer_raise(args[ARG_src].u_obj, &src, MP_BUFFER_READ); + size_t width; + if (src.typecode == 'f') { + width = sizeof(float); + } else if (src.typecode == 'd') { + width = sizeof(double); + } else { + mp_raise_TypeError(MP_ERROR_TEXT("src must be float32 or float64")); + } + size_t count = src.len / width; + if (dst.len != count * sizeof(int16_t)) { + mp_raise_ValueError( + MP_ERROR_TEXT("dst and src must have the same length")); + } + mp_float_t scale = args[ARG_scale].u_obj == MP_OBJ_NULL + ? (mp_float_t)32767.0 + : mp_obj_get_float(args[ARG_scale].u_obj); + if (width == sizeof(float)) { + audiodsp_cast_s16_f32((int16_t *)dst.buf, (const float *)src.buf, + count, (float)scale); + } else { + audiodsp_cast_s16_f64((int16_t *)dst.buf, (const double *)src.buf, + count, (double)scale); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(audiomath_cast_into_obj, 2, + audiomath_cast_into); + static const mp_rom_map_elem_t audiomath_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiomath) }, { MP_ROM_QSTR(MP_QSTR_Multiply), MP_ROM_PTR(&audiomath_multiply_type) }, { MP_ROM_QSTR(MP_QSTR_SubOctave), MP_ROM_PTR(&audiomath_suboctave_type) }, + { MP_ROM_QSTR(MP_QSTR_cast_into), MP_ROM_PTR(&audiomath_cast_into_obj) }, }; static MP_DEFINE_CONST_DICT(audiomath_module_globals, diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 550df5ab..682e946c 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -20,6 +20,7 @@ #include "shared/audiodsp_dynamics.h" #include "shared/audiodsp_splitter.h" #include "shared/audiodsp_midside.h" +#include "shared/audiodsp_cast.h" #include "shared/audiodsp_remix.h" #include "shared/audiodsp_multiply.h" #include "shared/audiodsp_suboctave.h" @@ -3765,6 +3766,88 @@ static PyObject *audiodsp_py_remix_s16(PyObject *module, PyObject *args) { return dest_obj; } +// A buffer's struct format names one native item: "h", or "@h", "=h", and on a +// little-endian host "' || format[0] == '!') { + format++; + } + #endif + if (format[0] == 0 || format[1] != 0) { + return 0; + } + return format[0]; +} + +// cast_into(dst, src, scale): shared/audiodsp_cast.h into a buffer the caller +// owns. audiomath.cast_into gives scale its default. +static PyObject *audiodsp_py_cast_into(PyObject *module, PyObject *args) { + PyObject *dst_obj; + PyObject *src_obj; + double scale; + if (!PyArg_ParseTuple(args, "OOd:cast_into", &dst_obj, &src_obj, + &scale)) { + return NULL; + } + Py_buffer dst = {0}; + if (PyObject_GetBuffer(dst_obj, &dst, + PyBUF_WRITABLE | PyBUF_C_CONTIGUOUS | PyBUF_FORMAT) < 0) { + return NULL; + } + char dst_code = audiodsp_native_format(dst.format); + if (dst_code != 'h' && dst_code != 'b' && dst_code != 'B' + && dst_code != 'c') { + PyBuffer_Release(&dst); + PyErr_SetString(PyExc_TypeError, "dst must be int16"); + return NULL; + } + Py_buffer src = {0}; + if (PyObject_GetBuffer(src_obj, &src, + PyBUF_C_CONTIGUOUS | PyBUF_FORMAT) < 0) { + PyBuffer_Release(&dst); + return NULL; + } + char src_code = audiodsp_native_format(src.format); + Py_ssize_t width = src_code == 'f' ? (Py_ssize_t)sizeof(float) + : src_code == 'd' ? (Py_ssize_t)sizeof(double) : 0; + if (width == 0) { + PyBuffer_Release(&dst); + PyBuffer_Release(&src); + PyErr_SetString(PyExc_TypeError, "src must be float32 or float64"); + return NULL; + } + Py_ssize_t count = src.len / width; + if (dst.len != count * (Py_ssize_t)sizeof(int16_t)) { + PyBuffer_Release(&dst); + PyBuffer_Release(&src); + PyErr_SetString(PyExc_ValueError, + "dst and src must have the same length"); + return NULL; + } + if (width == (Py_ssize_t)sizeof(float)) { + audiodsp_cast_s16_f32((int16_t *)dst.buf, (const float *)src.buf, + (size_t)count, (float)scale); + } else { + audiodsp_cast_s16_f64((int16_t *)dst.buf, (const double *)src.buf, + (size_t)count, scale); + } + PyBuffer_Release(&dst); + PyBuffer_Release(&src); + Py_RETURN_NONE; +} + // audiomath.SubOctave's divider. Unlike multiply_s16() above -- which is a // plain function, because the multiply carries no state at all -- the divider // is nothing but state: the count has to survive from one block to the next, @@ -4494,6 +4577,7 @@ static PyMethodDef audiodsp_methods[] = { {"midside_s16", audiodsp_midside_s16, METH_VARARGS, NULL}, {"meter_db_byte", audiodsp_meter_db_byte_py, METH_VARARGS, NULL}, {"remix_s16", audiodsp_py_remix_s16, METH_VARARGS, PyDoc_STR("Interleaved s16 native-endian channel convert between 1 and 2 channels. Optional writable dest.")}, + {"cast_into", audiodsp_py_cast_into, METH_VARARGS, PyDoc_STR("cast_into(dst, src, scale): float32 or float64 src * scale into int16 dst, rounded half away from zero and saturated. Allocates nothing.")}, {NULL, NULL, 0, NULL}, }; diff --git a/src/cpython/audiomath.py b/src/cpython/audiomath.py index 4897ddd9..0f0d3cf2 100644 --- a/src/cpython/audiomath.py +++ b/src/cpython/audiomath.py @@ -35,6 +35,10 @@ native-endian s16, 1↔2, optional writable destination so a caller can reuse scratch. `audiomixer.Mixer` cannot do this — its sources must already match `channel_count`. + +`cast_into` is not a node either. It turns a float block into the int16 +buffer a `RawSample` plays, in place, so a host that keeps its blocks in +float (numpy, ulab) can feed a node every block without a temporary. """ from audiocore import ( @@ -62,6 +66,20 @@ def remix_s16(source, src_channels, dst_channels, dest=None): return _audiodsp.remix_s16(source, int(src_channels), int(dst_channels), dest) + +def cast_into(dst, src, scale=32767.0): + """Write ``src * scale`` into ``dst`` as int16, allocating nothing. + + ``src`` is float32 or float64 (a numpy array, an ``array('f')`` or + ``array('d')``, anything with that buffer); ``dst`` is int16, or the bytes + of int16 samples, as many as ``src`` has. Each sample is rounded half away + from zero and clamped to -32768..32767, and NaN becomes 0. The product is + taken at the source's width, so it is the number ``src * scale`` would + have made. + """ + _audiodsp.cast_into(dst, src, float(scale)) + + #: Option name -> the native configure() slot, in the order #: shared/audiodsp_suboctave.h declares them, which is the order the #: MicroPython bindings list them in too. @@ -283,4 +301,4 @@ def _get_buffer(self, single_channel_output=False, audio_channel=0): return GET_BUFFER_MORE_DATA, self._publish(output) -__all__ = ("Multiply", "SubOctave") +__all__ = ("Multiply", "SubOctave", "cast_into") diff --git a/src/shared/audiodsp_cast.c b/src/shared/audiodsp_cast.c new file mode 100644 index 00000000..b0dd5bdf --- /dev/null +++ b/src/shared/audiodsp_cast.c @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT + +#include "shared/audiodsp_fp_contract.h" + +#include "shared/audiodsp_cast.h" + +// Rounding is written out rather than left to round() and roundf(), so no +// build needs libm for it, and the fraction is taken after the product has +// been rounded to the source's width: with contraction off (the header above) +// `v - i` cannot fuse with the multiply and see a product nobody stored. +// +// The comparisons come first and in this order because each one is false for +// NaN: a NaN falls through both clamps and is caught by the third test, which +// nothing else left can fail. + +static int16_t cast_one_f32(float v) { + if (v >= 32767.0f) { + return 32767; + } + if (v <= -32768.0f) { + return -32768; + } + if (!(v > -32768.0f)) { + return 0; + } + int32_t i = (int32_t)v; + float frac = v - (float)i; + if (frac >= 0.5f) { + i += 1; + } else if (frac <= -0.5f) { + i -= 1; + } + return (int16_t)i; +} + +static int16_t cast_one_f64(double v) { + if (v >= 32767.0) { + return 32767; + } + if (v <= -32768.0) { + return -32768; + } + if (!(v > -32768.0)) { + return 0; + } + int32_t i = (int32_t)v; + double frac = v - (double)i; + if (frac >= 0.5) { + i += 1; + } else if (frac <= -0.5) { + i -= 1; + } + return (int16_t)i; +} + +void audiodsp_cast_s16_f32(int16_t *dst, const float *src, size_t count, + float scale) { + for (size_t i = 0; i < count; i++) { + dst[i] = cast_one_f32(src[i] * scale); + } +} + +void audiodsp_cast_s16_f64(int16_t *dst, const double *src, size_t count, + double scale) { + for (size_t i = 0; i < count; i++) { + dst[i] = cast_one_f64(src[i] * scale); + } +} diff --git a/src/shared/audiodsp_cast.h b/src/shared/audiodsp_cast.h new file mode 100644 index 00000000..c3dd3f3f --- /dev/null +++ b/src/shared/audiodsp_cast.h @@ -0,0 +1,28 @@ +// Runtime-neutral float block to s16: scaled, rounded and saturated. +// +// New code -- not a CircuitPython port. A host that builds its blocks in +// float (a ulab or numpy array) has to turn each one into s16 before a +// RawSample can hand it to a node. Array libraries do that through a +// temporary the size of the block; this writes straight into the caller's +// buffer, so a block path can convert without allocating. +// +// Each sample is src[i] * scale, rounded half away from zero (C's round(), +// which is also what ulab's float-to-int cast uses), then clamped to +// [-32768, 32767]. NaN becomes 0. The product is taken at the source's own +// width, float for float32 and double for float64, so it is the same number +// an array library's `block * scale` would have made. +// +// dst may alias the start of src: dst[i] never reaches a float the loop has +// still to read. +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +void audiodsp_cast_s16_f32(int16_t *dst, const float *src, size_t count, + float scale); +void audiodsp_cast_s16_f64(int16_t *dst, const double *src, size_t count, + double scale); diff --git a/tests/parity/cast_probe.py b/tests/parity/cast_probe.py new file mode 100644 index 00000000..f88e0faa --- /dev/null +++ b/tests/parity/cast_probe.py @@ -0,0 +1,118 @@ +"""audiomath.cast_into on every target: the same int16 from the same floats. + + cast_probe.py audiomath + +The arithmetic is shared/audiodsp_cast.c on all three, so a line that differs +between two interpreters is a width or a compiler's choice, never intent. The +inputs sit where a cast goes wrong: exact halves either side of zero (round +half away, not to even and not toward zero), the floats just inside them, the +two rails and past them, infinities and NaN, at float32 and at float64. + +On the native builds the probe also asks the heap. A host calls this every +block, and the reason it exists is that an array library's cast allocates a +temporary; a build where it allocates anything exits 1 here. CPython has no +heap counter to ask and skips that half. +""" + +import sys +from array import array + +MODULE = sys.argv[1] if len(sys.argv) > 1 else "audiomath" +math_module = __import__(MODULE) + +INF = float("inf") +NAN = float("nan") + +# Every input and scale is exact in float32, so a build whose Python float is +# float32 (CI's float cell) hands the C the same numbers CPython does, and no +# line depends on how an interpreter parses a decimal. Hence the powers of two. +ULP_HALF = 1 / 33554432 # 2**-25, one float32 step below 0.5 +ULP_ONE = 1 / 16777216 # 2**-24, one step above 0.5 and below 1.0 + +HALVES = [0.5, 1.5, 2.5, -0.5, -1.5, -2.5, 32766.5, -32767.5, 0.0, -0.0] +NEAR = [0.5 - ULP_HALF, ULP_HALF - 0.5, 0.5 + ULP_ONE, -0.5 - ULP_ONE, + 1.5 - 2 * ULP_ONE, 2 * ULP_ONE - 1.5, 32767.25, 32767.75, -32768.25, + -32768.75] +WIDE = [1.0, -1.0, 1.5, -1.5, 2.0, -2.0, 3.0, -3.0, INF, -INF, NAN, + 2.0 ** 100] + + +def ramp(count=257): + """A ramp across +-2.2 in steps of 2**-17: past both rails at the vrack + bridge's 16384, where the products land on every eighth (exact halves + among them), and well past them at the default.""" + return [((i * 7919) % 576717 - 288358) / 131072 for i in range(count)] + + +def show(tag, typecode, values, scale=None): + src = array(typecode, values) + dst = array("h", bytes(2 * len(src))) + if scale is None: + math_module.cast_into(dst, src) + else: + math_module.cast_into(dst, src, scale) + print("cast", tag, typecode, " ".join(str(v) for v in dst)) + + +for typecode in ("f", "d"): + show("halves", typecode, HALVES, 1.0) + show("near", typecode, NEAR, 1.0) + show("wide", typecode, WIDE) + show("bridge", typecode, WIDE, 16384.0) + show("ramp", typecode, ramp(), 16384.0) + show("ramp-default", typecode, ramp()) + show("odd-scale", typecode, ramp(), 12345.75) + +# Bytes of int16 samples are a destination too, and scale is a keyword. +raw = bytearray(8) +math_module.cast_into(raw, array("f", [0.25, -0.25, 1.0, -1.0]), + scale=4.0) +print("cast bytes", " ".join(str(b) for b in raw)) + +errors = [] +for name, call in ( + ("short-dst", lambda: math_module.cast_into(array("h", [0, 0]), + array("f", [0.0] * 3))), + ("long-dst", lambda: math_module.cast_into(array("h", [0] * 4), + array("f", [0.0] * 3))), + ("int-src", lambda: math_module.cast_into(array("h", [0] * 3), + array("h", [0] * 3))), + ("float-dst", lambda: math_module.cast_into(array("f", [0.0] * 3), + array("f", [0.0] * 3))), +): + try: + call() + errors.append("%s:none" % name) + except ValueError: + errors.append("%s:ValueError" % name) + except TypeError: + errors.append("%s:TypeError" % name) +print("cast errors", " ".join(errors)) + +try: + import gc + mem_alloc = gc.mem_alloc +except (ImportError, AttributeError): + mem_alloc = None + +if mem_alloc is not None: + src = array("f", ramp(256)) + wide = array("d", ramp(256)) + dst = array("h", bytes(512)) + cast = math_module.cast_into + scale = 16384.0 + + def blocks(): + for _ in range(100): + cast(dst, src, scale) + cast(dst, wide, scale) + cast(dst, src) + + blocks() + gc.collect() + before = mem_alloc() + blocks() + grew = mem_alloc() - before + if grew: + print("cast_into allocated %d bytes over 300 calls" % grew) + sys.exit(1) diff --git a/tests/parity/verify_dsp.py b/tests/parity/verify_dsp.py index 9e2233b2..c1cb0635 100644 --- a/tests/parity/verify_dsp.py +++ b/tests/parity/verify_dsp.py @@ -96,6 +96,7 @@ ("midside_probe.py", "audioroute", {}, None), ("multiply_probe.py", "audiomath", {}, None), ("suboctave_probe.py", "audiomath", {}, None), + ("cast_probe.py", "audiomath", {}, None), ("feedback_delay_probe.py", "audioecho", {}, None), ("feedback_delay_options_probe.py", "audioecho", {}, None), # The node's own state: the damping floor (#157), then the filters out diff --git a/tests/test_cpython_audiomath.py b/tests/test_cpython_audiomath.py index 2bf3525a..9560a723 100644 --- a/tests/test_cpython_audiomath.py +++ b/tests/test_cpython_audiomath.py @@ -8,6 +8,7 @@ """ import math +import struct import unittest from array import array @@ -187,6 +188,141 @@ def test_set_does_not_restart_the_count_but_clear_does(self): self.assertNotEqual(list(render(third, 1)), before) +def _f32(value): + return struct.unpack("f", struct.pack("f", value))[0] + + +def reference_cast(value, scale, typecode): + """What cast_into is specified to do, in Python: the product at the + source's width, rounded half away from zero, clamped, NaN to 0. + + A float32 product is exact in a double (24 + 24 bits), so rounding that + double to float32 once is the float32 multiply.""" + if typecode == "f": + product = _f32(_f32(value) * _f32(scale)) + else: + product = value * scale + if product != product: + return 0 + if product >= 32767: + return 32767 + if product <= -32768: + return -32768 + whole = int(product) # toward zero, exact in this range + frac = product - whole + if frac >= 0.5: + whole += 1 + elif frac <= -0.5: + whole -= 1 + return whole + + +class CastIntoTest(unittest.TestCase): + """audiomath.cast_into: a float block into an int16 buffer the caller owns. + + The same C on every target is held identical by tests/parity/cast_probe.py + through verify_dsp.py, which also checks that the native builds allocate + nothing. This is the specification: the rounding, the rails and NaN.""" + + def cast(self, values, scale=None, typecode="f"): + src = array(typecode, values) + dst = array("h", bytes(2 * len(src))) + if scale is None: + self.assertIsNone(audiomath.cast_into(dst, src)) + else: + self.assertIsNone(audiomath.cast_into(dst, src, scale)) + return list(dst) + + def test_halves_round_away_from_zero(self): + # Not to even (2.5 -> 2) and not toward zero (1.5 -> 1). + for typecode in ("f", "d"): + self.assertEqual( + [1, 2, 3, -1, -2, -3, 0, 0], + self.cast([0.5, 1.5, 2.5, -0.5, -1.5, -2.5, 0.49999997, + -0.49999997], 1.0, typecode)) + + def test_default_scale_is_32767(self): + self.assertEqual([32767, -32767, 16384, 0], + self.cast([1.0, -1.0, 0.5, 0.0])) + + def test_rails_saturate(self): + inf = float("inf") + for typecode in ("f", "d"): + self.assertEqual( + [32767, -32768, 32767, -32768, 32767, -32768], + self.cast([1.5, -1.5, 1e30, -1e30, inf, -inf], None, + typecode)) + # The vrack bridge's scale: 1.0 is 16384, so +-2 is the rail. + self.assertEqual( + [16384, -16384, 32767, -32768, 32767, -32768], + self.cast([1.0, -1.0, 2.0, -2.0, 3.0, -3.0], 16384.0, + typecode)) + self.assertEqual([32767, -32768, 32767, -32768], + self.cast([32767.4, -32768.4, 32767.6, + -32768.6], 1.0, typecode)) + + def test_nan_is_zero(self): + for typecode in ("f", "d"): + self.assertEqual([0, 0], self.cast([float("nan"), -float("nan")], + 16384.0, typecode)) + + def test_matches_the_specification_across_a_sweep(self): + values = [((i * 7919) % 44001 - 22000) / 10000.0 for i in range(4001)] + values += [k / 16384.0 + 0.5 / 16384.0 for k in range(-40, 40)] + for typecode in ("f", "d"): + for scale in (16384.0, 32767.0, 12345.678, 1.0): + self.assertEqual( + [reference_cast(v, scale, typecode) for v in values], + self.cast(values, scale, typecode), + "%s at %r" % (typecode, scale)) + + def test_scale_is_a_keyword(self): + dst = array("h", [0, 0]) + audiomath.cast_into(dst, array("f", [0.25, -0.25]), scale=4) + self.assertEqual([1, -1], list(dst)) + + def test_writes_into_the_bytes_of_int16_samples(self): + dst = bytearray(4) + audiomath.cast_into(dst, array("d", [1.0, -1.0]), 100.0) + self.assertEqual(bytes(array("h", [100, -100])), bytes(dst)) + + def test_length_mismatch_raises(self): + for length in (2, 4, 0): + with self.assertRaises(ValueError): + audiomath.cast_into(array("h", [0] * length), + array("f", [0.0] * 3)) + with self.assertRaises(ValueError): + audiomath.cast_into(bytearray(5), array("f", [0.0] * 3)) + + def test_wrong_types_raise(self): + with self.assertRaises(TypeError): + audiomath.cast_into(array("h", [0] * 3), array("h", [0] * 3)) + with self.assertRaises(TypeError): + audiomath.cast_into(array("f", [0.0] * 3), array("f", [0.0] * 3)) + # Read-only: CPython says BufferError, MicroPython TypeError. + with self.assertRaises((TypeError, BufferError)): + audiomath.cast_into(bytes(6), array("f", [0.0] * 3)) + + def test_numpy_arrays(self): + try: + import numpy as np + except ImportError: + self.skipTest("numpy is not installed") + src = np.linspace(-2.5, 2.5, 1001, dtype=np.float32) + dst = np.zeros(1001, dtype=np.int16) + audiomath.cast_into(dst, src, 16384.0) + self.assertEqual([reference_cast(float(v), 16384.0, "f") for v in src], + dst.tolist()) + wide = src.astype(np.float64) + audiomath.cast_into(dst, wide, 16384.0) + self.assertEqual( + [reference_cast(float(v), 16384.0, "d") for v in wide], + dst.tolist()) + # A strided view has no contiguous buffer to write through. + with self.assertRaises((TypeError, ValueError, BufferError)): + audiomath.cast_into(np.zeros(2002, dtype=np.int16)[::2], src) + + if __name__ == "__main__": unittest.main()