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>
302 lines
7.3 KiB
TypeScript
302 lines
7.3 KiB
TypeScript
/**
|
|
* Unit conversion service wrapper for convert-units library
|
|
* Provides type-safe conversion utilities and metadata
|
|
*/
|
|
|
|
import convert from 'convert-units';
|
|
import { tempoMeasure } from './tempo';
|
|
|
|
export type Measure =
|
|
| 'angle'
|
|
| 'apparentPower'
|
|
| 'area'
|
|
| 'current'
|
|
| 'digital'
|
|
| 'each'
|
|
| 'energy'
|
|
| 'frequency'
|
|
| 'illuminance'
|
|
| 'length'
|
|
| 'mass'
|
|
| 'pace'
|
|
| 'partsPer'
|
|
| 'power'
|
|
| 'pressure'
|
|
| 'reactiveEnergy'
|
|
| 'reactivePower'
|
|
| 'speed'
|
|
| 'temperature'
|
|
| 'tempo'
|
|
| 'time'
|
|
| 'voltage'
|
|
| 'volume'
|
|
| 'volumeFlowRate';
|
|
|
|
export interface UnitInfo {
|
|
abbr: string;
|
|
measure: Measure;
|
|
system: 'metric' | 'imperial' | 'bits' | 'bytes' | string;
|
|
singular: string;
|
|
plural: string;
|
|
}
|
|
|
|
export interface ConversionResult {
|
|
value: number;
|
|
unit: string;
|
|
unitInfo: UnitInfo;
|
|
}
|
|
|
|
/**
|
|
* Get all available measures/categories
|
|
*/
|
|
export function getAllMeasures(): Measure[] {
|
|
const standardMeasures = convert().measures() as Measure[];
|
|
return [...standardMeasures, 'tempo'];
|
|
}
|
|
|
|
/**
|
|
* Get all units for a specific measure
|
|
*/
|
|
export function getUnitsForMeasure(measure: Measure): string[] {
|
|
if (measure === 'tempo') {
|
|
return Object.keys(tempoMeasure.tempo.systems.metric);
|
|
}
|
|
return convert().possibilities(measure);
|
|
}
|
|
|
|
/**
|
|
* Get detailed information about a unit
|
|
*/
|
|
export function getUnitInfo(unit: string): UnitInfo | null {
|
|
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);
|
|
return description as UnitInfo;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a value from one unit to another
|
|
*/
|
|
export function convertUnit(
|
|
value: number,
|
|
fromUnit: string,
|
|
toUnit: string
|
|
): number {
|
|
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);
|
|
} catch (error) {
|
|
console.error('Conversion error:', error);
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a value to all compatible units in the same measure
|
|
*/
|
|
export function convertToAll(
|
|
value: number,
|
|
fromUnit: string
|
|
): ConversionResult[] {
|
|
try {
|
|
const unitInfo = getUnitInfo(fromUnit);
|
|
if (!unitInfo) return [];
|
|
|
|
const compatibleUnits = getUnitsForMeasure(unitInfo.measure);
|
|
|
|
return compatibleUnits.map(toUnit => {
|
|
const convertedValue = convertUnit(value, fromUnit, toUnit);
|
|
const toUnitInfo = getUnitInfo(toUnit);
|
|
|
|
return {
|
|
value: convertedValue,
|
|
unit: toUnit,
|
|
unitInfo: toUnitInfo!,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
console.error('Conversion error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get category color for a measure (Tailwind class name)
|
|
*/
|
|
export function getCategoryColor(measure: Measure): string {
|
|
const colorMap: Record<Measure, string> = {
|
|
angle: 'category-angle',
|
|
apparentPower: 'category-apparent-power',
|
|
area: 'category-area',
|
|
current: 'category-current',
|
|
digital: 'category-digital',
|
|
each: 'category-each',
|
|
energy: 'category-energy',
|
|
frequency: 'category-frequency',
|
|
illuminance: 'category-illuminance',
|
|
length: 'category-length',
|
|
mass: 'category-mass',
|
|
pace: 'category-pace',
|
|
partsPer: 'category-parts-per',
|
|
power: 'category-power',
|
|
pressure: 'category-pressure',
|
|
reactiveEnergy: 'category-reactive-energy',
|
|
reactivePower: 'category-reactive-power',
|
|
speed: 'category-speed',
|
|
temperature: 'category-temperature',
|
|
tempo: 'category-tempo',
|
|
time: 'category-time',
|
|
voltage: 'category-voltage',
|
|
volume: 'category-volume',
|
|
volumeFlowRate: 'category-volume-flow-rate',
|
|
};
|
|
|
|
return colorMap[measure];
|
|
}
|
|
|
|
/**
|
|
* Get category color hex value for a measure
|
|
*/
|
|
export function getCategoryColorHex(measure: Measure): string {
|
|
const colorMap: Record<Measure, string> = {
|
|
angle: '#0EA5E9',
|
|
apparentPower: '#8B5CF6',
|
|
area: '#F59E0B',
|
|
current: '#F59E0B',
|
|
digital: '#06B6D4',
|
|
each: '#64748B',
|
|
energy: '#EAB308',
|
|
frequency: '#A855F7',
|
|
illuminance: '#84CC16',
|
|
length: '#3B82F6',
|
|
mass: '#10B981',
|
|
pace: '#14B8A6',
|
|
partsPer: '#EC4899',
|
|
power: '#F43F5E',
|
|
pressure: '#6366F1',
|
|
reactiveEnergy: '#D946EF',
|
|
reactivePower: '#E879F9',
|
|
speed: '#10B981',
|
|
temperature: '#EF4444',
|
|
tempo: '#F97316', // Orange for music/tempo
|
|
time: '#7C3AED',
|
|
voltage: '#FB923C',
|
|
volume: '#8B5CF6',
|
|
volumeFlowRate: '#22D3EE',
|
|
};
|
|
|
|
return colorMap[measure];
|
|
}
|
|
|
|
/**
|
|
* Format measure name for display
|
|
*/
|
|
export function formatMeasureName(measure: Measure): string {
|
|
const nameMap: Record<Measure, string> = {
|
|
angle: 'Angle',
|
|
apparentPower: 'Apparent Power',
|
|
area: 'Area',
|
|
current: 'Current',
|
|
digital: 'Digital Storage',
|
|
each: 'Each',
|
|
energy: 'Energy',
|
|
frequency: 'Frequency',
|
|
illuminance: 'Illuminance',
|
|
length: 'Length',
|
|
mass: 'Mass',
|
|
pace: 'Pace',
|
|
partsPer: 'Parts Per',
|
|
power: 'Power',
|
|
pressure: 'Pressure',
|
|
reactiveEnergy: 'Reactive Energy',
|
|
reactivePower: 'Reactive Power',
|
|
speed: 'Speed',
|
|
temperature: 'Temperature',
|
|
tempo: 'Tempo / BPM',
|
|
time: 'Time',
|
|
voltage: 'Voltage',
|
|
volume: 'Volume',
|
|
volumeFlowRate: 'Volume Flow Rate',
|
|
};
|
|
|
|
return nameMap[measure];
|
|
}
|
|
|
|
/**
|
|
* Search units by query string (fuzzy search)
|
|
*/
|
|
export function searchUnits(query: string): UnitInfo[] {
|
|
if (!query) return [];
|
|
|
|
const allMeasures = getAllMeasures();
|
|
const results: UnitInfo[] = [];
|
|
const lowerQuery = query.toLowerCase();
|
|
|
|
for (const measure of allMeasures) {
|
|
const units = getUnitsForMeasure(measure);
|
|
|
|
for (const unit of units) {
|
|
const info = getUnitInfo(unit);
|
|
if (!info) continue;
|
|
|
|
const searchableText = [
|
|
info.abbr,
|
|
info.singular,
|
|
info.plural,
|
|
measure,
|
|
formatMeasureName(measure),
|
|
]
|
|
.join(' ')
|
|
.toLowerCase();
|
|
|
|
if (searchableText.includes(lowerQuery)) {
|
|
results.push(info);
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|