| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { |
| Hit, |
| Limb, |
| MidiEvent, |
| ParsedRequest, |
| PatternTemplate, |
| } from "@patterntalk/shared-types"; |
|
|
| |
| export const TICKS_PER_QUARTER = 480; |
|
|
| |
| export function ticksPerBar( |
| timeSignature: { numerator: number; denominator: number }, |
| ): number { |
| |
| return TICKS_PER_QUARTER * timeSignature.numerator; |
| } |
|
|
| export interface EngineOverrides { |
| cymbal?: ParsedRequest["cymbal"]; |
| accents?: string[]; |
| feel?: ParsedRequest["feel"]; |
| } |
|
|
| export interface EngineInput { |
| template: PatternTemplate; |
| bars: number; |
| bpm: number; |
| timeSignature?: { numerator: number; denominator: number }; |
| overrides?: EngineOverrides; |
| } |
|
|
| |
| const CYMBAL_LIMBS: ReadonlySet<Limb> = new Set([ |
| "hihat", |
| "hihat-open", |
| "ride", |
| "ride-bell", |
| "crash", |
| "china", |
| "splash", |
| ]); |
|
|
| |
| function cymbalToLimb(type: NonNullable<ParsedRequest["cymbal"]>["type"]): Limb { |
| switch (type) { |
| case "crash": |
| return "crash"; |
| case "ride": |
| return "ride-bell"; |
| case "china": |
| return "china"; |
| case "hihat-open": |
| return "hihat-open"; |
| case "hihat-closed": |
| return "hihat"; |
| case "splash": |
| return "splash"; |
| } |
| } |
|
|
| interface ExpandedBar { |
| |
| barIndex: number; |
| hits: Array<{ hit: Hit; tick: number }>; |
| } |
|
|
| |
| |
| |
| |
| |
| function expandBars(input: EngineInput): ExpandedBar[] { |
| const ts = input.timeSignature ?? { numerator: 4, denominator: 4 }; |
| const barTicks = ticksPerBar(ts); |
| const template = input.template; |
| const repeatLength = Math.max(1, template.defaultBars); |
| const totalBars = Math.max(1, Math.floor(input.bars)); |
|
|
| const result: ExpandedBar[] = []; |
| for (let barIndex = 0; barIndex < totalBars; barIndex++) { |
| |
| const templateBarIndex = barIndex % repeatLength; |
|
|
| |
| const humanize = (barIndex * 17 + 11) % 5 - 2; |
|
|
| const hits: Array<{ hit: Hit; tick: number }> = []; |
| for (const hit of template.hits) { |
| |
| |
| |
| |
| |
| if (template.defaultBars === 1 || templateBarIndex === 0) { |
| |
| const pos = Math.max(0, Math.min(0.999_999, hit.position)); |
| const tick = Math.round(barIndex * barTicks + pos * barTicks); |
| const velocity = Math.max( |
| 1, |
| Math.min(127, hit.velocity + humanize), |
| ); |
| hits.push({ hit: { ...hit, velocity }, tick }); |
| } |
| } |
| result.push({ barIndex, hits }); |
| } |
| return result; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function applyCymbalOverride( |
| bars: ExpandedBar[], |
| cymbal: NonNullable<ParsedRequest["cymbal"]>, |
| ): ExpandedBar[] { |
| const target: Limb = cymbalToLimb(cymbal.type); |
| return bars.map((bar) => ({ |
| ...bar, |
| hits: bar.hits.map(({ hit, tick }) => |
| CYMBAL_LIMBS.has(hit.limb) |
| ? { hit: { ...hit, limb: target }, tick } |
| : { hit, tick }, |
| ), |
| })); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function applyAccents( |
| bars: ExpandedBar[], |
| accents: string[], |
| timeSignature: { numerator: number; denominator: number } = { numerator: 4, denominator: 4 }, |
| ): ExpandedBar[] { |
| const barTicks = ticksPerBar(timeSignature); |
| const quarter = TICKS_PER_QUARTER; |
|
|
| return bars.map((bar) => { |
| const hits = bar.hits.map(({ hit, tick }) => { |
| const localTick = tick - bar.barIndex * barTicks; |
| |
| const localQuarter = localTick / quarter; |
| const beat = Math.floor(localQuarter) + 1; |
| const isAnd = (localQuarter - Math.floor(localQuarter)) > 0.4; |
| const localPos = `${beat}${isAnd ? "-and" : ""}`; |
|
|
| const matched = accents.some((a) => { |
| if (a === localPos) return true; |
| if (a === "1" && beat === 1 && !isAnd) return true; |
| if (a === "3" && beat === 3 && !isAnd) return true; |
| if (a === "and-of-2" && beat === 2 && isAnd) return true; |
| if (a === "and-of-4" && beat === 4 && isAnd) return true; |
| return false; |
| }); |
|
|
| return matched |
| ? { |
| hit: { ...hit, velocity: Math.min(127, hit.velocity + 20), accent: true }, |
| tick, |
| } |
| : { hit, tick }; |
| }); |
| return { ...bar, hits }; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| function applyFeel( |
| bars: ExpandedBar[], |
| feel: NonNullable<ParsedRequest["feel"]>, |
| swingRatio: number, |
| timeSignature: { numerator: number; denominator: number } = { numerator: 4, denominator: 4 }, |
| ): ExpandedBar[] { |
| if (feel !== "swing") return bars; |
|
|
| const barTicks = ticksPerBar(timeSignature); |
| const quarter = TICKS_PER_QUARTER; |
| const swingOffset = Math.round(swingRatio * quarter); |
|
|
| return bars.map((bar) => ({ |
| ...bar, |
| hits: bar.hits.map(({ hit, tick }) => { |
| const localTick = tick - bar.barIndex * barTicks; |
| const localQuarter = localTick / quarter; |
| const isAnd = (localQuarter - Math.floor(localQuarter)) > 0.4; |
| return isAnd |
| ? { hit, tick: tick + swingOffset } |
| : { hit, tick }; |
| }), |
| })); |
| } |
|
|
| |
| |
| |
| |
| |
| export function expandPattern(input: EngineInput): MidiEvent[] { |
| const ts = input.timeSignature ?? { numerator: 4, denominator: 4 }; |
| const overrides = input.overrides ?? {}; |
|
|
| let bars = expandBars(input); |
|
|
| if (overrides.cymbal) { |
| bars = applyCymbalOverride(bars, overrides.cymbal); |
| } |
| if (overrides.accents && overrides.accents.length > 0) { |
| bars = applyAccents(bars, overrides.accents, ts); |
| } |
| if (overrides.feel) { |
| bars = applyFeel(bars, overrides.feel, input.template.swingRatio ?? 0, ts); |
| } |
|
|
| const events: MidiEvent[] = []; |
| for (const bar of bars) { |
| for (const { hit, tick } of bar.hits) { |
| events.push({ |
| tick, |
| limb: hit.limb, |
| velocity: hit.velocity, |
| duration: 8, |
| }); |
| } |
| } |
|
|
| |
| events.sort((a, b) => a.tick - b.tick); |
| return events; |
| } |
|
|
| |
| |
| |
| |
| export function bpmToMicrosecondsPerQuarter(bpm: number): number { |
| return Math.round(60_000_000 / bpm); |
| } |
|
|