1import type { TempoEvent, TimeSigEvent } from '../types.ts';
3// TempoMap converts between ticks and seconds under a piecewise-constant
4// tempo, and enumerates beat/bar grid lines under the time signature map.
6interface Segment {
7 tick: number;
8 sec: number;
9 secPerTick: number;
10}
12export class TempoMap {
13 private segs: Segment[];
15 constructor(readonly ppq: number, tempo: TempoEvent[], readonly timeSig: TimeSigEvent[] = []) {
16 const evs = [...tempo].sort((a, b) => a.tick - b.tick);
17 if (evs.length === 0 || evs[0].tick !== 0) evs.unshift({ tick: 0, usPerQuarter: 500000 });
18 this.segs = [];
19 let sec = 0;
20 for (let i = 0; i < evs.length; i++) {
21 const e = evs[i];
22 if (i > 0) {
23 const prev = this.segs[this.segs.length - 1];
24 sec = prev.sec + (e.tick - prev.tick) * prev.secPerTick;
25 }
26 const secPerTick = e.usPerQuarter / 1e6 / ppq;
27 // A later event at the same tick replaces the earlier one.
28 if (this.segs.length > 0 && this.segs[this.segs.length - 1].tick === e.tick) {
29 this.segs[this.segs.length - 1] = { tick: e.tick, sec, secPerTick };
30 } else {
31 this.segs.push({ tick: e.tick, sec, secPerTick });
32 }
33 }
34 if (this.timeSig.length === 0 || this.timeSig[0].tick !== 0) {
35 this.timeSig = [{ tick: 0, numerator: 4, denominator: 4 }, ...this.timeSig];
36 }
37 this.timeSig = [...this.timeSig].sort((a, b) => a.tick - b.tick);
38 }
40 private segAtTick(tick: number): Segment {
41 let lo = 0;
42 let hi = this.segs.length - 1;
43 while (lo < hi) {
44 const mid = (lo + hi + 1) >> 1;
45 if (this.segs[mid].tick <= tick) lo = mid;
46 else hi = mid - 1;
47 }
48 return this.segs[lo];
49 }
51 private segAtSec(sec: number): Segment {
52 let lo = 0;
53 let hi = this.segs.length - 1;
54 while (lo < hi) {
55 const mid = (lo + hi + 1) >> 1;
56 if (this.segs[mid].sec <= sec) lo = mid;
57 else hi = mid - 1;
58 }
59 return this.segs[lo];
60 }
62 tickToSec(tick: number): number {
63 const s = this.segAtTick(Math.max(0, tick));
64 return s.sec + (tick - s.tick) * s.secPerTick;
65 }
67 secToTick(sec: number): number {
68 const s = this.segAtSec(Math.max(0, sec));
69 return s.tick + (sec - s.sec) / s.secPerTick;
70 }
72 bpmAt(tick: number): number {
73 const s = this.segAtTick(Math.max(0, tick));
74 return 60 / (s.secPerTick * this.ppq);
75 }
77 // Bar/beat position for display, 1-based.
78 barBeatAt(tick: number): { bar: number; beat: number } {
79 let bar = 0;
80 let t = 0;
81 for (let i = 0; i < this.timeSig.length; i++) {
82 const sig = this.timeSig[i];
83 const next = i + 1 < this.timeSig.length ? this.timeSig[i + 1].tick : Infinity;
84 const beatTicks = (this.ppq * 4) / sig.denominator;
85 const barTicks = beatTicks * sig.numerator;
86 if (tick < next) {
87 const rel = tick - sig.tick;
88 const barsIn = Math.floor(rel / barTicks);
89 const beatIn = Math.floor((rel - barsIn * barTicks) / beatTicks);
90 return { bar: bar + barsIn + 1, beat: beatIn + 1 };
91 }
92 bar += Math.round((next - sig.tick) / barTicks);
93 t = next;
94 }
95 void t;
96 return { bar: 1, beat: 1 };
97 }
99 // Beat grid lines in [fromTick, toTick), marking bar starts.
100 gridLines(fromTick: number, toTick: number): { tick: number; isBar: boolean }[] {
101 const out: { tick: number; isBar: boolean }[] = [];
102 for (let i = 0; i < this.timeSig.length; i++) {
103 const sig = this.timeSig[i];
104 const next = i + 1 < this.timeSig.length ? this.timeSig[i + 1].tick : toTick;
105 if (next <= fromTick || sig.tick >= toTick) continue;
106 const beatTicks = (this.ppq * 4) / sig.denominator;
107 const start = Math.max(sig.tick, fromTick);
108 let k = Math.floor((start - sig.tick) / beatTicks);
109 for (;;) {
110 const tick = sig.tick + k * beatTicks;
111 if (tick >= Math.min(next, toTick)) break;
112 if (tick >= fromTick) out.push({ tick, isBar: k % sig.numerator === 0 });
113 k++;
114 if (out.length > 20000) return out; // safety
115 }
116 }
117 return out;
118 }
119}