From 75f895284f1218ef5ceed3fca592a2c7356569b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 8 Nov 2025 11:53:50 +0100 Subject: [PATCH] feat: add custom tempo/BPM converter with musical note units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/globals.css | 1 + lib/tempo.ts | 117 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/units.ts | 52 ++++++++++++++++++++- 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 lib/tempo.ts diff --git a/app/globals.css b/app/globals.css index 5c1cc7b..9170645 100644 --- a/app/globals.css +++ b/app/globals.css @@ -74,6 +74,7 @@ --color-category-reactive-power: oklch(74.5% 0.233 316.8); --color-category-speed: oklch(72.4% 0.159 165.1); --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-voltage: oklch(75.5% 0.159 55.3); --color-category-volume: oklch(64.8% 0.190 293.6); diff --git a/lib/tempo.ts b/lib/tempo.ts new file mode 100644 index 0000000..cb6fafc --- /dev/null +++ b/lib/tempo.ts @@ -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 + } + } + } + } +}; diff --git a/lib/units.ts b/lib/units.ts index d3e74e3..082d363 100644 --- a/lib/units.ts +++ b/lib/units.ts @@ -4,6 +4,7 @@ */ import convert from 'convert-units'; +import { tempoMeasure } from './tempo'; export type Measure = | 'angle' @@ -25,6 +26,7 @@ export type Measure = | 'reactivePower' | 'speed' | 'temperature' + | 'tempo' | 'time' | 'voltage' | 'volume' @@ -48,13 +50,17 @@ export interface ConversionResult { * Get all available measures/categories */ 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 */ export function getUnitsForMeasure(measure: Measure): string[] { + if (measure === 'tempo') { + return Object.keys(tempoMeasure.tempo.systems.metric); + } return convert().possibilities(measure); } @@ -63,6 +69,19 @@ export function getUnitsForMeasure(measure: Measure): string[] { */ 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 { @@ -79,6 +98,34 @@ export function convertUnit( 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); @@ -139,6 +186,7 @@ export function getCategoryColor(measure: Measure): string { reactivePower: 'category-reactive-power', speed: 'category-speed', temperature: 'category-temperature', + tempo: 'category-tempo', time: 'category-time', voltage: 'category-voltage', volume: 'category-volume', @@ -172,6 +220,7 @@ export function getCategoryColorHex(measure: Measure): string { reactivePower: '#E879F9', speed: '#10B981', temperature: '#EF4444', + tempo: '#F97316', // Orange for music/tempo time: '#7C3AED', voltage: '#FB923C', volume: '#8B5CF6', @@ -205,6 +254,7 @@ export function formatMeasureName(measure: Measure): string { reactivePower: 'Reactive Power', speed: 'Speed', temperature: 'Temperature', + tempo: 'Tempo / BPM', time: 'Time', voltage: 'Voltage', volume: 'Volume',