From 2fc47b2554df55bdfd4723ae6c295df1634615df Mon Sep 17 00:00:00 2001 From: "John K. Luebs" Date: Wed, 13 Nov 2024 01:31:51 -0600 Subject: [PATCH] Update --- .gitignore | 1 + CMakeLists.txt | 81 +++--- demo/index.html | 39 ++- ecgsyn.cpp | 371 ++++++++++++++++++++++++---- mini-odeint/include/mini-odeint.hpp | 15 +- package.json | 6 +- pnpm-lock.yaml | 141 ++++++++++- src/index.ts | 333 ++++++++++++++++++++++++- 8 files changed, 888 insertions(+), 99 deletions(-) diff --git a/.gitignore b/.gitignore index c09716e..b9a5f09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .cache/ build/ +/cmake-*/ compile_commands.json /dist/ node_modules/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 6047ff0..992a2b6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,23 +1,30 @@ cmake_minimum_required(VERSION 3.20) set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) - -set(EMSCRIPTEN_ROOT $ENV{EMSDK}) -if(NOT EMSCRIPTEN_ROOT) - set(EMSCRIPTEN_ROOT /usr/lib/emscripten) -endif() - -set(EMSCRIPTEN_ROOT - "${EMSCRIPTEN_ROOT}" - CACHE PATH "Emscripten SDK path") - -set(CMAKE_TOOLCHAIN_FILE - "${EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake" - CACHE FILEPATH "Emscripten toolchain file") +set(CMAKE_POLICY_DEFAULT_CMP0069 NEW) project(ecgsyn.js) +option(BUILD_WITH_EMSCRIPTEN "Build with Emscripten" ON) option(BUILD_SHARED_LIBS "Build shared libraries" OFF) +if(BUILD_WITH_EMSCRIPTEN) + + set(EMSCRIPTEN_ROOT $ENV{EMSDK}) + if(NOT EMSCRIPTEN_ROOT) + set(EMSCRIPTEN_ROOT /usr/lib/emscripten) + endif() + + set(EMSCRIPTEN_ROOT + "${EMSCRIPTEN_ROOT}" + CACHE PATH "Emscripten SDK path") + + set(CMAKE_TOOLCHAIN_FILE + "${EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake" + CACHE FILEPATH "Emscripten toolchain file") + + set(BUILD_SHARED_LIBS OFF FORCE) +endif(BUILD_WITH_EMSCRIPTEN) + set(CMAKE_C_STANDARD 99) set(CMAKE_CXX_STANDARD 20) @@ -25,24 +32,40 @@ set(PFFFT_USE_TYPE_FLOAT OFF CACHE BOOL "activate pffft float" FORCE) -include_directories(BEFORE SYSTEM compat) -add_compile_options(-flto -msimd128 -mavx) -add_link_options(-flto) +include(CheckIPOSupported) +check_ipo_supported(RESULT ipo_supported) +if(ipo_supported) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON) +endif() + +if(BUILD_WITH_EMSCRIPTEN) + include_directories(BEFORE SYSTEM compat) + + add_compile_options(-msimd128 -msse -mavx -fwasm-exceptions) + set(ecgsyn_link_options # -flto + -fwasm-exceptions + -sEXPORTED_FUNCTIONS=_rr_gen_new,_rr_gen_new_series,_destroy_obj,_ecgsyn,_realloc,_free + --no-entry + -sSTRICT + -sNO_ASSERTIONS + -sNO_FILESYSTEM + -sMALLOC=emmalloc) + +else() + set(TARGET_C_ARCH haswell) + set(TARGET_CXX_ARCH haswell) + + set(ecgsyn_defs ECGSYN_HOST_BUILD) +endif() add_subdirectory(mini-odeint EXCLUDE_FROM_ALL) add_subdirectory(pffft EXCLUDE_FROM_ALL) add_executable(ecgsyn ecgsyn.cpp) -target_compile_options(ecgsyn PRIVATE) -target_link_options( - ecgsyn - PRIVATE - -flto - -sEXPORTED_FUNCTIONS=_ecgsyn - --no-entry - -sSTRICT - -sNO_ASSERTIONS - -sNO_FILESYSTEM - -sMALLOC=emmalloc) -target_link_libraries(ecgsyn PRIVATE PFFFT) -set_target_properties(ecgsyn PROPERTIES SUFFIX ".wasm") +target_compile_definitions(ecgsyn PRIVATE PFFFT_ENABLE_DOUBLE ${ecgsyn_defs}) +target_link_options(ecgsyn PRIVATE ${ecgsyn_link_options}) +target_link_libraries(ecgsyn PRIVATE PFFFT mini-odeint) + +if(BUILD_WITH_EMSCRIPTEN) + set_target_properties(ecgsyn PROPERTIES SUFFIX ".wasm") +endif(BUILD_WITH_EMSCRIPTEN) diff --git a/demo/index.html b/demo/index.html index b65beaf..c6b3510 100644 --- a/demo/index.html +++ b/demo/index.html @@ -8,15 +8,36 @@ + + +
+ +
+ + +
+
diff --git a/ecgsyn.cpp b/ecgsyn.cpp index 71dc360..b57629b 100644 --- a/ecgsyn.cpp +++ b/ecgsyn.cpp @@ -3,8 +3,8 @@ #include #include +#include "mini-odeint.hpp" #include "pffft.hpp" - #include "xoshiro.hpp" #if defined(__clang__) @@ -29,7 +29,7 @@ FORCE_INLINE constexpr auto sqr(auto &&x) noexcept(noexcept(x * x)) } template inline T stdev(std::span data) { - const auto n = T{data.size()}; + const auto n = static_cast(data.size()); const auto mean = std::accumulate(data.begin(), data.end(), T{}) / n; const auto variance = std::accumulate(data.begin(), data.end(), T{}, @@ -38,43 +38,53 @@ template inline T stdev(std::span data) { return std::sqrt(variance); } +template +constexpr bool is_wasm_bindable_v = + std::conjunction_v, + std::is_standard_layout>; + } // namespace namespace ecgsyns { struct TimeParameters { - int num_beats = 12; - int sr_internal = 512; - int decimate_factor = 2; - double hr_mean = 60.0; - double hr_std = 1.0; - std::uint64_t seed = 0; + int num_beats{12}; + int sr_internal{512}; + int decimate_factor{2}; + double hr_mean{60.0}; + double hr_std{1.0}; + std::uint64_t seed{8}; }; +static_assert(is_wasm_bindable_v); struct RRParameters { - double flo = 0.1; - double flostd = 0.01; - double fhi = 0.25; - double fhistd = 0.01; - double lf_hf_ratio = 0.5; + double flo{0.1}; + double flostd{0.01}; + double fhi{0.25}; + double fhistd{0.01}; + double lf_hf_ratio{0.5}; }; +static_assert(is_wasm_bindable_v); -template class RRSeries { +template class RRSeries { +public: + struct Segment { + T end; + T value; + }; + +private: TimeParameters time_params_; RRParameters rr_params_; XoshiroCpp::Xoshiro256Plus rng_; std::size_t size_; - struct Segment { - T end; - T value; - }; std::vector segments_; public: RRSeries(TimeParameters time_params, RRParameters rr_params, XoshiroCpp::Xoshiro256Plus rng, std::span signal) - : time_params_(std::move(time_params)), rr_params_(std::move(rr_params)), - rng_(std::move(rng)), size_(signal.size()) { + : time_params_{time_params}, rr_params_{rr_params}, rng_{rng}, + size_{signal.size()} { const auto sr = static_cast(time_params_.sr_internal); @@ -82,16 +92,18 @@ public: T tecg{}; std::size_t i{}; - while (i < size_) { + while (i < signal.size()) { tecg += signal[i]; segments_.emplace_back(tecg, signal[i]); - i = static_cast(std::nearbyint(tecg * sr * T{0.5}) * - T{2.0}) + - 1; + i = 1 + static_cast(std::nearbyint(tecg * sr * T{0.5}) * + T{2.0}); } } } + constexpr std::size_t segments_size() const { return segments_.size(); } + constexpr const Segment *segments_data() const { return segments_.data(); } + T operator()(T t) const noexcept { auto lower = std::lower_bound( segments_.begin(), segments_.end(), t, @@ -102,30 +114,38 @@ public: return lower->value; } - const TimeParameters &timeParams() const noexcept { return time_params_; } - const RRParameters &rrParams() const noexcept { return rr_params_; } - const XoshiroCpp::Xoshiro256Plus &rng() const noexcept { return rng_; } - std::size_t size() const noexcept { return size_; } + [[nodiscard]] const TimeParameters &time_params() const noexcept { + return time_params_; + } + [[nodiscard]] const RRParameters &rr_params() const noexcept { + return rr_params_; + } + XoshiroCpp::Xoshiro256Plus &rng() noexcept { return rng_; } + [[nodiscard]] std::size_t size() const noexcept { return size_; } + [[nodiscard]] std::size_t output_size() const noexcept { + return size_ / time_params_.decimate_factor; + } }; class FFTException : public std::exception { public: - FFTException() {} - virtual const char *what() const throw() { return "FFTException"; } + [[nodiscard]] const char *what() const noexcept override { + return "FFTException"; + } }; -template class RRGenerator { +template class RRGenerator { TimeParameters time_params_; + XoshiroCpp::Xoshiro256Plus rng_; T rr_mean_; T rr_std_; std::size_t nrr_; - XoshiroCpp::Xoshiro256Plus rng_; pffft::Fft fft_; pffft::AlignedVector signal_; pffft::AlignedVector> spectrum_; public: - RRGenerator(const TimeParameters &time_params) + explicit RRGenerator(const TimeParameters &time_params) : time_params_(time_params), rng_(time_params.seed), rr_mean_(60.0 / time_params.hr_mean), rr_std_(60.0 * time_params.hr_std / sqr(time_params.hr_mean)), @@ -140,7 +160,20 @@ public: } } - std::vector generateSignal(const RRParameters ¶ms) { + constexpr std::size_t nrr() const noexcept { return nrr_; } + + constexpr const TimeParameters &time_params() const noexcept { + return time_params_; + } + + RRSeries generate(const RRParameters ¶ms) { + auto buf = std::make_unique_for_overwrite(nrr_); + std::span signal{buf.get(), nrr_}; + generate_signal(params, signal); + return {time_params_, params, rng_, signal}; + } + + void generate_signal(const RRParameters ¶ms, std::span output) { const T w1 = T{2.0 * M_PI} * params.flo; const T w2 = T{2.0 * M_PI} * params.fhi; const T c1 = T{2.0 * M_PI} * params.flostd; @@ -151,10 +184,10 @@ public: const T sr = time_params_.sr_internal; - const T dw = (sr / T{nrr_}) * T{2.0 * M_PI}; + const T dw = (sr / T(nrr_)) * T{2.0 * M_PI}; for (std::size_t i{}; auto &p : spectrum_) { - const T w = dw * T{i}; + const T w = dw * T(i); const T dw1 = w - w1; const T dw2 = w - w2; @@ -176,34 +209,272 @@ public: fft_.inverse(spectrum_, signal_); std::ranges::transform(signal_, signal_.begin(), - [this](T x) { return x * T{1.0} / T{nrr_}; }); - const T xstd = stdev(signal_); + [this](T x) { return x * T(1.0 / nrr_); }); + const T xstd = stdev(signal_); const T ratio = rr_std_ / xstd; std::vector result; result.reserve(nrr_); - std::ranges::transform(signal_, std::back_inserter(result), + std::ranges::transform(signal_, output.begin(), [ratio, this](T x) { return x * ratio + rr_mean_; }); - return result; } }; +template struct Attractor { + T theta; + T a; + T b; + T theta_rf; + + constexpr static Attractor make(T degrees, T a, T b, T rf = 0.0) { + return {degrees * M_PI / 180.0, a, b, rf}; + } +}; + +template >> +struct Parameters { + std::pair range{-0.4, 1.2}; + T noise_amplitude{}; + + U attractors; +}; + +template struct Parameters>> { + std::pair range{-0.4, 1.2}; + T noise_amplitude{}; + + std::vector> attractors{ + Attractor::make(-70.0, 1.2, 0.25, 0.25), + Attractor::make(-15.0, -5.0, 0.1, 0.5), + Attractor::make(0.0, 30, 0.1), + Attractor::make(15.0, -7.5, 0.1, 0.5), + Attractor::make(100.0, 0.75, 0.4, 0.25), + }; +}; + +template >> +std::size_t generate(const Parameters ¶ms, RRSeries &rr_series, + std::span zresult) { + + struct ExPoint { + T ti; + T ai; + T bi; + }; + auto &rng = rr_series.rng(); + + const auto sr_internal{rr_series.time_params().sr_internal}; + + const T hr_sec{rr_series.time_params().hr_mean / 60.0}; + const T hr_fact{std::sqrt(hr_sec)}; + + // adjust extrema parameters for mean heart rate + std::vector ex; + ex.reserve(params.attractors.size()); + for (const auto &a : params.attractors) { + ex.emplace_back(a.theta * std::pow(hr_sec, a.theta_rf), a.a, a.b * hr_fact); + } + + const T fhi{rr_series.rr_params().fhi}; + const auto nt{rr_series.size()}; + + const T dt{1.0 / static_cast(sr_internal)}; + + std::vector ts; + ts.reserve(nt); + for (std::size_t i{}; i < nt; ++i) { + ts.emplace_back(static_cast(i) * dt); + } + + using namespace mini_odeint; + Vec3 x0{1.0, 0.0, 0.04}; + std::vector> ys(nt); + explicitRungeKutta>( + std::span{ys}, std::span{ts}, x0, 1e-6, [&](Vec3 v, T t) { + const T ta{std::atan2(v.y, v.x)}; + + const T r0{1.0}; + const T a0{T{1.0} - std::sqrt(sqr(v.x) + sqr(v.y)) / r0}; + + const T w0{T{2.0 * M_PI} / rr_series(t)}; + + const T zbase{T{0.005} * std::sin(T{2.0 * M_PI} * fhi * t)}; + + Vec3 f{a0 * v.x - w0 * v.y, a0 * v.y + w0 * v.x, 0.0}; + + for (const auto &e : ex) { + const T dt{std::remainder(ta - e.ti, T{2.0 * M_PI})}; + + f.z += -e.ai * dt * std::exp(T{-0.5} * sqr(dt) / sqr(e.bi)); + } + f.z += T{-1.0} * (v.z + zbase); + return f; + }); + + // extract z and downsample to output rate + for (std::size_t i{}, j{}; i < nt && j < zresult.size(); + i += rr_series.time_params().decimate_factor, ++j) { + zresult[j] = ys[i].z; + } + + const auto [zmin, zmax] = std::ranges::minmax(zresult); + const T zrange = zmax - zmin; + + // Scale signal between -0.4 and 1.2 mV + // add uniformly distributed measurement noise + std::ranges::transform(zresult, zresult.begin(), [&](T z) { + return (params.range.second - params.range.first) * (z - zmin) / zrange + + params.range.first + + T{2.0 * XoshiroCpp::DoubleFromBits(rng()) - 1.0} * + params.noise_amplitude; + }); + return ys.size() / rr_series.time_params().decimate_factor; +} + } // namespace ecgsyns +namespace { + +template class WasmSpan { +public: + T *ptr; + std::size_t n; + +public: + using iterator = T *; + constexpr std::size_t size() const noexcept { return n; } + constexpr iterator begin() const noexcept { return ptr; } + constexpr iterator end() const noexcept { return ptr + n; } +}; + +class WrapperBase { +public: + virtual ~WrapperBase() = default; + + WrapperBase(const WrapperBase &) = delete; + WrapperBase &operator=(const WrapperBase &) = delete; + +protected: + WrapperBase() = default; +}; + +template struct Wrapper : public WrapperBase { + T value; + + explicit Wrapper(T value) : value{std::move(value)} {} +}; + +struct WObject { + enum class type : std::uint32_t { + kRRGenerator = 1, + kRRSeries, + } t; + + WObject(type t) : t{t} {} +}; + +struct WRRGenerator : WObject { + std::size_t nrr; + std::size_t output_size; + + WRRGenerator(std::size_t nrr, std::size_t output_size) + : WObject{type::kRRGenerator}, nrr{nrr}, output_size{output_size} {} +}; + +struct NRRGenerator : WRRGenerator { + ecgsyns::RRGenerator obj; + + NRRGenerator(ecgsyns::RRGenerator o) + : WRRGenerator{o.nrr(), o.nrr() / o.time_params().decimate_factor}, + obj{std::move(o)} {} +}; + +struct WRRSeries : WObject { + std::size_t size; + std::size_t output_size; + std::size_t nsegments; + const ecgsyns::RRSeries::Segment *segments; + WRRSeries(std::size_t size, std::size_t output_size, std::size_t nsegments, + const ecgsyns::RRSeries::Segment *segments) + : WObject{type::kRRSeries}, size{size}, output_size{output_size}, + nsegments{nsegments}, segments{segments} {} +}; + +struct NRRSeries : WRRSeries { + ecgsyns::RRSeries obj; + + NRRSeries(ecgsyns::RRSeries o) + : WRRSeries{o.size(), o.output_size(), o.segments_size(), + o.segments_data()}, + obj{std::move(o)} {} +}; + +} // namespace + +#include + extern "C" { -int ecgsyn() { +using namespace ecgsyns; + +WRRGenerator *rr_gen_new(const TimeParameters *time_params) { + return new NRRGenerator{ecgsyns::RRGenerator{*time_params}}; +} + +WRRSeries *rr_gen_new_series(WRRGenerator *generator, + const RRParameters *params) { + return new NRRSeries{ + static_cast(generator)->obj.generate(*params)}; +} + +void rr_gen_generate(WRRGenerator *generator, const RRParameters *params, + double *output) { + auto &rr = static_cast(generator)->obj; + rr.generate_signal(*params, {output, rr.nrr()}); +} + +void destroy_obj(const WObject *o) { + switch (o->t) { + case WObject::type::kRRGenerator: + delete static_cast(o); + break; + case WObject::type::kRRSeries: + delete static_cast(o); + break; + } +} + +using WasmParameters = Parameters>>; +static_assert(is_wasm_bindable_v); + +void ecgsyn(const WasmParameters *params, WRRSeries *rr_series, + double *output) { + + auto &rr = static_cast(rr_series)->obj; + ecgsyns::generate(*params, rr, {output, rr.output_size()}); +} +} + +#if defined(ECGSYN_HOST_BUILD) + +#include +int main() { using namespace ecgsyns; - RRGenerator rr_gen{TimeParameters{}}; - const auto rr = rr_gen.generateSignal(RRParameters{}); + TimeParameters time_params{}; + time_params.num_beats = 4; + auto rr = rr_init(&time_params); - RRSeries rr_series{TimeParameters{}, RRParameters{}, - XoshiroCpp::Xoshiro256Plus{}, rr}; + const RRParameters rr_params{}; + auto rrs = rr_generate(rr, &rr_params); - std::printf("%f", rr_series(0.0)); - // XoshiroCpp::DoubleFromBits(); - std::printf("hello world\n"); - return 69; -} + Parameters params; + std::vector output(rrs->value.output_size()); + generate(params, rrs->value, std::span(output)); + std::ofstream f{"ecg.csv"}; + for (const auto &x : output) { + f << x << '\n'; + } + return 0; } +#endif diff --git a/mini-odeint/include/mini-odeint.hpp b/mini-odeint/include/mini-odeint.hpp index a218a6b..4c5c0e0 100644 --- a/mini-odeint/include/mini-odeint.hpp +++ b/mini-odeint/include/mini-odeint.hpp @@ -62,10 +62,15 @@ template struct DormandPrince { 90730570.0 / 29380423.0, -8293050.0 / 29380423.0}}}; }; -template struct scalar_type { +template struct scalar_type { using type = T; }; +template +struct scalar_type> { + using type = typename T::value_type; +}; + template struct scalar_type { using type = T; }; @@ -269,10 +274,6 @@ inline std::floating_point auto inf_norm(std::floating_point auto v) { return std::abs(v); } -template inline auto inf_norm(const OdeVector &v) { - return inf_norm(static_cast(v)); -} - template inline E inf_norm(const Vec2 &v) { return std::max(std::abs(v.x), std::abs(v.y)); } @@ -281,6 +282,10 @@ template inline E inf_norm(const Vec3 &v) { return std::max({std::abs(v.x), std::abs(v.y), std::abs(v.z)}); } +template inline auto inf_norm(const OdeVector &v) { + return inf_norm(static_cast(v)); +} + } // namespace func template , diff --git a/package.json b/package.json index 635a2b9..86cb7de 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,18 @@ "./ecgsyn.wasm": "./dist/ecgsyn.wasm" }, "scripts": { - "build": "cmake --build build && tsc && cp -f build/ecgsyn.wasm ./dist", + "build": "cmake --build cmake-build-release-wasm && tsc && cp -f cmake-build-release-wasm/ecgsyn.wasm ./dist", "typecheck": "tsc --noEmit", "lint": "eslint .", "format": "prettier --write \"*.{js,ts,json,css,yml,yaml}\" \"**/*.{js,ts,json,css,yml.yaml}\"", "serve": "http-server -c-1", - "test": "vitest" + "test": "vitest", + "watch": "tsc -w" }, "devDependencies": { "@eslint/js": "^9.14.0", "http-server": "^14.1.1", + "nodemon": "^3.1.7", "prettier": "^3.3.3", "typescript": "^5.6.3", "typescript-eslint": "^8.13.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 374c067..5643f6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: http-server: specifier: ^14.1.1 version: 14.1.1 + nodemon: + specifier: ^3.1.7 + version: 3.1.7 prettier: specifier: ^3.3.3 version: 3.3.3 @@ -172,6 +175,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -185,6 +192,10 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -207,6 +218,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -350,6 +365,11 @@ packages: debug: optional: true + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -375,6 +395,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -415,6 +439,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -427,6 +454,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -505,6 +536,15 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nodemon@3.1.7: + resolution: {integrity: sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.3: resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==} engines: {node: '>= 0.4'} @@ -554,6 +594,9 @@ packages: engines: {node: '>=14'} hasBin: true + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -565,6 +608,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -609,10 +656,18 @@ packages: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} engines: {node: '>= 0.4'} + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -624,6 +679,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + ts-api-utils@1.4.0: resolution: {integrity: sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==} engines: {node: '>=16'} @@ -648,6 +707,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + union@0.5.0: resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} engines: {node: '>= 0.8.0'} @@ -687,7 +749,7 @@ snapshots: '@eslint/config-array@0.18.0': dependencies: '@eslint/object-schema': 2.1.4 - debug: 4.3.7 + debug: 4.3.7(supports-color@5.5.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -697,7 +759,7 @@ snapshots: '@eslint/eslintrc@3.1.0': dependencies: ajv: 6.12.6 - debug: 4.3.7 + debug: 4.3.7(supports-color@5.5.0) espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2 @@ -769,7 +831,7 @@ snapshots: '@typescript-eslint/types': 8.13.0 '@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.3) '@typescript-eslint/visitor-keys': 8.13.0 - debug: 4.3.7 + debug: 4.3.7(supports-color@5.5.0) eslint: 9.14.0 optionalDependencies: typescript: 5.6.3 @@ -785,7 +847,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.3) '@typescript-eslint/utils': 8.13.0(eslint@9.14.0)(typescript@5.6.3) - debug: 4.3.7 + debug: 4.3.7(supports-color@5.5.0) ts-api-utils: 1.4.0(typescript@5.6.3) optionalDependencies: typescript: 5.6.3 @@ -799,7 +861,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.13.0 '@typescript-eslint/visitor-keys': 8.13.0 - debug: 4.3.7 + debug: 4.3.7(supports-color@5.5.0) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -843,6 +905,11 @@ snapshots: dependencies: color-convert: 2.0.1 + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + argparse@2.0.1: {} async@2.6.4: @@ -855,6 +922,8 @@ snapshots: dependencies: safe-buffer: 5.1.2 + binary-extensions@2.3.0: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -883,6 +952,18 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -903,9 +984,11 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.7: + debug@4.3.7(supports-color@5.5.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 deep-is@0.1.4: {} @@ -949,7 +1032,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.5 - debug: 4.3.7 + debug: 4.3.7(supports-color@5.5.0) escape-string-regexp: 4.0.0 eslint-scope: 8.2.0 eslint-visitor-keys: 4.2.0 @@ -1032,6 +1115,9 @@ snapshots: follow-redirects@1.15.9: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-intrinsic@1.2.4: @@ -1058,6 +1144,8 @@ snapshots: graphemer@1.4.0: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -1109,6 +1197,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ignore-by-default@1.0.1: {} + ignore@5.3.2: {} import-fresh@3.3.0: @@ -1118,6 +1208,10 @@ snapshots: imurmurhash@0.1.4: {} + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1182,6 +1276,21 @@ snapshots: natural-compare@1.4.0: {} + nodemon@3.1.7: + dependencies: + chokidar: 3.6.0 + debug: 4.3.7(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.6.3 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + object-inspect@1.13.3: {} opener@1.5.2: {} @@ -1225,6 +1334,8 @@ snapshots: prettier@3.3.3: {} + pstree.remy@1.1.8: {} + punycode@2.3.1: {} qs@6.13.0: @@ -1233,6 +1344,10 @@ snapshots: queue-microtask@1.2.3: {} + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + requires-port@1.0.0: {} resolve-from@4.0.0: {} @@ -1273,8 +1388,16 @@ snapshots: get-intrinsic: 1.2.4 object-inspect: 1.13.3 + simple-update-notifier@2.0.0: + dependencies: + semver: 7.6.3 + strip-json-comments@3.1.1: {} + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -1285,6 +1408,8 @@ snapshots: dependencies: is-number: 7.0.0 + touch@3.1.1: {} + ts-api-utils@1.4.0(typescript@5.6.3): dependencies: typescript: 5.6.3 @@ -1306,6 +1431,8 @@ snapshots: typescript@5.6.3: {} + undefsafe@2.0.5: {} + union@0.5.0: dependencies: qs: 6.13.0 diff --git a/src/index.ts b/src/index.ts index f88bb05..3a472c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,13 @@ export interface Exports extends WebAssembly.Exports { memory: WebAssembly.Memory; _initialize(): void; - ecgsyn(): number; + rr_gen_new(time_params: number): number; + rr_gen_new_series(gen: number, params: number): number; + rr_gen_generate(gen: number, params: number, output: number): void; + destroy_obj(obj: number): void; + ecgsyn(params: number, rr_series: number, output: number): void; + realloc(ptr: number, size: number): number; + free(ptr: number): void; } let exports: Exports; @@ -103,7 +109,207 @@ class WASI { } } -export default async function load(): Promise { +export interface Attractor { + theta: number; + a: number; + b: number; + thetaRf: number; +} + +export function attractor(deg: number, a: number, b: number, thetaRf = 0): Attractor { + return { theta: (deg * Math.PI) / 180.0, a, b, thetaRf }; +} + +const freeRegistry = new FinalizationRegistry((ptr: number) => exports.free(ptr)); + +type Realloc = (p: number, s: number) => number; + +export class TimeParameters { + // layout: (num_beats: i32, sr_internal: i32, decimate_factor: i32, hr_mean: f64, hr_std: f64, seed: i64) + static readonly #SIZE = 40; + #view: DataView; + + constructor(exports: Exports) { + const ptr = exports.realloc(0, TimeParameters.#SIZE); + freeRegistry.register(this, ptr); + this.#view = new DataView(exports.memory.buffer, ptr, TimeParameters.#SIZE); + this.numBeats = 12; + this.srInternal = 512; + this.decimateFactor = 2; + this.hrMean = 60.0; + this.hrStd = 1.0; + this.seed = BigInt(8); + } + + set numBeats(value: number) { + this.#view.setInt32(0, value, true); + } + get numBeats(): number { + return this.#view.getInt32(0, true); + } + set srInternal(value: number) { + this.#view.setInt32(4, value, true); + } + get srInternal(): number { + return this.#view.getInt32(4, true); + } + set decimateFactor(value: number) { + this.#view.setInt32(8, value, true); + } + get decimateFactor(): number { + return this.#view.getInt32(8, true); + } + set hrMean(value: number) { + this.#view.setFloat64(16, value, true); + } + get hrMean(): number { + return this.#view.getFloat64(16, true); + } + set hrStd(value: number) { + this.#view.setFloat64(24, value, true); + } + get hrStd(): number { + return this.#view.getFloat64(24, true); + } + set seed(value: bigint) { + this.#view.setBigUint64(32, value, true); + } + get seed(): bigint { + return this.#view.getBigInt64(32, true); + } + + get _ptr(): number { + return this.#view.byteOffset; + } +} + +export class RRParameters { + // layout: (flo: f64, flostd: f64, fhi: f64, fhistd: f64, lf_hf_ratio: f64) + static readonly #SIZE = 40; + #view: DataView; + + constructor(exports: Exports) { + const ptr = exports.realloc(0, RRParameters.#SIZE); + freeRegistry.register(this, ptr); + this.#view = new DataView(exports.memory.buffer, ptr, RRParameters.#SIZE); + this.flo = 0.04; + this.flostd = 0.01; + this.fhi = 0.15; + this.fhistd = 0.025; + this.lfHfRatio = 1.0; + } + + set flo(value: number) { + this.#view.setFloat64(0, value, true); + } + get flo(): number { + return this.#view.getFloat64(0, true); + } + set flostd(value: number) { + this.#view.setFloat64(8, value, true); + } + get flostd(): number { + return this.#view.getFloat64(8, true); + } + set fhi(value: number) { + this.#view.setFloat64(16, value, true); + } + get fhi(): number { + return this.#view.getFloat64(16, true); + } + set fhistd(value: number) { + this.#view.setFloat64(24, value, true); + } + get fhistd(): number { + return this.#view.getFloat64(24, true); + } + set lfHfRatio(value: number) { + this.#view.setFloat64(32, value, true); + } + get lfHfRatio(): number { + return this.#view.getFloat64(32, true); + } + get _ptr(): number { + return this.#view.byteOffset; + } +} + +export class Parameters { + // layout (min: f64, max: f64, noiseAmplitude: f64, ptr: i32, n: i32) + static readonly #SIZE = 32; + static readonly #ATTRACTOR_SIZE = 32; + + attractors: Attractor[] = [ + attractor(-70.0, 1.2, 0.25, 0.25), + attractor(-15.0, -5.0, 0.1, 0.5), + attractor(0.0, 30.0, 0.1), + attractor(15.0, -7.5, 0.1, 0.5), + attractor(100.0, 0.75, 0.4, 0.25), + ]; + #view: DataView; + readonly #realloc: Realloc; + + static #size(n: number): number { + return Parameters.#SIZE + n * Parameters.#ATTRACTOR_SIZE; + } + + constructor(exports: Exports) { + this.#realloc = exports.realloc.bind(exports); + const sz = Parameters.#size(this.attractors.length); + const ptr = this.#realloc(0, sz); + freeRegistry.register(this, ptr, this); + + this.#view = new DataView(exports.memory.buffer, ptr, sz); + this.#view.setUint32(24, this.#view.byteOffset + Parameters.#SIZE, true); + + this.range = { min: -0.4, max: 1.2 }; + this.noiseAmplitude = 0.01; + } + + set range(value: { min: number; max: number }) { + this.#view.setFloat64(0, value.min, true); + this.#view.setFloat64(8, value.max, true); + } + + get range(): { min: number; max: number } { + return { min: this.#view.getFloat64(0, true), max: this.#view.getFloat64(8, true) }; + } + + set noiseAmplitude(value: number) { + this.#view.setFloat64(16, value, true); + } + + get noiseAmplitude(): number { + return this.#view.getFloat64(16, true); + } + + #setAttractors(): void { + if (this.#view.byteLength < Parameters.#size(this.attractors.length)) { + const sz = Parameters.#size(this.attractors.length); + const ptr = this.#realloc(this.#view.byteOffset, sz); + freeRegistry.unregister(this); + freeRegistry.register(this, ptr, this); + this.#view = new DataView(this.#view.buffer, ptr, sz); + } + + this.#view.setUint32(28, this.attractors.length, true); + let offset = Parameters.#SIZE; + for (const a of this.attractors) { + this.#view.setFloat64(offset, a.theta, true); + this.#view.setFloat64(offset + 8, a.a, true); + this.#view.setFloat64(offset + 16, a.b, true); + this.#view.setFloat64(offset + 24, a.thetaRf, true); + offset += Parameters.#ATTRACTOR_SIZE; + } + } + + get _ptr(): number { + this.#setAttractors(); + return this.#view.byteOffset; + } +} + +export async function load(): Promise { if (!exports) { const url = new URL("./ecgsyn.wasm", import.meta.url); const wasi = new WASI(); @@ -114,3 +320,126 @@ export default async function load(): Promise { } return exports; } + +const destroyRegistry = new FinalizationRegistry((ptr: number) => exports.destroy_obj(ptr)); + +export class ECGSyn { + #rrGenPtr: number; + #rrSeriesPtr: number; + #signal: Float64Array; + #view: DataView; + + readonly timeParams: TimeParameters; + readonly rrParams: RRParameters; + readonly parameters: Parameters; + + constructor() { + this.timeParams = new TimeParameters(exports); + this.rrParams = new RRParameters(exports); + this.parameters = new Parameters(exports); + + this.#rrGenPtr = 0; + this.#rrSeriesPtr = 0; + + this.#signal = new Float64Array(0); + this.#view = new DataView(exports.memory.buffer); + } + + rrProcess(): void { + const timeParams = this.timeParams; + const rrParams = this.rrParams; + timeParams.decimateFactor = 1; + timeParams.hrMean = 60; + timeParams.numBeats = 10; + timeParams.seed = BigInt(20); + + this.#rrGenPtr = exports.rr_gen_new(timeParams._ptr); + destroyRegistry.register(this, this.#rrGenPtr); + this.#rrSeriesPtr = exports.rr_gen_new_series(this.#rrGenPtr, rrParams._ptr); + destroyRegistry.register(this, this.#rrSeriesPtr); + + const length = this.#view.getUint32(this.#rrSeriesPtr + 4, true); + const mem = exports.realloc(0, length * Float64Array.BYTES_PER_ELEMENT); + this.#signal = new Float64Array(exports.memory.buffer, mem, length); + } + + compute(): void { + const params = this.parameters; + params.noiseAmplitude = 0.0; + + exports.ecgsyn(params._ptr, this.#rrSeriesPtr, this.#signal.byteOffset); + } + + update(): void { + const data = this.#signal; + + const canvas = document.getElementById("output") as HTMLCanvasElement; + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const minValue = -0.4; + const maxValue = 1.2; + const valueRange = maxValue - minValue; + + const padding = 40; + const plotWidth = canvas.width - padding * 2; + const plotHeight = canvas.height - padding * 2; + + ctx.strokeStyle = "black"; + ctx.lineWidth = 1; + ctx.beginPath(); + + ctx.moveTo(padding, padding); + ctx.lineTo(padding, canvas.height - padding); + + ctx.lineTo(canvas.width - padding, canvas.height - padding); + ctx.stroke(); + + ctx.fillStyle = "black"; + ctx.font = "12px sans-serif"; + + for (let i = 0; i < 8; i++) { + const value = minValue + (valueRange * i) / 8; + const y = canvas.height - padding - (i * plotHeight) / 8; + ctx.fillText(value.toFixed(1), padding - 30, y + 4); + + ctx.strokeStyle = "lightgray"; + ctx.beginPath(); + ctx.moveTo(padding, y); + ctx.lineTo(canvas.width - padding, y); + ctx.stroke(); + } + + // X axis labels + for (let i = 0; i < 8; i++) { + const x = padding + (i * plotWidth) / 8; + const value = ((i * data.length) / (8 * 512)).toFixed(2); + ctx.fillText(value, x - 10, canvas.height - padding + 20); + + // Grid lines + ctx.strokeStyle = "lightgray"; + ctx.beginPath(); + ctx.moveTo(x, padding); + ctx.lineTo(x, canvas.height - padding); + ctx.stroke(); + } + + ctx.strokeStyle = "#2563eb"; + ctx.lineWidth = 1; + + ctx.beginPath(); + + const initialX = padding; + const initialY = canvas.height - padding - ((data[0] - minValue) * plotHeight) / valueRange; + ctx.moveTo(initialX, initialY); + + for (let i = 1; i < data.length; i++) { + const x = padding + (i * plotWidth) / data.length; + const y = canvas.height - padding - ((data[i] - minValue) * plotHeight) / valueRange; + ctx.lineTo(x, y); + } + + ctx.stroke(); + } +}