feat: add custom tempo/BPM converter with musical note units
Add comprehensive tempo conversion system supporting: - BPM (beats per minute) as base unit - Musical note durations: whole, half, quarter, eighth, sixteenth, thirty-second - Dotted notes (1.5x duration): dotted-half, dotted-quarter, dotted-eighth, dotted-sixteenth - Triplet notes (2/3 duration): quarter-triplet, eighth-triplet, sixteenth-triplet - Time units: milliseconds, seconds, Hertz Technical implementation: - Created lib/tempo.ts with custom measure definition - Extended lib/units.ts with tempo integration and reciprocal conversion logic - Added tempo category color (orange #F97316) to globals.css - Conversion formula: milliseconds per beat = 60000 / BPM - Special handling for BPM ↔ time unit conversions using reciprocal relationship The tempo converter integrates seamlessly with existing UX: - Appears as "Tempo / BPM" category in category selector - Supports all features: search, favorites, history, visual comparison, draggable bars - Enables musicians to convert between BPM and note durations for tempo calculations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,7 @@
|
|||||||
--color-category-reactive-power: oklch(74.5% 0.233 316.8);
|
--color-category-reactive-power: oklch(74.5% 0.233 316.8);
|
||||||
--color-category-speed: oklch(72.4% 0.159 165.1);
|
--color-category-speed: oklch(72.4% 0.159 165.1);
|
||||||
--color-category-temperature: oklch(62.8% 0.257 29.2);
|
--color-category-temperature: oklch(62.8% 0.257 29.2);
|
||||||
|
--color-category-tempo: oklch(70% 0.18 30);
|
||||||
--color-category-time: oklch(58.5% 0.238 293.1);
|
--color-category-time: oklch(58.5% 0.238 293.1);
|
||||||
--color-category-voltage: oklch(75.5% 0.159 55.3);
|
--color-category-voltage: oklch(75.5% 0.159 55.3);
|
||||||
--color-category-volume: oklch(64.8% 0.190 293.6);
|
--color-category-volume: oklch(64.8% 0.190 293.6);
|
||||||
|
|||||||
117
lib/tempo.ts
Normal file
117
lib/tempo.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Custom tempo/BPM measure for convert-units
|
||||||
|
*
|
||||||
|
* Converts between BPM and note durations in milliseconds.
|
||||||
|
* Uses a reciprocal relationship where BPM (beats per minute) is the base unit.
|
||||||
|
*
|
||||||
|
* Formula: milliseconds per beat = 60000 / BPM
|
||||||
|
*
|
||||||
|
* The to_anchor value represents the conversion factor:
|
||||||
|
* - For BPM → time units: multiply by to_anchor to get milliseconds
|
||||||
|
* - For time units → BPM: divide by to_anchor to get BPM
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const tempoMeasure = {
|
||||||
|
tempo: {
|
||||||
|
systems: {
|
||||||
|
metric: {
|
||||||
|
// BPM as the base unit (1 BPM = 60000 ms per beat)
|
||||||
|
'BPM': {
|
||||||
|
name: { singular: 'Beat per Minute', plural: 'Beats per Minute' },
|
||||||
|
to_anchor: 1
|
||||||
|
},
|
||||||
|
|
||||||
|
// Whole note (4 beats) = 240000 / BPM
|
||||||
|
'whole': {
|
||||||
|
name: { singular: 'Whole Note', plural: 'Whole Notes' },
|
||||||
|
to_anchor: 240000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Half note (2 beats) = 120000 / BPM
|
||||||
|
'half': {
|
||||||
|
name: { singular: 'Half Note', plural: 'Half Notes' },
|
||||||
|
to_anchor: 120000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quarter note (1 beat) = 60000 / BPM
|
||||||
|
'quarter': {
|
||||||
|
name: { singular: 'Quarter Note', plural: 'Quarter Notes' },
|
||||||
|
to_anchor: 60000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Eighth note (0.5 beats) = 30000 / BPM
|
||||||
|
'eighth': {
|
||||||
|
name: { singular: 'Eighth Note', plural: 'Eighth Notes' },
|
||||||
|
to_anchor: 30000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sixteenth note (0.25 beats) = 15000 / BPM
|
||||||
|
'sixteenth': {
|
||||||
|
name: { singular: 'Sixteenth Note', plural: 'Sixteenth Notes' },
|
||||||
|
to_anchor: 15000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thirty-second note (0.125 beats) = 7500 / BPM
|
||||||
|
'thirty-second': {
|
||||||
|
name: { singular: 'Thirty-Second Note', plural: 'Thirty-Second Notes' },
|
||||||
|
to_anchor: 7500
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dotted notes (1.5x the duration)
|
||||||
|
'dotted-half': {
|
||||||
|
name: { singular: 'Dotted Half Note', plural: 'Dotted Half Notes' },
|
||||||
|
to_anchor: 180000 // 3 beats
|
||||||
|
},
|
||||||
|
|
||||||
|
'dotted-quarter': {
|
||||||
|
name: { singular: 'Dotted Quarter Note', plural: 'Dotted Quarter Notes' },
|
||||||
|
to_anchor: 90000 // 1.5 beats
|
||||||
|
},
|
||||||
|
|
||||||
|
'dotted-eighth': {
|
||||||
|
name: { singular: 'Dotted Eighth Note', plural: 'Dotted Eighth Notes' },
|
||||||
|
to_anchor: 45000 // 0.75 beats
|
||||||
|
},
|
||||||
|
|
||||||
|
'dotted-sixteenth': {
|
||||||
|
name: { singular: 'Dotted Sixteenth Note', plural: 'Dotted Sixteenth Notes' },
|
||||||
|
to_anchor: 22500 // 0.375 beats
|
||||||
|
},
|
||||||
|
|
||||||
|
// Triplet notes (2/3 of the duration)
|
||||||
|
'quarter-triplet': {
|
||||||
|
name: { singular: 'Quarter Note Triplet', plural: 'Quarter Note Triplets' },
|
||||||
|
to_anchor: 40000 // 2/3 beat
|
||||||
|
},
|
||||||
|
|
||||||
|
'eighth-triplet': {
|
||||||
|
name: { singular: 'Eighth Note Triplet', plural: 'Eighth Note Triplets' },
|
||||||
|
to_anchor: 20000 // 1/3 beat
|
||||||
|
},
|
||||||
|
|
||||||
|
'sixteenth-triplet': {
|
||||||
|
name: { singular: 'Sixteenth Note Triplet', plural: 'Sixteenth Note Triplets' },
|
||||||
|
to_anchor: 10000 // 1/6 beat
|
||||||
|
},
|
||||||
|
|
||||||
|
// Milliseconds as direct time unit
|
||||||
|
'ms': {
|
||||||
|
name: { singular: 'Millisecond', plural: 'Milliseconds' },
|
||||||
|
to_anchor: 60000 // Same as quarter note
|
||||||
|
},
|
||||||
|
|
||||||
|
// Seconds
|
||||||
|
's': {
|
||||||
|
name: { singular: 'Second', plural: 'Seconds' },
|
||||||
|
to_anchor: 60 // 60 seconds per beat at 1 BPM
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hertz (beats per second)
|
||||||
|
'Hz': {
|
||||||
|
name: { singular: 'Hertz', plural: 'Hertz' },
|
||||||
|
to_anchor: 1 / 60 // 1 BPM = 1/60 Hz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
52
lib/units.ts
52
lib/units.ts
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import convert from 'convert-units';
|
import convert from 'convert-units';
|
||||||
|
import { tempoMeasure } from './tempo';
|
||||||
|
|
||||||
export type Measure =
|
export type Measure =
|
||||||
| 'angle'
|
| 'angle'
|
||||||
@@ -25,6 +26,7 @@ export type Measure =
|
|||||||
| 'reactivePower'
|
| 'reactivePower'
|
||||||
| 'speed'
|
| 'speed'
|
||||||
| 'temperature'
|
| 'temperature'
|
||||||
|
| 'tempo'
|
||||||
| 'time'
|
| 'time'
|
||||||
| 'voltage'
|
| 'voltage'
|
||||||
| 'volume'
|
| 'volume'
|
||||||
@@ -48,13 +50,17 @@ export interface ConversionResult {
|
|||||||
* Get all available measures/categories
|
* Get all available measures/categories
|
||||||
*/
|
*/
|
||||||
export function getAllMeasures(): Measure[] {
|
export function getAllMeasures(): Measure[] {
|
||||||
return convert().measures() as Measure[];
|
const standardMeasures = convert().measures() as Measure[];
|
||||||
|
return [...standardMeasures, 'tempo'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all units for a specific measure
|
* Get all units for a specific measure
|
||||||
*/
|
*/
|
||||||
export function getUnitsForMeasure(measure: Measure): string[] {
|
export function getUnitsForMeasure(measure: Measure): string[] {
|
||||||
|
if (measure === 'tempo') {
|
||||||
|
return Object.keys(tempoMeasure.tempo.systems.metric);
|
||||||
|
}
|
||||||
return convert().possibilities(measure);
|
return convert().possibilities(measure);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +69,19 @@ export function getUnitsForMeasure(measure: Measure): string[] {
|
|||||||
*/
|
*/
|
||||||
export function getUnitInfo(unit: string): UnitInfo | null {
|
export function getUnitInfo(unit: string): UnitInfo | null {
|
||||||
try {
|
try {
|
||||||
|
// Check if it's a tempo unit
|
||||||
|
const tempoUnits = tempoMeasure.tempo.systems.metric;
|
||||||
|
if (unit in tempoUnits) {
|
||||||
|
const tempoUnit = tempoUnits[unit as keyof typeof tempoUnits];
|
||||||
|
return {
|
||||||
|
abbr: unit,
|
||||||
|
measure: 'tempo',
|
||||||
|
system: 'metric',
|
||||||
|
singular: tempoUnit.name.singular,
|
||||||
|
plural: tempoUnit.name.plural,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const description = convert().describe(unit);
|
const description = convert().describe(unit);
|
||||||
return description as UnitInfo;
|
return description as UnitInfo;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -79,6 +98,34 @@ export function convertUnit(
|
|||||||
toUnit: string
|
toUnit: string
|
||||||
): number {
|
): number {
|
||||||
try {
|
try {
|
||||||
|
// Handle tempo conversions
|
||||||
|
const tempoUnits = tempoMeasure.tempo.systems.metric;
|
||||||
|
const isFromTempo = fromUnit in tempoUnits;
|
||||||
|
const isToTempo = toUnit in tempoUnits;
|
||||||
|
|
||||||
|
if (isFromTempo && isToTempo) {
|
||||||
|
const fromAnchor = tempoUnits[fromUnit as keyof typeof tempoUnits].to_anchor;
|
||||||
|
const toAnchor = tempoUnits[toUnit as keyof typeof tempoUnits].to_anchor;
|
||||||
|
|
||||||
|
// Special handling for BPM conversions (reciprocal relationship)
|
||||||
|
if (fromUnit === 'BPM') {
|
||||||
|
// BPM → time unit: divide anchor by BPM value
|
||||||
|
// Example: 120 BPM → quarter = 60000 / 120 = 500ms
|
||||||
|
return toAnchor / value;
|
||||||
|
} else if (toUnit === 'BPM') {
|
||||||
|
// Time unit → BPM: divide anchor by time value
|
||||||
|
// Example: 500ms quarter → BPM = 60000 / 500 = 120
|
||||||
|
return fromAnchor / value;
|
||||||
|
} else {
|
||||||
|
// Time unit → time unit: convert through BPM
|
||||||
|
// Example: 500ms quarter → eighth
|
||||||
|
// Step 1: 500ms → BPM = 60000 / 500 = 120
|
||||||
|
// Step 2: 120 BPM → eighth = 30000 / 120 = 250ms
|
||||||
|
const bpm = fromAnchor / value;
|
||||||
|
return toAnchor / bpm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return convert(value).from(fromUnit).to(toUnit);
|
return convert(value).from(fromUnit).to(toUnit);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Conversion error:', error);
|
console.error('Conversion error:', error);
|
||||||
@@ -139,6 +186,7 @@ export function getCategoryColor(measure: Measure): string {
|
|||||||
reactivePower: 'category-reactive-power',
|
reactivePower: 'category-reactive-power',
|
||||||
speed: 'category-speed',
|
speed: 'category-speed',
|
||||||
temperature: 'category-temperature',
|
temperature: 'category-temperature',
|
||||||
|
tempo: 'category-tempo',
|
||||||
time: 'category-time',
|
time: 'category-time',
|
||||||
voltage: 'category-voltage',
|
voltage: 'category-voltage',
|
||||||
volume: 'category-volume',
|
volume: 'category-volume',
|
||||||
@@ -172,6 +220,7 @@ export function getCategoryColorHex(measure: Measure): string {
|
|||||||
reactivePower: '#E879F9',
|
reactivePower: '#E879F9',
|
||||||
speed: '#10B981',
|
speed: '#10B981',
|
||||||
temperature: '#EF4444',
|
temperature: '#EF4444',
|
||||||
|
tempo: '#F97316', // Orange for music/tempo
|
||||||
time: '#7C3AED',
|
time: '#7C3AED',
|
||||||
voltage: '#FB923C',
|
voltage: '#FB923C',
|
||||||
volume: '#8B5CF6',
|
volume: '#8B5CF6',
|
||||||
@@ -205,6 +254,7 @@ export function formatMeasureName(measure: Measure): string {
|
|||||||
reactivePower: 'Reactive Power',
|
reactivePower: 'Reactive Power',
|
||||||
speed: 'Speed',
|
speed: 'Speed',
|
||||||
temperature: 'Temperature',
|
temperature: 'Temperature',
|
||||||
|
tempo: 'Tempo / BPM',
|
||||||
time: 'Time',
|
time: 'Time',
|
||||||
voltage: 'Voltage',
|
voltage: 'Voltage',
|
||||||
volume: 'Volume',
|
volume: 'Volume',
|
||||||
|
|||||||
Reference in New Issue
Block a user