From 4484ee4e8003fb89e2a39b5f81de8f454811fe8e Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 16:52:47 +0200 Subject: [PATCH 01/10] Fix saw and isaw returning negative numbers on negative t --- packages/core/signal.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 4398e2ab7..35fccb8f1 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import { Hap } from './hap.mjs'; import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs'; +import { _mod } from './util.mjs'; import Fraction from './fraction.mjs'; import { id, keyAlias, getCurrentKeyboardState } from './util.mjs'; @@ -33,7 +34,7 @@ export const signal = (func) => { * .scale('C major') * */ -export const saw = signal((t) => t % 1); +export const saw = signal((t) => _mod(t, 1)); /** * A sawtooth signal between -1 and 1 (like `saw`, but bipolar). @@ -56,7 +57,7 @@ export const saw2 = saw.toBipolar(); * .scale('C major') * */ -export const isaw = signal((t) => 1 - (t % 1)); +export const isaw = signal((t) => 1 - _mod(t, 1)); /** * A sawtooth signal between 1 and -1 (like `saw2`, but flipped). From 04232370c27fe8e1b0c6ebbdf57e31195ab4ddf0 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 17:11:52 +0200 Subject: [PATCH 02/10] Apply the same fix to square and randrun --- packages/core/signal.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 35fccb8f1..7fef18762 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -114,7 +114,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4)); * n(square.segment(4).range(0,7)).scale("C:minor") * */ -export const square = signal((t) => Math.floor((t * 2) % 2)); +export const square = signal((t) => Math.floor(_mod((t * 2), 2))); /** * A square signal between -1 and 1 (like `square`, but bipolar). @@ -397,7 +397,7 @@ export const randrun = (n) => { .map((n, i) => [n, i]) .sort((a, b) => (a[0] > b[0]) - (a[0] < b[0])) .map((x) => x[1]); - const i = t.cyclePos().mul(n).floor() % n; + const i = _mod(t.cyclePos().mul(n).floor(), n); return nums[i]; })._segment(n); }; From 429682735d97fdd5747f3f7b87a3001043458617 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 17:25:45 +0200 Subject: [PATCH 03/10] Formatting --- packages/core/signal.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 7fef18762..51e8b5a4e 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -114,7 +114,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4)); * n(square.segment(4).range(0,7)).scale("C:minor") * */ -export const square = signal((t) => Math.floor(_mod((t * 2), 2))); +export const square = signal((t) => Math.floor(_mod(t * 2, 2))); /** * A square signal between -1 and 1 (like `square`, but bipolar). From eafa2f17ecf716600b232a64130c6b62624f801f Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 18:03:12 +0200 Subject: [PATCH 04/10] Fix the same modulo bug in arp and slowcatPrime --- packages/core/pattern.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index aa5e1060f..f61c90452 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1002,7 +1002,7 @@ export const arpWith = register('arpWith', (func, pat) => { * */ export const arp = register( 'arp', - (indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[i % haps.length])), + (indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[_mod(i, haps.length)])), false, ); @@ -1559,7 +1559,7 @@ export function slowcat(...pats) { export function slowcatPrime(...pats) { pats = pats.map(reify); const query = function (state) { - const pat_n = Math.floor(state.span.begin) % pats.length; + const pat_n = _mod(Math.floor(state.span.begin), pats.length); const pat = pats[pat_n]; // can be undefined for same cases e.g. /#cHVyZSg0MikKICAuZXZlcnkoMyxhZGQoNykpCiAgLmxhdGUoLjUp return pat?.query(state) || []; }; From 9bf3249e82c37640a76683108e9c47edc2a42c80 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Wed, 5 Aug 2026 00:25:08 +0200 Subject: [PATCH 05/10] slowcatPrime pat can no longer be undefined --- packages/core/pattern.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index f61c90452..300544b29 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1557,11 +1557,14 @@ export function slowcat(...pats) { * @return {Pattern} */ export function slowcatPrime(...pats) { + if (!pats.length) { + return silence; + } pats = pats.map(reify); const query = function (state) { const pat_n = _mod(Math.floor(state.span.begin), pats.length); - const pat = pats[pat_n]; // can be undefined for same cases e.g. /#cHVyZSg0MikKICAuZXZlcnkoMyxhZGQoNykpCiAgLmxhdGUoLjUp - return pat?.query(state) || []; + const pat = pats[pat_n]; + return pat.query(state); }; return new Pattern(query).splitQueries(); } From f4b3fdaacdc689eba0494c1deb7828dfbc7ec8fc Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Wed, 5 Aug 2026 00:39:38 +0200 Subject: [PATCH 06/10] Update juxUndTollerei 1 tune snapshot --- test/__snapshots__/tunes.test.mjs.snap | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/__snapshots__/tunes.test.mjs.snap b/test/__snapshots__/tunes.test.mjs.snap index fc77616c4..1fb178801 100644 --- a/test/__snapshots__/tunes.test.mjs.snap +++ b/test/__snapshots__/tunes.test.mjs.snap @@ -7203,8 +7203,12 @@ exports[`renders tunes > tune: holyflute 1`] = ` exports[`renders tunes > tune: juxUndTollerei 1`] = ` [ + "[ -99/200 ⇜ (0/1 → 1/200) | note:63 s:triangle pan:0 cutoff:758.852817928549 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", + "[ -99/200 ⇜ (0/1 → 1/200) | note:67 s:triangle pan:1 color:green cutoff:758.852817928549 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 0/1 → 1/4 | note:c3 s:sawtooth pan:0 cutoff:1100 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 0/1 → 1/4 | note:bb3 s:sawtooth pan:1 color:green cutoff:1100 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", + "[ 1/200 → 101/200 | note:55 s:triangle pan:0 cutoff:1103.534282651425 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", + "[ 1/200 → 101/200 | note:65 s:triangle pan:1 color:green cutoff:1103.534282651425 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/4 → 1/2 | note:eb3 s:sawtooth pan:0 cutoff:1275.581289814515 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/4 → 1/2 | note:g3 s:sawtooth pan:1 color:green cutoff:1275.581289814515 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/2 → 3/4 | note:g3 s:sawtooth pan:0 cutoff:1444.415089128581 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", From bd54c8003a9287af513dbad3243c5f2b9e8c2dd8 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 01:00:33 +0200 Subject: [PATCH 07/10] Introduce isquare and isquare2 for signal parity --- packages/core/signal.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 51e8b5a4e..b86f16e8a 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -124,6 +124,22 @@ export const square = signal((t) => Math.floor(_mod(t * 2, 2))); */ export const square2 = square.toBipolar(); +/** + * A square signal between 1 and 0 (like `square` but flipped). + * + * @return {Pattern} + * @tags generators + */ +export const isquare = signal((t) => 1 - Math.floor(_mod(t * 2, 2))); + +/** + * A square signal between 1 and -1 (like `isquare`, but bipolar). + * + * @return {Pattern} + * @tags generators + */ +export const isquare2 = isquare.toBipolar(); + /** * A triangle signal between 0 and 1. * From 08dc5e2c5cb37cf764931344fe5bc12510dcd1e7 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 01:03:14 +0200 Subject: [PATCH 08/10] Add tests for all signals using modulo, including negative time --- packages/core/test/signal.test.mjs | 72 +++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/core/test/signal.test.mjs b/packages/core/test/signal.test.mjs index 26c1d656a..628864c79 100644 --- a/packages/core/test/signal.test.mjs +++ b/packages/core/test/signal.test.mjs @@ -8,7 +8,23 @@ import Fraction from 'fraction.js'; import { describe, it, expect, vi } from 'vitest'; -import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs'; +import { + saw, + saw2, + isaw, + isaw2, + tri, + tri2, + itri, + itri2, + square, + square2, + isquare, + isquare2, + per, + perx, + cyclesPer, +} from '../signal.mjs'; import { fastcat, sequence, State, TimeSpan, Hap, note } from '../index.mjs'; const st = (begin, end) => new State(ts(begin, end)); @@ -24,17 +40,61 @@ const sameFirst = (a, b) => { describe('signal()', () => { it('Can make saw/saw2', () => { - expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual( - sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(), - ); - + expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.25, 0.5, 0.75).firstCycle()); expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle()); }); it('Can make isaw/isaw2', () => { expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle()); - expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle()); }); + it('Can make tri/tri2', () => { + expect(tri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.5, 1, 0.5).firstCycle()); + expect(tri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle()); + }); + it('Can make itri/itri2', () => { + expect(itri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, 0.5).firstCycle()); + expect(itri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle()); + }); + it('Can make square/square2', () => { + expect(square.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle()); + expect(square2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -1, 1, 1).firstCycle()); + }); + it('Can make isquare/isquare2', () => { + expect(isquare.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, 0, 0).firstCycle()); + expect(isquare2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, -1, -1).firstCycle()); + }); + it('Can go into negative time', () => { + expect(saw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(0, 0.25, 0.5, 0.75).firstCycle(), + ); + expect(saw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(-1, -0.5, 0, 0.5).firstCycle(), + ); + expect(isaw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 0.75, 0.5, 0.25).firstCycle(), + ); + expect(isaw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 0.5, 0, -0.5).firstCycle(), + ); + expect(tri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(0, 0.5, 1, 0.5).firstCycle(), + ); + expect(tri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle()); + expect(itri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 0.5, 0, 0.5).firstCycle(), + ); + expect(itri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle()); + expect(square.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle()); + expect(square2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(-1, -1, 1, 1).firstCycle(), + ); + expect(isquare.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 1, 0, 0).firstCycle(), + ); + expect(isquare2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 1, -1, -1).firstCycle(), + ); + }); }); describe('cyclesPer', () => { From 76fa8264cdc2f2ffa7e9912e5d7fa600846b5d95 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 01:04:25 +0200 Subject: [PATCH 09/10] Remove old check in slowcat and add empty check at the start --- packages/core/pattern.mjs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 300544b29..0f5083901 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1529,7 +1529,9 @@ export function slowcat(...pats) { // Array test here is to avoid infinite recursions.. pats = pats.map((pat) => (Array.isArray(pat) ? fastcat(...pat) : reify(pat))); - if (pats.length == 1) { + if (!pats.length) { + return silence; + } else if (pats.length == 1) { return pats[0]; } @@ -1537,10 +1539,6 @@ export function slowcat(...pats) { const span = state.span; const pat_n = _mod(span.begin.sam(), pats.length); const pat = pats[pat_n]; - if (!pat) { - // pat_n can be negative, if the span is in the past.. - return []; - } // A bit of maths to make sure that cycles from constituent patterns aren't skipped. // For example if three patterns are slowcat-ed, the fourth cycle of the result should // be the second (rather than fourth) cycle from the first pattern. From 07f01c237f71b87eec5514d5a8743a32604f1518 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 01:04:48 +0200 Subject: [PATCH 10/10] Add tests for slowcat, slowcatPrime and arp --- packages/core/test/pattern.test.mjs | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index aaa14bd27..cb5345610 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -18,6 +18,7 @@ import { fastcat, firstOf, slowcat, + slowcatPrime, cat, sequence, palindrome, @@ -53,6 +54,7 @@ import { stepcat, sometimes, expand, + arp, } from '../index.mjs'; import { log, logValues } from '../pattern.mjs'; @@ -544,6 +546,9 @@ describe('Pattern', () => { }); }); describe('slowcat()', () => { + it('Can be empty', () => { + expect(slowcat().firstCycle()).toStrictEqual([]); + }); it('Can concatenate things slowly', () => { expect( slowcat('a', 'b') @@ -576,6 +581,38 @@ describe('Pattern', () => { sameFirst(slowcat('a', ['b', 'c']).fast(4), sequence('a', ['b', 'c']).fast(2)); }); }); + describe('slowcatPrime()', () => { + it('Can be empty', () => { + expect(slowcatPrime().firstCycle()).toStrictEqual([]); + }); + it('Can slowcat patterns swapping back and forth skipping the expected notes', () => { + expect( + slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2)) + .fast(4) + .firstCycle() + .map((a) => a.value), + ).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]); + }); + it('Can go into negative time', () => { + expect( + slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2)) + .fast(4) + .late(8) + .firstCycle() + .map((a) => a.value), + ).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]); + }); + }); + describe('arp()', () => { + it('It wraps around with both positive and negative indices', () => { + expect( + stack('a', 'b', 'c') + .arp(fastcat(-3, -2, -1, 0, 1, 2, 3, 4)) + .firstCycle() + .map((a) => a.value), + ).toStrictEqual(['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b']); + }); + }); describe('rev()', () => { it('Can reverse things', () => { expect(