File size: 6,031 Bytes
b2e4883
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
/**
 * Pattern engine tests.
 *
 * These are the foundation's correctness contract. They run on every PR.
 * If they break, the demo breaks — fix the engine, not the test.
 */

import { describe, it, expect } from "vitest";
import {
  expandPattern,
  bpmToMicrosecondsPerQuarter,
  ticksPerBar,
  TICKS_PER_QUARTER,
} from "../lib/patterns/engine";
import dbeat from "../data/patterns/d-beat.json";
import skank from "../data/patterns/skank.json";
import type { PatternTemplate } from "@patterntalk/shared-types";

const DB = dbeat as PatternTemplate;
const SK = skank as PatternTemplate;

describe("engine math primitives", () => {
  it("computes ticks-per-bar correctly for 4/4", () => {
    expect(ticksPerBar({ numerator: 4, denominator: 4 })).toBe(1920);
  });

  it("converts BPM to microseconds-per-quarter-note", () => {
    expect(bpmToMicrosecondsPerQuarter(120)).toBe(500000);
    expect(bpmToMicrosecondsPerQuarter(180)).toBe(333333);
    expect(bpmToMicrosecondsPerQuarter(60)).toBe(1000000);
  });
});

describe("expandPattern — d-beat", () => {
  const events = expandPattern({ template: DB, bars: 1, bpm: 180 });

  it("preserves the template's hit count for 1 bar", () => {
    expect(events).toHaveLength(DB.hits.length);
  });

  it("produces 4x hits for 4 bars", () => {
    const fourBars = expandPattern({ template: DB, bars: 4, bpm: 180 });
    expect(fourBars).toHaveLength(DB.hits.length * 4);
  });

  it("starts at tick 0 and respects the 1920 ticks-per-bar window", () => {
    const tickValues = events.map((e) => e.tick);
    expect(tickValues[0]).toBe(0);
    expect(Math.max(...tickValues)).toBeLessThan(1920);
  });

  it("sorts events by tick ascending", () => {
    const sorted = [...events].sort((a, b) => a.tick - b.tick);
    expect(events.map((e) => e.tick)).toEqual(sorted.map((e) => e.tick));
  });

  it("keeps all velocities in 1–127 range (humanize clamp)", () => {
    for (const ev of events) {
      expect(ev.velocity).toBeGreaterThanOrEqual(1);
      expect(ev.velocity).toBeLessThanOrEqual(127);
    }
  });

  it("emits a mix of kick, snare, and ride-bell hits", () => {
    const limbs = new Set(events.map((e) => e.limb));
    expect(limbs.has("kick")).toBe(true);
    expect(limbs.has("snare")).toBe(true);
    expect(limbs.has("ride-bell")).toBe(true);
  });
});

describe("expandPattern — cymbal override", () => {
  it("replaces ride-bell hits with crash when cymbal override is { type: 'crash' }", () => {
    const events = expandPattern({
      template: DB,
      bars: 1,
      bpm: 180,
      overrides: { cymbal: { type: "crash", pattern: "8ths" } },
    });
    const rideBellCount = events.filter((e) => e.limb === "ride-bell").length;
    const crashCount = events.filter((e) => e.limb === "crash").length;
    expect(rideBellCount).toBe(0);
    expect(crashCount).toBeGreaterThan(0);
  });

  it("replaces hihat with hihat-open", () => {
    const events = expandPattern({
      template: SK,
      bars: 1,
      bpm: 120,
      overrides: { cymbal: { type: "hihat-open", pattern: "upstrokes" } },
    });
    expect(events.filter((e) => e.limb === "hihat-open").length).toBeGreaterThan(0);
    expect(events.filter((e) => e.limb === "hihat").length).toBe(0);
  });
});

describe("expandPattern — accents", () => {
  it("boosts velocity by +20 on beat 1", () => {
    const baseline = expandPattern({ template: DB, bars: 4, bpm: 180 });
    const accented = expandPattern({
      template: DB,
      bars: 4,
      bpm: 180,
      overrides: { accents: ["1"] },
    });

    // The first kick in each bar is at tick = barIndex * 1920.
    // Its velocity in DB.hits is 110; humanize can shift ±2.
    // With accents, raw velocity = 110 + 20 + humanize (range 128–132),
    // clamped to 127. So expected >= 127.
    const firstKickPerBar = [0, 1920, 3840, 5760].map(
      (tick) => accented.find((e) => e.tick === tick && e.limb === "kick")!,
    );
    for (const ev of firstKickPerBar) {
      expect(ev.velocity).toBeGreaterThanOrEqual(127);
      // Clamped to 127
      expect(ev.velocity).toBeLessThanOrEqual(127);
    }
    // Sanity: without accents, same hit should be lower.
    const firstUnaccented = baseline
      .filter((e) => e.limb === "kick" && [0, 1920, 3840, 5760].includes(e.tick))
      .map((e) => e.velocity);
    expect(firstUnaccented.every((v) => v < 127)).toBe(true);
  });

  it("regression: accents apply to ALL bars, not just bar 0", () => {
    // Catches the bug where barTicks was being miscomputed from
    // bars[1]?.tick (which is undefined for ExpandedBar), making
    // accents only match for bar 0.
    const accented = expandPattern({
      template: DB,
      bars: 4,
      bpm: 180,
      overrides: { accents: ["1"] },
    });
    const beatOneKicks = [0, 1920, 3840, 5760]
      .map((tick) => accented.find((e) => e.tick === tick && e.limb === "kick"))
      .filter((e): e is NonNullable<typeof e> => e !== undefined);
    expect(beatOneKicks).toHaveLength(4);
    for (const ev of beatOneKicks) {
      expect(ev.velocity).toBe(127); // 110 + humanize(±2) + 20 → clamped
    }
  });
});

describe("expandPattern — integration sanity", () => {
  it("d-beat at 180 BPM for 4 bars produces a tick range equal to 4 × ticksPerBar", () => {
    const events = expandPattern({ template: DB, bars: 4, bpm: 180 });
    const maxTick = Math.max(...events.map((e) => e.tick));
    expect(maxTick).toBeLessThan(4 * ticksPerBar({ numerator: 4, denominator: 4 }));
  });

  it("skank at 120 BPM has 8 hits in 1 bar", () => {
    const events = expandPattern({ template: SK, bars: 1, bpm: 120 });
    expect(events).toHaveLength(SK.hits.length);
    // 2 kicks + 2 snares + 4 hihats
    expect(events.filter((e) => e.limb === "kick")).toHaveLength(2);
    expect(events.filter((e) => e.limb === "snare")).toHaveLength(2);
    expect(events.filter((e) => e.limb === "hihat")).toHaveLength(4);
  });

  it("respects TICKS_PER_QUARTER constant", () => {
    expect(TICKS_PER_QUARTER).toBe(480);
  });
});