From ceb687cf3f34cb6c8266538803f267af1dbda887 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 08:55:21 +0000 Subject: [PATCH 01/28] Initial plan From 63a4780397724b016b947da4bb4470941cc505b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 09:09:56 +0000 Subject: [PATCH 02/28] feat: add configurable weighted path finder Agent-Logs-Url: https://github.com/samsta/KeyBpmMap/sessions/184d8d9d-d9eb-4abe-9328-cd87262fa315 Co-authored-by: samsta <3915450+samsta@users.noreply.github.com> --- README.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/App.css | 82 +++++++- src/App.tsx | 372 +++++++++++++++++++++++++++++++++++++ src/lib/pathFinder.test.ts | 102 ++++++++++ src/lib/pathFinder.ts | 372 +++++++++++++++++++++++++++++++++++++ 7 files changed, 931 insertions(+), 4 deletions(-) create mode 100644 src/lib/pathFinder.test.ts create mode 100644 src/lib/pathFinder.ts diff --git a/README.md b/README.md index 72035d7..2d9f9b4 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ It reads local Engine DJ SQLite databases and Traktor `collection.nml` files dir - Browser-only Traktor `collection.nml` loading, including playlist metadata - Engine numeric, Open Key, and standard musical key → Camelot conversion - Polar harmonic density map and rectangular BPM heatmap +- Weighted path finder between start/end tracks with configurable key/tempo costs - Playlist, rating, BPM, and text filters - Region inspection for exact track lists inside a selected cell - Playlist transition summaries derived from playlist ordering diff --git a/package-lock.json b/package-lock.json index 6b73538..baea878 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "keybpmmap", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "keybpmmap", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "d3": "^7.9.0", "fast-xml-parser": "^5.8.0", diff --git a/package.json b/package.json index 747d0b4..7957745 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "keybpmmap", "private": true, - "version": "0.4.0", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index 7be3506..d93f2ba 100644 --- a/src/App.css +++ b/src/App.css @@ -109,7 +109,8 @@ } .source-panel, -.filters-panel { +.filters-panel, +.pathfinder-panel { padding: 24px 28px; } @@ -144,6 +145,10 @@ select { box-sizing: border-box; } +input[type='checkbox'] { + width: auto; +} + .stats-grid, .visual-grid, .insights-grid { @@ -253,6 +258,80 @@ select { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } +.pathfinder-panel { + display: grid; + gap: 16px; +} + +.path-weight-grid { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); +} + +.path-checkbox { + align-self: end; + display: flex; + align-items: center; + gap: 10px; +} + +.path-results-grid { + display: grid; + gap: 16px; +} + +.path-graph-wrapper { + overflow-x: auto; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + padding: 10px; + background: rgba(15, 23, 42, 0.4); +} + +.path-graph { + width: 100%; + min-width: 460px; + height: auto; +} + +.path-graph-row { + cursor: pointer; +} + +.path-graph-row polyline { + fill: none; + stroke: rgba(148, 163, 184, 0.55); + stroke-width: 2; +} + +.path-graph-row circle { + fill: rgba(30, 41, 59, 1); + stroke: rgba(148, 163, 184, 0.55); + stroke-width: 2; +} + +.path-graph-row.is-selected polyline, +.path-graph-row.is-selected circle { + stroke: rgba(34, 211, 238, 0.9); +} + +.path-graph-label, +.path-graph-cost, +.path-graph-node-label { + fill: #cbd5e1; + font-size: 0.78rem; +} + +.path-summary-list { + max-height: 220px; + overflow: auto; + padding-right: 6px; +} + +.path-details { + display: grid; + gap: 10px; +} + .insight-subtitle, .placeholder-text, .error-banner { @@ -390,6 +469,7 @@ select { .hero-panel, .source-panel, .filters-panel, + .pathfinder-panel, .chart-panel, .insight-panel { padding: 20px; diff --git a/src/App.tsx b/src/App.tsx index 4616997..d6d6324 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,14 @@ import { CAMELOT_KEYS, formatKey, type KeyRepresentation } from './lib/camelot' import { downloadSvgAsPng } from './lib/exportSvg' import { loadLibraryFromFile } from './lib/libraryLoader' import { createMockLibrary } from './lib/mockData' +import { + DEFAULT_PATH_FINDER_SETTINGS, + clampPathFinderSettings, + findNavigationPaths, + type NavigationPath, + type PathFinderSettings, + type PathFinderWeights, +} from './lib/pathFinder' import type { LibraryData, SparseCellSummary, TrackRecord } from './types' const initialFilters = { @@ -44,7 +52,19 @@ type PolarKeyMode = 'both' | 'A' | 'B' const POLAR_KEY_MODES = ['both', 'A', 'B'] as const const KEY_REPRESENTATIONS = ['camelot', 'open-key', 'musical'] as const const KEY_REPRESENTATION_STORAGE_KEY = 'keybpmmap.keyRepresentation' +const PATH_FINDER_SETTINGS_STORAGE_KEY = 'keybpmmap.pathFinderSettings' const APP_VERSION = __APP_VERSION__ +const PATH_WEIGHT_FIELDS: Array<{ key: keyof PathFinderWeights; label: string }> = [ + { key: 'sameKey', label: 'same key' }, + { key: 'oneUp', label: 'one up' }, + { key: 'oneDown', label: 'one down' }, + { key: 'aToB', label: 'A→B' }, + { key: 'bToA', label: 'B→A' }, + { key: 'energyBoost', label: 'xA→(x+3)B' }, + { key: 'centerJump', label: 'center jump' }, + { key: 'tempoPercent', label: 'tempo per %' }, + { key: 'keyChangeByTempo', label: 'tempo key change' }, +] function App() { const [library, setLibrary] = useState(() => createMockLibrary()) @@ -57,6 +77,14 @@ function App() { const [keyRepresentation, setKeyRepresentation] = useState( getInitialKeyRepresentation, ) + const [pathFinderSettings, setPathFinderSettings] = useState( + getInitialPathFinderSettings, + ) + const [pathSelection, setPathSelection] = useState({ + startTrackId: '', + endTrackId: '', + }) + const [selectedPathId, setSelectedPathId] = useState(null) const fileInputRef = useRef(null) const polarRef = useRef(null) const heatmapRef = useRef(null) @@ -69,6 +97,17 @@ function App() { } }, [keyRepresentation]) + useEffect(() => { + try { + window.localStorage.setItem( + PATH_FINDER_SETTINGS_STORAGE_KEY, + JSON.stringify(pathFinderSettings), + ) + } catch { + // Ignore localStorage persistence errors. + } + }, [pathFinderSettings]) + const playlistLookup = useMemo( () => new Map(library.playlists.map((playlist) => [playlist.id, playlist])), [library.playlists], @@ -138,6 +177,60 @@ function App() { ) const summary = useMemo(() => summarizeTracks(filteredTracks), [filteredTracks]) const sparseCells = useMemo(() => findSparseCells(densityCells, bpmBands), [bpmBands, densityCells]) + const pathScopeTracks = useMemo( + () => + [...filteredTracks].sort( + (left, right) => + left.artist.localeCompare(right.artist) || + left.title.localeCompare(right.title) || + left.id.localeCompare(right.id), + ), + [filteredTracks], + ) + const resolvedPathSelection = useMemo(() => { + const trackIds = pathScopeTracks.map((track) => track.id) + if (trackIds.length === 0) { + return { startTrackId: '', endTrackId: '' } + } + + const trackIdSet = new Set(trackIds) + const startTrackId = trackIdSet.has(pathSelection.startTrackId) + ? pathSelection.startTrackId + : trackIds[0] + const endCandidates = trackIds.filter((trackId) => trackId !== startTrackId) + const endTrackId = endCandidates.includes(pathSelection.endTrackId) + ? pathSelection.endTrackId + : (endCandidates[0] ?? startTrackId) + + return { startTrackId, endTrackId } + }, [pathScopeTracks, pathSelection.endTrackId, pathSelection.startTrackId]) + const pathTrackLookup = useMemo( + () => new Map(pathScopeTracks.map((track) => [track.id, track])), + [pathScopeTracks], + ) + const navigationPaths = useMemo( + () => + findNavigationPaths( + pathScopeTracks, + resolvedPathSelection.startTrackId, + resolvedPathSelection.endTrackId, + pathFinderSettings, + ), + [pathFinderSettings, pathScopeTracks, resolvedPathSelection.endTrackId, resolvedPathSelection.startTrackId], + ) + const selectedPath = useMemo( + () => + navigationPaths.find((path) => path.id === selectedPathId) ?? + navigationPaths[0] ?? + null, + [navigationPaths, selectedPathId], + ) + const pathGraphMaxNodes = useMemo( + () => Math.max(...navigationPaths.map((path) => path.trackIds.length), 0), + [navigationPaths], + ) + const pathGraphWidth = Math.max(460, pathGraphMaxNodes * 150) + const pathGraphHeight = Math.max(130, navigationPaths.length * 76 + 24) const formatVisibleKey = useMemo( () => (camelotKey: string) => formatKey(camelotKey, keyRepresentation), [keyRepresentation], @@ -181,6 +274,37 @@ function App() { setSelectedCellId(null) } + const handlePathWeightChange = (weight: keyof PathFinderWeights, value: string) => { + const nextValue = Number(value) + if (!Number.isFinite(nextValue)) { + return + } + + setPathFinderSettings((current) => + clampPathFinderSettings({ + ...current, + weights: { + ...current.weights, + [weight]: nextValue, + }, + }), + ) + } + + const handlePathMaxCostChange = (value: string) => { + const nextValue = Number(value) + if (!Number.isFinite(nextValue)) { + return + } + + setPathFinderSettings((current) => + clampPathFinderSettings({ + ...current, + maxTotalCost: nextValue, + }), + ) + } + const handleOpenDatabase = async () => { const pickerWindow = window as Window & { showOpenFilePicker?: (options?: OpenFilePickerConfig) => Promise @@ -490,6 +614,191 @@ function App() { +
+
+
+

Path finder

+

+ Find weighted routes between two tracks using key transitions, tempo deltas, and optional + tempo-based key changes. +

+

Showing up to 5 best paths under the configured max total cost.

+
+
+ +
+ + + + + + + +
+ +
+ {PATH_WEIGHT_FIELDS.map((field) => ( + + ))} +
+ + {navigationPaths.length > 0 ? ( +
+
+ + {navigationPaths.map((path, pathIndex) => { + const y = 40 + pathIndex * 70 + const stepWidth = + path.trackIds.length <= 1 ? 0 : (pathGraphWidth - 120) / (path.trackIds.length - 1) + const points = path.trackIds + .map((_, index) => `${60 + index * stepWidth},${y}`) + .join(' ') + const isSelected = selectedPath?.id === path.id + + return ( + setSelectedPathId(path.id)} + > + + {path.trackIds.map((trackId, nodeIndex) => ( + + + + {nodeIndex + 1} + + + ))} + + #{pathIndex + 1} + + + {path.totalCost.toFixed(2)} + + + ) + })} + +
+ +
    + {navigationPaths.map((path, pathIndex) => ( +
  • + +
  • + ))} +
+ + {selectedPath ? ( +
+

+ Path total: {selectedPath.totalCost.toFixed(2)} across {selectedPath.steps.length} step + {selectedPath.steps.length === 1 ? '' : 's'} +

+
    + {selectedPath.steps.map((step, index) => { + const fromTrack = pathTrackLookup.get(step.fromTrackId) + const toTrack = pathTrackLookup.get(step.toTrackId) + return ( +
  • +
    + Step {index + 1} + + {fromTrack ? formatTrackLabel(fromTrack) : step.fromTrackId} →{' '} + {toTrack ? formatTrackLabel(toTrack) : step.toTrackId} + +
    +
    + {formatPathStepSummary(step, formatVisibleKey)} + {step.stepCost.toFixed(2)} cost +
    +
  • + ) + })} +
+
+ ) : null} +
+ ) : ( +

+ No path found for the selected tracks and max total cost. Increase the max cost or adjust + weights. +

+ )} +
+
@@ -743,6 +1052,44 @@ function formatKeyRepresentationLabel(representation: KeyRepresentation): string } } +function formatTrackLabel(track: TrackRecord): string { + return `${track.artist} — ${track.title}` +} + +function formatPathStepSummary( + step: NavigationPath['steps'][number], + formatVisibleKey: (camelotKey: string) => string, +): string { + const adjustmentLabel = + step.adjustment === 'none' ? '' : ` · ${step.adjustment} (${step.adjustmentCost.toFixed(2)})` + return `${formatVisibleKey(step.fromKey)} ${step.fromBpm.toFixed(1)} → ${formatVisibleKey(step.toKey)} ${step.toBpm.toFixed(1)} BPM · ${formatPathRule(step.keyRule)} (${step.keyCost.toFixed(2)}) · tempo ${step.tempoPercentDelta.toFixed(2)}% (${step.tempoCost.toFixed(2)})${adjustmentLabel}` +} + +function formatPathRule(rule: keyof PathFinderWeights): string { + switch (rule) { + case 'sameKey': + return 'same key' + case 'oneUp': + return 'one up' + case 'oneDown': + return 'one down' + case 'aToB': + return 'A→B' + case 'bToA': + return 'B→A' + case 'energyBoost': + return 'xA→(x+3)B' + case 'centerJump': + return 'center jump' + case 'tempoPercent': + return 'tempo' + case 'keyChangeByTempo': + return 'tempo key change' + default: + return rule + } +} + function asKeyRepresentation(value: string): KeyRepresentation { return KEY_REPRESENTATIONS.includes(value as KeyRepresentation) ? (value as KeyRepresentation) @@ -761,4 +1108,29 @@ function getInitialKeyRepresentation(): KeyRepresentation { } } +function getInitialPathFinderSettings(): PathFinderSettings { + if (typeof window === 'undefined') { + return DEFAULT_PATH_FINDER_SETTINGS + } + + try { + const raw = window.localStorage.getItem(PATH_FINDER_SETTINGS_STORAGE_KEY) + if (!raw) { + return DEFAULT_PATH_FINDER_SETTINGS + } + + const parsed = JSON.parse(raw) + return clampPathFinderSettings({ + ...DEFAULT_PATH_FINDER_SETTINGS, + ...parsed, + weights: { + ...DEFAULT_PATH_FINDER_SETTINGS.weights, + ...(parsed as { weights?: Partial }).weights, + }, + }) + } catch { + return DEFAULT_PATH_FINDER_SETTINGS + } +} + export default App diff --git a/src/lib/pathFinder.test.ts b/src/lib/pathFinder.test.ts new file mode 100644 index 0000000..cc1d14a --- /dev/null +++ b/src/lib/pathFinder.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_PATH_FINDER_SETTINGS, + findNavigationPaths, + type PathFinderSettings, +} from './pathFinder' +import type { TrackRecord } from '../types' + +const makeTrack = ( + id: string, + camelotKey: string, + bpm: number, +): TrackRecord => ({ + id, + title: id, + artist: 'artist', + bpm, + rating: null, + camelotKey, + rawKey: null, + path: '', + playlists: [], +}) + +describe('findNavigationPaths', () => { + it('returns the lowest-cost path first', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('end', '8B', 120), + makeTrack('slow-hop', '8A', 122), + makeTrack('fast-hop', '8A', 126), + ] + + const paths = findNavigationPaths(tracks, 'start', 'end', { + ...DEFAULT_PATH_FINDER_SETTINGS, + maxTotalCost: 10, + }) + + expect(paths.length).toBeGreaterThan(0) + expect(paths[0]?.trackIds).toEqual(['start', 'end']) + expect(paths[0]?.totalCost).toBeCloseTo(2, 6) + }) + + it('caps results to 5 best paths and honors maxTotalCost', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('end', '8B', 120), + makeTrack('mid-1', '8A', 121), + makeTrack('mid-2', '8A', 122), + makeTrack('mid-3', '8A', 123), + makeTrack('mid-4', '8A', 124), + makeTrack('mid-5', '8A', 125), + makeTrack('mid-6', '8A', 126), + ] + + const allPaths = findNavigationPaths(tracks, 'start', 'end', { + ...DEFAULT_PATH_FINDER_SETTINGS, + maxTotalCost: 100, + }) + expect(allPaths).toHaveLength(5) + + const tightPaths = findNavigationPaths(tracks, 'start', 'end', { + ...DEFAULT_PATH_FINDER_SETTINGS, + maxTotalCost: 2.1, + }) + expect(tightPaths).toHaveLength(1) + expect(tightPaths[0]?.trackIds).toEqual(['start', 'end']) + }) + + it('supports optional key change by tempo', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('end', '3A', 127), + ] + + const disabled = findNavigationPaths(tracks, 'start', 'end', { + ...DEFAULT_PATH_FINDER_SETTINGS, + allowKeyChangeByTempo: false, + maxTotalCost: 10, + }) + expect(disabled).toEqual([]) + + const enabled = findNavigationPaths(tracks, 'start', 'end', { + ...DEFAULT_PATH_FINDER_SETTINGS, + allowKeyChangeByTempo: true, + maxTotalCost: 10, + }) + + expect(enabled).toHaveLength(1) + expect(enabled[0]?.steps[0]?.adjustment).toBe('speed-up') + expect(enabled[0]?.totalCost).toBeGreaterThan(3) + expect(enabled[0]?.totalCost).toBeLessThan(4) + }) + + it('returns no paths when either endpoint is missing', () => { + const tracks: TrackRecord[] = [makeTrack('start', '8A', 120)] + const settings: PathFinderSettings = { ...DEFAULT_PATH_FINDER_SETTINGS } + + expect(findNavigationPaths(tracks, 'start', 'end', settings)).toEqual([]) + expect(findNavigationPaths(tracks, '', 'end', settings)).toEqual([]) + }) +}) diff --git a/src/lib/pathFinder.ts b/src/lib/pathFinder.ts new file mode 100644 index 0000000..19c17c9 --- /dev/null +++ b/src/lib/pathFinder.ts @@ -0,0 +1,372 @@ +import type { TrackRecord } from '../types' + +const EPSILON = 1e-9 +const SPEED_UP_FACTOR = 1.05946 +const SLOW_DOWN_FACTOR = 0.9439 +const MAX_PATH_RESULTS = 5 + +export interface PathFinderWeights { + sameKey: number + oneUp: number + oneDown: number + aToB: number + bToA: number + energyBoost: number + centerJump: number + tempoPercent: number + keyChangeByTempo: number +} + +export interface PathFinderSettings { + weights: PathFinderWeights + allowKeyChangeByTempo: boolean + maxTotalCost: number +} + +export interface PathStep { + fromTrackId: string + toTrackId: string + fromKey: string + toKey: string + fromBpm: number + toBpm: number + keyRule: keyof PathFinderWeights + keyCost: number + tempoPercentDelta: number + tempoCost: number + adjustment: 'none' | 'speed-up' | 'slow-down' + adjustmentCost: number + stepCost: number +} + +export interface NavigationPath { + id: string + trackIds: string[] + steps: PathStep[] + totalCost: number +} + +interface ParsedCamelot { + index: number + mode: 'A' | 'B' +} + +interface TempoAdjustedState { + key: string + bpm: number + adjustment: 'none' | 'speed-up' | 'slow-down' + adjustmentCost: number +} + +interface CandidateState { + trackIds: string[] + steps: PathStep[] + totalCost: number +} + +export const DEFAULT_PATH_FINDER_WEIGHTS: PathFinderWeights = { + sameKey: 0, + oneUp: 1, + oneDown: 1, + aToB: 2, + bToA: 2, + energyBoost: 1, + centerJump: 4, + tempoPercent: 1, + keyChangeByTempo: 3, +} + +export const DEFAULT_PATH_FINDER_SETTINGS: PathFinderSettings = { + weights: DEFAULT_PATH_FINDER_WEIGHTS, + allowKeyChangeByTempo: false, + maxTotalCost: 10, +} + +export function clampPathFinderSettings(value: PathFinderSettings): PathFinderSettings { + return { + weights: { + sameKey: clampWeight(value.weights.sameKey), + oneUp: clampWeight(value.weights.oneUp), + oneDown: clampWeight(value.weights.oneDown), + aToB: clampWeight(value.weights.aToB), + bToA: clampWeight(value.weights.bToA), + energyBoost: clampWeight(value.weights.energyBoost), + centerJump: clampWeight(value.weights.centerJump), + tempoPercent: clampWeight(value.weights.tempoPercent), + keyChangeByTempo: clampWeight(value.weights.keyChangeByTempo), + }, + allowKeyChangeByTempo: Boolean(value.allowKeyChangeByTempo), + maxTotalCost: clampWeight(value.maxTotalCost), + } +} + +export function findNavigationPaths( + tracks: TrackRecord[], + startTrackId: string, + endTrackId: string, + rawSettings: PathFinderSettings, +): NavigationPath[] { + if (!startTrackId || !endTrackId || startTrackId === endTrackId) { + return [] + } + + const settings = clampPathFinderSettings(rawSettings) + if (settings.maxTotalCost <= 0) { + return [] + } + + const trackById = new Map(tracks.map((track) => [track.id, track])) + const startTrack = trackById.get(startTrackId) + const endTrack = trackById.get(endTrackId) + + if (!startTrack || !endTrack || startTrack.bpm === null || endTrack.bpm === null) { + return [] + } + + const queue: CandidateState[] = [ + { + trackIds: [startTrack.id], + steps: [], + totalCost: 0, + }, + ] + const results: NavigationPath[] = [] + const bestArrivalCosts = new Map() + + while (queue.length > 0) { + queue.sort((left, right) => left.totalCost - right.totalCost) + const current = queue.shift() + + if (!current) { + break + } + + const currentTrackId = current.trackIds.at(-1) + if (!currentTrackId) { + continue + } + + if (currentTrackId === endTrackId && current.steps.length > 0) { + results.push({ + id: current.trackIds.join('→'), + trackIds: current.trackIds, + steps: current.steps, + totalCost: current.totalCost, + }) + + if (results.length >= MAX_PATH_RESULTS) { + break + } + continue + } + + if (current.totalCost + EPSILON >= settings.maxTotalCost) { + continue + } + + const currentTrack = trackById.get(currentTrackId) + if (!currentTrack || currentTrack.bpm === null) { + continue + } + + const candidateStates = getTempoAdjustedStates(currentTrack, settings) + + for (const nextTrack of tracks) { + if (nextTrack.id === currentTrack.id || nextTrack.bpm === null || current.trackIds.includes(nextTrack.id)) { + continue + } + + for (const tempoAdjustedState of candidateStates) { + const transition = scoreTransition(tempoAdjustedState, nextTrack, settings.weights) + if (!transition) { + continue + } + + const totalCost = current.totalCost + transition.stepCost + if (totalCost - settings.maxTotalCost > EPSILON) { + continue + } + + if (!allowTrackArrival(bestArrivalCosts, nextTrack.id, totalCost)) { + continue + } + + queue.push({ + trackIds: [...current.trackIds, nextTrack.id], + steps: [...current.steps, { + fromTrackId: currentTrack.id, + toTrackId: nextTrack.id, + ...transition, + }], + totalCost, + }) + } + } + } + + return results + .sort((left, right) => left.totalCost - right.totalCost || left.steps.length - right.steps.length) + .slice(0, MAX_PATH_RESULTS) +} + +function scoreTransition( + fromState: TempoAdjustedState, + toTrack: TrackRecord, + weights: PathFinderWeights, +): Omit | null { + const keyTransition = getKeyTransitionCost(fromState.key, toTrack.camelotKey, weights) + if (!keyTransition) { + return null + } + + if (toTrack.bpm === null || fromState.bpm <= 0 || toTrack.bpm <= 0) { + return null + } + + const tempoPercentDelta = Math.abs((toTrack.bpm - fromState.bpm) / fromState.bpm) * 100 + const tempoCost = tempoPercentDelta * weights.tempoPercent + const stepCost = fromState.adjustmentCost + keyTransition.cost + tempoCost + + return { + fromKey: fromState.key, + toKey: toTrack.camelotKey, + fromBpm: fromState.bpm, + toBpm: toTrack.bpm, + keyRule: keyTransition.rule, + keyCost: keyTransition.cost, + tempoPercentDelta, + tempoCost, + adjustment: fromState.adjustment, + adjustmentCost: fromState.adjustmentCost, + stepCost, + } +} + +function getTempoAdjustedStates(track: TrackRecord, settings: PathFinderSettings): TempoAdjustedState[] { + if (track.bpm === null) { + return [] + } + + const states: TempoAdjustedState[] = [ + { + key: track.camelotKey, + bpm: track.bpm, + adjustment: 'none', + adjustmentCost: 0, + }, + ] + + if (!settings.allowKeyChangeByTempo) { + return states + } + + const speedUpKey = shiftCamelotKey(track.camelotKey, -5) + const slowDownKey = shiftCamelotKey(track.camelotKey, 5) + + if (speedUpKey) { + states.push({ + key: speedUpKey, + bpm: track.bpm * SPEED_UP_FACTOR, + adjustment: 'speed-up', + adjustmentCost: settings.weights.keyChangeByTempo, + }) + } + + if (slowDownKey) { + states.push({ + key: slowDownKey, + bpm: track.bpm * SLOW_DOWN_FACTOR, + adjustment: 'slow-down', + adjustmentCost: settings.weights.keyChangeByTempo, + }) + } + + return states +} + +function getKeyTransitionCost( + fromCamelot: string, + toCamelot: string, + weights: PathFinderWeights, +): { rule: keyof PathFinderWeights; cost: number } | null { + const from = parseCamelotKey(fromCamelot) + const to = parseCamelotKey(toCamelot) + + if (!from || !to) { + return null + } + + if (from.index === to.index && from.mode === to.mode) { + return { rule: 'sameKey', cost: weights.sameKey } + } + + const upOffset = (to.index - from.index + 12) % 12 + + if (from.mode === to.mode && upOffset === 1) { + return { rule: 'oneUp', cost: weights.oneUp } + } + + if (from.mode === to.mode && upOffset === 11) { + return { rule: 'oneDown', cost: weights.oneDown } + } + + if (from.index === to.index && from.mode === 'A' && to.mode === 'B') { + return { rule: 'aToB', cost: weights.aToB } + } + + if (from.index === to.index && from.mode === 'B' && to.mode === 'A') { + return { rule: 'bToA', cost: weights.bToA } + } + + if (from.mode === 'A' && to.mode === 'B' && upOffset === 3) { + return { rule: 'energyBoost', cost: weights.energyBoost } + } + + if (from.mode === to.mode && upOffset === 6) { + return { rule: 'centerJump', cost: weights.centerJump } + } + + return null +} + +function parseCamelotKey(value: string): ParsedCamelot | null { + const match = value.match(/^(1[0-2]|[1-9])(A|B)$/i) + if (!match) { + return null + } + + return { + index: Number(match[1]), + mode: match[2].toUpperCase() as 'A' | 'B', + } +} + +function shiftCamelotKey(value: string, shift: number): string | null { + const parsed = parseCamelotKey(value) + if (!parsed) { + return null + } + + const shifted = (((parsed.index - 1 + shift) % 12) + 12) % 12 + return `${shifted + 1}${parsed.mode}` +} + +function allowTrackArrival(bestArrivalCosts: Map, trackId: string, cost: number): boolean { + const current = bestArrivalCosts.get(trackId) ?? [] + const next = [...current, cost].sort((left, right) => left - right).slice(0, MAX_PATH_RESULTS) + + if (!next.some((value) => Math.abs(value - cost) <= EPSILON)) { + return false + } + + bestArrivalCosts.set(trackId, next) + return true +} + +function clampWeight(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, value) +} From fda58e70289b7589d00e5e264f477fb23ae71873 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 09:13:00 +0000 Subject: [PATCH 03/28] perf: optimize path queue and polish step summary formatting Agent-Logs-Url: https://github.com/samsta/KeyBpmMap/sessions/184d8d9d-d9eb-4abe-9328-cd87262fa315 Co-authored-by: samsta <3915450+samsta@users.noreply.github.com> --- src/App.tsx | 14 +++++-- src/lib/pathFinder.ts | 90 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d6d6324..41a0cb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1060,9 +1060,17 @@ function formatPathStepSummary( step: NavigationPath['steps'][number], formatVisibleKey: (camelotKey: string) => string, ): string { - const adjustmentLabel = - step.adjustment === 'none' ? '' : ` · ${step.adjustment} (${step.adjustmentCost.toFixed(2)})` - return `${formatVisibleKey(step.fromKey)} ${step.fromBpm.toFixed(1)} → ${formatVisibleKey(step.toKey)} ${step.toBpm.toFixed(1)} BPM · ${formatPathRule(step.keyRule)} (${step.keyCost.toFixed(2)}) · tempo ${step.tempoPercentDelta.toFixed(2)}% (${step.tempoCost.toFixed(2)})${adjustmentLabel}` + const parts = [ + `${formatVisibleKey(step.fromKey)} ${step.fromBpm.toFixed(1)} → ${formatVisibleKey(step.toKey)} ${step.toBpm.toFixed(1)} BPM`, + `${formatPathRule(step.keyRule)} (${step.keyCost.toFixed(2)})`, + `tempo ${step.tempoPercentDelta.toFixed(2)}% (${step.tempoCost.toFixed(2)})`, + ] + + if (step.adjustment !== 'none') { + parts.push(`${step.adjustment} (${step.adjustmentCost.toFixed(2)})`) + } + + return parts.join(' · ') } function formatPathRule(rule: keyof PathFinderWeights): string { diff --git a/src/lib/pathFinder.ts b/src/lib/pathFinder.ts index 19c17c9..6d46b47 100644 --- a/src/lib/pathFinder.ts +++ b/src/lib/pathFinder.ts @@ -64,6 +64,78 @@ interface CandidateState { totalCost: number } +class MinCostQueue { + private readonly data: CandidateState[] = [] + + get size(): number { + return this.data.length + } + + push(value: CandidateState): void { + this.data.push(value) + this.bubbleUp(this.data.length - 1) + } + + pop(): CandidateState | undefined { + if (this.data.length === 0) { + return undefined + } + + const top = this.data[0] + const tail = this.data.pop() + if (this.data.length > 0 && tail) { + this.data[0] = tail + this.bubbleDown(0) + } + + return top + } + + private bubbleUp(index: number): void { + let currentIndex = index + while (currentIndex > 0) { + const parentIndex = Math.floor((currentIndex - 1) / 2) + if (this.data[parentIndex].totalCost <= this.data[currentIndex].totalCost) { + break + } + + ;[this.data[parentIndex], this.data[currentIndex]] = [this.data[currentIndex], this.data[parentIndex]] + currentIndex = parentIndex + } + } + + private bubbleDown(index: number): void { + let currentIndex = index + + while (true) { + const leftIndex = currentIndex * 2 + 1 + const rightIndex = currentIndex * 2 + 2 + let nextIndex = currentIndex + + if ( + leftIndex < this.data.length && + this.data[leftIndex].totalCost < this.data[nextIndex].totalCost + ) { + nextIndex = leftIndex + } + + if ( + rightIndex < this.data.length && + this.data[rightIndex].totalCost < this.data[nextIndex].totalCost + ) { + nextIndex = rightIndex + } + + if (nextIndex === currentIndex) { + break + } + + ;[this.data[currentIndex], this.data[nextIndex]] = [this.data[nextIndex], this.data[currentIndex]] + currentIndex = nextIndex + } + } +} + export const DEFAULT_PATH_FINDER_WEIGHTS: PathFinderWeights = { sameKey: 0, oneUp: 1, @@ -123,19 +195,17 @@ export function findNavigationPaths( return [] } - const queue: CandidateState[] = [ - { - trackIds: [startTrack.id], - steps: [], - totalCost: 0, - }, - ] + const queue = new MinCostQueue() + queue.push({ + trackIds: [startTrack.id], + steps: [], + totalCost: 0, + }) const results: NavigationPath[] = [] const bestArrivalCosts = new Map() - while (queue.length > 0) { - queue.sort((left, right) => left.totalCost - right.totalCost) - const current = queue.shift() + while (queue.size > 0) { + const current = queue.pop() if (!current) { break From 810d07689322ef435652746eb150f9c209751400 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 09:53:31 +0000 Subject: [PATCH 04/28] feat: address pathfinder review feedback Agent-Logs-Url: https://github.com/samsta/KeyBpmMap/sessions/0beaaa16-de27-4aa2-b17c-6905062c34ca Co-authored-by: samsta <3915450+samsta@users.noreply.github.com> --- src/App.css | 31 ++++ src/App.tsx | 334 ++++++++++++++++++++++++++++--------- src/lib/mockData.ts | 49 ++++++ src/lib/pathFinder.test.ts | 16 ++ src/lib/pathFinder.ts | 14 ++ 5 files changed, 369 insertions(+), 75 deletions(-) diff --git a/src/App.css b/src/App.css index d93f2ba..02f7caf 100644 --- a/src/App.css +++ b/src/App.css @@ -261,6 +261,7 @@ input[type='checkbox'] { .pathfinder-panel { display: grid; gap: 16px; + order: 999; } .path-weight-grid { @@ -274,6 +275,25 @@ input[type='checkbox'] { gap: 10px; } +.path-helper-copy, +.field-help, +.path-status { + color: #94a3b8; +} + +.field-help { + font-size: 0.8rem; + line-height: 1.3; + font-weight: 500; +} + +.path-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + .path-results-grid { display: grid; gap: 16px; @@ -402,6 +422,17 @@ input[type='checkbox'] { font-style: normal; } +.track-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.track-action-button { + padding: 0.45rem 0.8rem; + font-size: 0.82rem; +} + .simple-list button { width: 100%; padding: 0; diff --git a/src/App.tsx b/src/App.tsx index 41a0cb7..70a97ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,16 +54,34 @@ const KEY_REPRESENTATIONS = ['camelot', 'open-key', 'musical'] as const const KEY_REPRESENTATION_STORAGE_KEY = 'keybpmmap.keyRepresentation' const PATH_FINDER_SETTINGS_STORAGE_KEY = 'keybpmmap.pathFinderSettings' const APP_VERSION = __APP_VERSION__ -const PATH_WEIGHT_FIELDS: Array<{ key: keyof PathFinderWeights; label: string }> = [ - { key: 'sameKey', label: 'same key' }, - { key: 'oneUp', label: 'one up' }, - { key: 'oneDown', label: 'one down' }, - { key: 'aToB', label: 'A→B' }, - { key: 'bToA', label: 'B→A' }, - { key: 'energyBoost', label: 'xA→(x+3)B' }, - { key: 'centerJump', label: 'center jump' }, - { key: 'tempoPercent', label: 'tempo per %' }, - { key: 'keyChangeByTempo', label: 'tempo key change' }, +const PATH_WEIGHT_FIELDS: Array<{ + key: keyof PathFinderWeights + label: string + description: string + exampleFrom?: string + exampleTo?: string + exampleText?: string +}> = [ + { key: 'sameKey', label: 'Same Key', description: 'Keep the same harmonic slot.', exampleFrom: '8A', exampleTo: '8A' }, + { key: 'oneUp', label: 'One Up', description: 'Move clockwise by one on the wheel.', exampleFrom: '5A', exampleTo: '6A' }, + { key: 'oneDown', label: 'One Down', description: 'Move counter-clockwise by one on the wheel.', exampleFrom: '6A', exampleTo: '5A' }, + { key: 'aToB', label: 'A → B', description: 'Switch from minor to major at the same number.', exampleFrom: '8A', exampleTo: '8B' }, + { key: 'bToA', label: 'B → A', description: 'Switch from major to minor at the same number.', exampleFrom: '8B', exampleTo: '8A' }, + { key: 'energyBoost', label: 'xA → (x+3)B', description: 'Minor to major energy lift.', exampleFrom: '5A', exampleTo: '8B' }, + { key: 'energyDrop', label: 'xB → (x-3)A', description: 'Major to minor inverse of the energy lift.', exampleFrom: '8B', exampleTo: '5A' }, + { key: 'centerJump', label: 'Center Jump', description: 'Jump across the wheel center.', exampleFrom: '5A', exampleTo: '11A' }, + { + key: 'tempoPercent', + label: 'Tempo Per %', + description: 'Cost per tempo percent difference.', + exampleText: 'Example: 120.0 BPM → 121.2 BPM is 1%.', + }, + { + key: 'keyChangeByTempo', + label: 'Tempo Key Change', + description: 'Cost to shift key by tempo before evaluating transitions.', + exampleText: 'Example: 8A at 120 BPM +5.946% → 3A at 127.1 BPM.', + }, ] function App() { @@ -81,9 +99,15 @@ function App() { getInitialPathFinderSettings, ) const [pathSelection, setPathSelection] = useState({ - startTrackId: '', - endTrackId: '', + startTrack: '', + endTrack: '', }) + const [pathSearchRequest, setPathSearchRequest] = useState<{ + startTrackId: string + endTrackId: string + settings: PathFinderSettings + } | null>(null) + const [pathSearchStatus, setPathSearchStatus] = useState(null) const [selectedPathId, setSelectedPathId] = useState(null) const fileInputRef = useRef(null) const polarRef = useRef(null) @@ -187,36 +211,63 @@ function App() { ), [filteredTracks], ) + const pathTrackOptions = useMemo( + () => + pathScopeTracks.map((track) => ({ + id: track.id, + label: formatTrackSearchLabel(track, keyRepresentation), + })), + [keyRepresentation, pathScopeTracks], + ) + const pathTrackIdByLabel = useMemo( + () => + new Map( + pathTrackOptions.map((option) => [option.label.toLowerCase(), option.id]), + ), + [pathTrackOptions], + ) + const pathTrackLabelById = useMemo( + () => new Map(pathTrackOptions.map((option) => [option.id, option.label])), + [pathTrackOptions], + ) const resolvedPathSelection = useMemo(() => { - const trackIds = pathScopeTracks.map((track) => track.id) - if (trackIds.length === 0) { - return { startTrackId: '', endTrackId: '' } - } + const defaultStart = pathTrackOptions[0]?.label ?? '' + const defaultEnd = pathTrackOptions.find((option) => option.label !== defaultStart)?.label ?? defaultStart - const trackIdSet = new Set(trackIds) - const startTrackId = trackIdSet.has(pathSelection.startTrackId) - ? pathSelection.startTrackId - : trackIds[0] - const endCandidates = trackIds.filter((trackId) => trackId !== startTrackId) - const endTrackId = endCandidates.includes(pathSelection.endTrackId) - ? pathSelection.endTrackId - : (endCandidates[0] ?? startTrackId) - - return { startTrackId, endTrackId } - }, [pathScopeTracks, pathSelection.endTrackId, pathSelection.startTrackId]) + return { + startTrack: pathSelection.startTrack || defaultStart, + endTrack: pathSelection.endTrack || defaultEnd, + } + }, [pathSelection.endTrack, pathSelection.startTrack, pathTrackOptions]) const pathTrackLookup = useMemo( () => new Map(pathScopeTracks.map((track) => [track.id, track])), [pathScopeTracks], ) + const searchedPathSelection = useMemo(() => { + if (!pathSearchRequest) { + return null + } + + if (!pathTrackLookup.has(pathSearchRequest.startTrackId) || !pathTrackLookup.has(pathSearchRequest.endTrackId)) { + return null + } + + return pathSearchRequest + }, [pathSearchRequest, pathTrackLookup]) const navigationPaths = useMemo( - () => - findNavigationPaths( + () => { + if (!searchedPathSelection) { + return [] + } + + return findNavigationPaths( pathScopeTracks, - resolvedPathSelection.startTrackId, - resolvedPathSelection.endTrackId, - pathFinderSettings, - ), - [pathFinderSettings, pathScopeTracks, resolvedPathSelection.endTrackId, resolvedPathSelection.startTrackId], + searchedPathSelection.startTrackId, + searchedPathSelection.endTrackId, + searchedPathSelection.settings, + ) + }, + [pathScopeTracks, searchedPathSelection], ) const selectedPath = useMemo( () => @@ -305,6 +356,40 @@ function App() { ) } + const handleSetPathTrack = (target: 'startTrack' | 'endTrack', trackId: string) => { + const label = pathTrackLabelById.get(trackId) + if (!label) { + return + } + + setPathSelection((current) => ({ ...current, [target]: label })) + } + + const handleFindPath = () => { + const startTrackId = resolveTrackId(resolvedPathSelection.startTrack, pathTrackIdByLabel, pathTrackLookup) + const endTrackId = resolveTrackId(resolvedPathSelection.endTrack, pathTrackIdByLabel, pathTrackLookup) + + if (!startTrackId || !endTrackId) { + setPathSearchStatus('Select valid Start Track and End Track values from the list.') + setPathSearchRequest(null) + return + } + + if (startTrackId === endTrackId) { + setPathSearchStatus('Start Track and End Track must be different.') + setPathSearchRequest(null) + return + } + + setPathSearchStatus(null) + setPathSearchRequest({ + startTrackId, + endTrackId, + settings: clampPathFinderSettings(pathFinderSettings), + }) + setSelectedPathId(null) + } + const handleOpenDatabase = async () => { const pickerWindow = window as Window & { showOpenFilePicker?: (options?: OpenFilePickerConfig) => Promise @@ -617,50 +702,57 @@ function App() {
-

Path finder

+

Path Finder

- Find weighted routes between two tracks using key transitions, tempo deltas, and optional - tempo-based key changes. + Set transition costs to guide route quality between tracks. +

+

+ Weights define how expensive each key or tempo move is. Lower values prefer that move. The + search keeps only the 5 best paths under Max Total Cost and runs only when you click Find + Path.

-

Showing up to 5 best paths under the configured max total cost.

+ + {pathTrackOptions.map((option) => ( + + + {pathTrackOptions.map((option) => ( +
+
+ + {pathSearchStatus ?

{pathSearchStatus}

: null} +
{PATH_WEIGHT_FIELDS.map((field) => ( ))}
- {navigationPaths.length > 0 ? ( + {searchedPathSelection ? ( + navigationPaths.length > 0 ? (
) : null}
- ) : ( + ) : (

- No path found for the selected tracks and max total cost. Increase the max cost or adjust - weights. + No path found for the selected tracks and max total cost. Increase Max Total Cost or adjust + weights, then click Find Path again.

+ ) + ) : ( +

Pick Start Track and End Track, then click Find Path.

)}
@@ -909,6 +1017,22 @@ function App() { {formatVisibleKey(track.camelotKey)} {formatRating(track)}
+
+ + +
)) ) : ( @@ -1056,6 +1180,34 @@ function formatTrackLabel(track: TrackRecord): string { return `${track.artist} — ${track.title}` } +function formatTrackSearchLabel(track: TrackRecord, keyRepresentation: KeyRepresentation): string { + const key = formatKey(track.camelotKey, keyRepresentation) + const bpmLabel = track.bpm === null ? 'No BPM' : `${track.bpm.toFixed(1)} BPM` + return `${track.artist} — ${track.title} (${key}, ${bpmLabel})` +} + +function formatWeightLabel( + weight: keyof PathFinderWeights, + fallbackLabel: string, + keyRepresentation: KeyRepresentation, +): string { + const minorLabel = getRepresentationMinorLabel(keyRepresentation) + const majorLabel = getRepresentationMajorLabel(keyRepresentation) + + switch (weight) { + case 'aToB': + return `${minorLabel} → ${majorLabel}` + case 'bToA': + return `${majorLabel} → ${minorLabel}` + case 'energyBoost': + return `x${minorLabel} → (x+3)${majorLabel}` + case 'energyDrop': + return `x${majorLabel} → (x-3)${minorLabel}` + default: + return fallbackLabel + } +} + function formatPathStepSummary( step: NavigationPath['steps'][number], formatVisibleKey: (camelotKey: string) => string, @@ -1063,11 +1215,11 @@ function formatPathStepSummary( const parts = [ `${formatVisibleKey(step.fromKey)} ${step.fromBpm.toFixed(1)} → ${formatVisibleKey(step.toKey)} ${step.toBpm.toFixed(1)} BPM`, `${formatPathRule(step.keyRule)} (${step.keyCost.toFixed(2)})`, - `tempo ${step.tempoPercentDelta.toFixed(2)}% (${step.tempoCost.toFixed(2)})`, + `Tempo ${step.tempoPercentDelta.toFixed(2)}% (${step.tempoCost.toFixed(2)})`, ] if (step.adjustment !== 'none') { - parts.push(`${step.adjustment} (${step.adjustmentCost.toFixed(2)})`) + parts.push(`${formatTempoAdjustment(step.adjustment)} (${step.adjustmentCost.toFixed(2)})`) } return parts.join(' · ') @@ -1076,28 +1228,60 @@ function formatPathStepSummary( function formatPathRule(rule: keyof PathFinderWeights): string { switch (rule) { case 'sameKey': - return 'same key' + return 'Same Key' case 'oneUp': - return 'one up' + return 'One Up' case 'oneDown': - return 'one down' + return 'One Down' case 'aToB': return 'A→B' case 'bToA': return 'B→A' case 'energyBoost': return 'xA→(x+3)B' + case 'energyDrop': + return 'xB→(x-3)A' case 'centerJump': - return 'center jump' + return 'Center Jump' case 'tempoPercent': - return 'tempo' + return 'Tempo' case 'keyChangeByTempo': - return 'tempo key change' + return 'Tempo Key Change' default: return rule } } +function formatTempoAdjustment(adjustment: NavigationPath['steps'][number]['adjustment']): string { + if (adjustment === 'speed-up') { + return 'Speed Up' + } + + if (adjustment === 'slow-down') { + return 'Slow Down' + } + + return 'No Adjustment' +} + +function resolveTrackId( + value: string, + pathTrackIdByLabel: Map, + pathTrackLookup: Map, +): string | null { + const trimmed = value.trim() + if (!trimmed) { + return null + } + + const byLabel = pathTrackIdByLabel.get(trimmed.toLowerCase()) + if (byLabel) { + return byLabel + } + + return pathTrackLookup.has(trimmed) ? trimmed : null +} + function asKeyRepresentation(value: string): KeyRepresentation { return KEY_REPRESENTATIONS.includes(value as KeyRepresentation) ? (value as KeyRepresentation) diff --git a/src/lib/mockData.ts b/src/lib/mockData.ts index 445a995..fa4fc4c 100644 --- a/src/lib/mockData.ts +++ b/src/lib/mockData.ts @@ -111,6 +111,50 @@ const tracks: TrackRecord[] = [ path: '/mock/sunrise-draft.wav', playlists: ['p-3'], }, + { + id: 't-011', + title: 'Arc Signal', + artist: 'Mara Tone', + bpm: 121, + rating: 4, + camelotKey: '8A', + rawKey: null, + path: '/mock/arc-signal.wav', + playlists: ['p-4'], + }, + { + id: 't-012', + title: 'Delta Lift', + artist: 'Mara Tone', + bpm: 122, + rating: 4, + camelotKey: '9A', + rawKey: null, + path: '/mock/delta-lift.wav', + playlists: ['p-4'], + }, + { + id: 't-013', + title: 'Mirror Rail', + artist: 'Mara Tone', + bpm: 123, + rating: 4, + camelotKey: '9B', + rawKey: null, + path: '/mock/mirror-rail.wav', + playlists: ['p-4'], + }, + { + id: 't-014', + title: 'Copper Dawn', + artist: 'Mara Tone', + bpm: 124, + rating: 4, + camelotKey: '10B', + rawKey: null, + path: '/mock/copper-dawn.wav', + playlists: ['p-4'], + }, ] const playlists: PlaylistInfo[] = [ @@ -129,6 +173,11 @@ const playlists: PlaylistInfo[] = [ name: 'Warm-up bridge', trackIds: ['t-009', 't-010', 't-003', 't-004'], }, + { + id: 'p-4', + name: 'Path finder lane', + trackIds: ['t-001', 't-011', 't-012', 't-013', 't-014'], + }, ] const transitions: TransitionRecord[] = playlists.flatMap((playlist) => { diff --git a/src/lib/pathFinder.test.ts b/src/lib/pathFinder.test.ts index cc1d14a..9411b2a 100644 --- a/src/lib/pathFinder.test.ts +++ b/src/lib/pathFinder.test.ts @@ -92,6 +92,22 @@ describe('findNavigationPaths', () => { expect(enabled[0]?.totalCost).toBeLessThan(4) }) + it('supports xB to (x-3)A transitions', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8B', 120), + makeTrack('end', '5A', 120), + ] + + const paths = findNavigationPaths(tracks, 'start', 'end', { + ...DEFAULT_PATH_FINDER_SETTINGS, + maxTotalCost: 10, + }) + + expect(paths).toHaveLength(1) + expect(paths[0]?.steps[0]?.keyRule).toBe('energyDrop') + expect(paths[0]?.totalCost).toBe(1) + }) + it('returns no paths when either endpoint is missing', () => { const tracks: TrackRecord[] = [makeTrack('start', '8A', 120)] const settings: PathFinderSettings = { ...DEFAULT_PATH_FINDER_SETTINGS } diff --git a/src/lib/pathFinder.ts b/src/lib/pathFinder.ts index 6d46b47..91016f8 100644 --- a/src/lib/pathFinder.ts +++ b/src/lib/pathFinder.ts @@ -4,6 +4,7 @@ const EPSILON = 1e-9 const SPEED_UP_FACTOR = 1.05946 const SLOW_DOWN_FACTOR = 0.9439 const MAX_PATH_RESULTS = 5 +const MAX_EXPLORED_STATES = 50_000 export interface PathFinderWeights { sameKey: number @@ -12,6 +13,7 @@ export interface PathFinderWeights { aToB: number bToA: number energyBoost: number + energyDrop: number centerJump: number tempoPercent: number keyChangeByTempo: number @@ -143,6 +145,7 @@ export const DEFAULT_PATH_FINDER_WEIGHTS: PathFinderWeights = { aToB: 2, bToA: 2, energyBoost: 1, + energyDrop: 1, centerJump: 4, tempoPercent: 1, keyChangeByTempo: 3, @@ -163,6 +166,7 @@ export function clampPathFinderSettings(value: PathFinderSettings): PathFinderSe aToB: clampWeight(value.weights.aToB), bToA: clampWeight(value.weights.bToA), energyBoost: clampWeight(value.weights.energyBoost), + energyDrop: clampWeight(value.weights.energyDrop), centerJump: clampWeight(value.weights.centerJump), tempoPercent: clampWeight(value.weights.tempoPercent), keyChangeByTempo: clampWeight(value.weights.keyChangeByTempo), @@ -203,6 +207,7 @@ export function findNavigationPaths( }) const results: NavigationPath[] = [] const bestArrivalCosts = new Map() + let exploredStates = 0 while (queue.size > 0) { const current = queue.pop() @@ -211,6 +216,11 @@ export function findNavigationPaths( break } + exploredStates += 1 + if (exploredStates > MAX_EXPLORED_STATES) { + break + } + const currentTrackId = current.trackIds.at(-1) if (!currentTrackId) { continue @@ -392,6 +402,10 @@ function getKeyTransitionCost( return { rule: 'energyBoost', cost: weights.energyBoost } } + if (from.mode === 'B' && to.mode === 'A' && upOffset === 9) { + return { rule: 'energyDrop', cost: weights.energyDrop } + } + if (from.mode === to.mode && upOffset === 6) { return { rule: 'centerJump', cost: weights.centerJump } } From 9adf69a0f0f4f6c4a2867e65e903ac9e7bb5aec4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 09:55:23 +0000 Subject: [PATCH 05/28] chore: polish path summary tempo label casing Agent-Logs-Url: https://github.com/samsta/KeyBpmMap/sessions/0beaaa16-de27-4aa2-b17c-6905062c34ca Co-authored-by: samsta <3915450+samsta@users.noreply.github.com> --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 70a97ee..c07f0c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1215,7 +1215,7 @@ function formatPathStepSummary( const parts = [ `${formatVisibleKey(step.fromKey)} ${step.fromBpm.toFixed(1)} → ${formatVisibleKey(step.toKey)} ${step.toBpm.toFixed(1)} BPM`, `${formatPathRule(step.keyRule)} (${step.keyCost.toFixed(2)})`, - `Tempo ${step.tempoPercentDelta.toFixed(2)}% (${step.tempoCost.toFixed(2)})`, + `tempo ${step.tempoPercentDelta.toFixed(2)}% (${step.tempoCost.toFixed(2)})`, ] if (step.adjustment !== 'none') { From 9899f9fbcc438b6f0d9b8b89a187a1e52f5d70ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 10:41:33 +0000 Subject: [PATCH 06/28] feat: improve scope diagnostics and pathfinder UX polish Agent-Logs-Url: https://github.com/samsta/KeyBpmMap/sessions/c7c66bfa-ce5f-4da5-9ec7-484eaae5f0fd Co-authored-by: samsta <3915450+samsta@users.noreply.github.com> --- src/App.css | 42 ++++++++++++++ src/App.tsx | 154 ++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 174 insertions(+), 22 deletions(-) diff --git a/src/App.css b/src/App.css index 02f7caf..c22a512 100644 --- a/src/App.css +++ b/src/App.css @@ -341,6 +341,16 @@ input[type='checkbox'] { font-size: 0.78rem; } +.path-graph-track-label { + fill: #94a3b8; + font-size: 0.63rem; +} + +.path-graph-track-artist { + font-weight: 700; + fill: #cbd5e1; +} + .path-summary-list { max-height: 220px; overflow: auto; @@ -433,6 +443,38 @@ input[type='checkbox'] { font-size: 0.82rem; } +.scope-track-table-wrapper { + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 14px; +} + +.scope-track-table { + width: 100%; + min-width: 680px; + border-collapse: collapse; + font-size: 0.86rem; +} + +.scope-track-table th, +.scope-track-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + text-align: left; + vertical-align: middle; +} + +.scope-track-table thead th { + font-size: 0.78rem; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.scope-track-table tbody td { + color: #cbd5e1; +} + .simple-list button { width: 100%; padding: 0; diff --git a/src/App.tsx b/src/App.tsx index c07f0c1..8dc47e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,8 +65,20 @@ const PATH_WEIGHT_FIELDS: Array<{ { key: 'sameKey', label: 'Same Key', description: 'Keep the same harmonic slot.', exampleFrom: '8A', exampleTo: '8A' }, { key: 'oneUp', label: 'One Up', description: 'Move clockwise by one on the wheel.', exampleFrom: '5A', exampleTo: '6A' }, { key: 'oneDown', label: 'One Down', description: 'Move counter-clockwise by one on the wheel.', exampleFrom: '6A', exampleTo: '5A' }, - { key: 'aToB', label: 'A → B', description: 'Switch from minor to major at the same number.', exampleFrom: '8A', exampleTo: '8B' }, - { key: 'bToA', label: 'B → A', description: 'Switch from major to minor at the same number.', exampleFrom: '8B', exampleTo: '8A' }, + { + key: 'aToB', + label: 'A → B', + description: 'Jump from the inner ring to the outer ring, or from a minor key to its relative major.', + exampleFrom: '8A', + exampleTo: '8B', + }, + { + key: 'bToA', + label: 'B → A', + description: 'Jump from the outer ring to the inner ring, or from a major key to its relative minor.', + exampleFrom: '8B', + exampleTo: '8A', + }, { key: 'energyBoost', label: 'xA → (x+3)B', description: 'Minor to major energy lift.', exampleFrom: '5A', exampleTo: '8B' }, { key: 'energyDrop', label: 'xB → (x-3)A', description: 'Major to minor inverse of the energy lift.', exampleFrom: '8B', exampleTo: '5A' }, { key: 'centerJump', label: 'Center Jump', description: 'Jump across the wheel center.', exampleFrom: '5A', exampleTo: '11A' }, @@ -109,6 +121,7 @@ function App() { } | null>(null) const [pathSearchStatus, setPathSearchStatus] = useState(null) const [selectedPathId, setSelectedPathId] = useState(null) + const [showScopeTrackTable, setShowScopeTrackTable] = useState(false) const fileInputRef = useRef(null) const polarRef = useRef(null) const heatmapRef = useRef(null) @@ -230,15 +243,6 @@ function App() { () => new Map(pathTrackOptions.map((option) => [option.id, option.label])), [pathTrackOptions], ) - const resolvedPathSelection = useMemo(() => { - const defaultStart = pathTrackOptions[0]?.label ?? '' - const defaultEnd = pathTrackOptions.find((option) => option.label !== defaultStart)?.label ?? defaultStart - - return { - startTrack: pathSelection.startTrack || defaultStart, - endTrack: pathSelection.endTrack || defaultEnd, - } - }, [pathSelection.endTrack, pathSelection.startTrack, pathTrackOptions]) const pathTrackLookup = useMemo( () => new Map(pathScopeTracks.map((track) => [track.id, track])), [pathScopeTracks], @@ -281,7 +285,7 @@ function App() { [navigationPaths], ) const pathGraphWidth = Math.max(460, pathGraphMaxNodes * 150) - const pathGraphHeight = Math.max(130, navigationPaths.length * 76 + 24) + const pathGraphHeight = Math.max(150, navigationPaths.length * 92 + 24) const formatVisibleKey = useMemo( () => (camelotKey: string) => formatKey(camelotKey, keyRepresentation), [keyRepresentation], @@ -366,8 +370,8 @@ function App() { } const handleFindPath = () => { - const startTrackId = resolveTrackId(resolvedPathSelection.startTrack, pathTrackIdByLabel, pathTrackLookup) - const endTrackId = resolveTrackId(resolvedPathSelection.endTrack, pathTrackIdByLabel, pathTrackLookup) + const startTrackId = resolveTrackId(pathSelection.startTrack, pathTrackIdByLabel, pathTrackLookup) + const endTrackId = resolveTrackId(pathSelection.endTrack, pathTrackIdByLabel, pathTrackLookup) if (!startTrackId || !endTrackId) { setPathSearchStatus('Select valid Start Track and End Track values from the list.') @@ -699,6 +703,77 @@ function App() {
+
+
+
+

Tracks in Scope

+

+ Use this optional table to diagnose why tracks might be missing from plots. Tracks without BPM + are never plotted, and keys outside the current key-family filter are hidden. +

+
+ +
+ {showScopeTrackTable ? ( +
+ + + + + + + + + + + + + {pathScopeTracks.map((track) => { + const isInPlots = track.bpm !== null && polarKeySet.has(track.camelotKey) + return ( + + + + + + + + + ) + })} + +
ArtistTitleBPMKeyIn PlotsPath
{track.artist}{track.title}{track.bpm === null ? 'No BPM' : track.bpm.toFixed(1)}{formatVisibleKey(track.camelotKey)} + {isInPlots + ? 'Yes' + : (track.bpm === null ? 'No (missing BPM)' : 'No (hidden by key filter)')} + +
+ + +
+
+
+ ) : null} +
+
@@ -720,7 +795,7 @@ function App() { setPathSelection((current) => ({ ...current, startTrack: event.target.value })) @@ -738,7 +813,7 @@ function App() { setPathSelection((current) => ({ ...current, endTrack: event.target.value })) @@ -812,7 +887,7 @@ function App() { aria-label="Navigation path graph" > {navigationPaths.map((path, pathIndex) => { - const y = 40 + pathIndex * 70 + const y = 38 + pathIndex * 92 const stepWidth = path.trackIds.length <= 1 ? 0 : (pathGraphWidth - 120) / (path.trackIds.length - 1) const points = path.trackIds @@ -827,19 +902,35 @@ function App() { onClick={() => setSelectedPathId(path.id)} > - {path.trackIds.map((trackId, nodeIndex) => ( - - + {path.trackIds.map((trackId, nodeIndex) => { + const track = pathTrackLookup.get(trackId) + const x = 60 + nodeIndex * stepWidth + + return ( + + {nodeIndex + 1} + {track ? ( + + {track.artist} + {track.title} + + ) : null} - ))} + ) + })} #{pathIndex + 1} @@ -1191,6 +1282,25 @@ function formatWeightLabel( fallbackLabel: string, keyRepresentation: KeyRepresentation, ): string { + if (keyRepresentation === 'musical') { + switch (weight) { + case 'oneUp': + return 'One 5th up' + case 'oneDown': + return 'One 5th down' + case 'aToB': + return 'min → rel maj' + case 'bToA': + return 'maj → rel min' + case 'energyBoost': + return 'min → maj' + case 'energyDrop': + return 'maj → min' + default: + return fallbackLabel + } + } + const minorLabel = getRepresentationMinorLabel(keyRepresentation) const majorLabel = getRepresentationMajorLabel(keyRepresentation) From 3eea3eb49c14bf655ce783ef296033c881c44f68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 10:48:39 +0000 Subject: [PATCH 07/28] feat: add scope diagnostics and improve pathfinder labels Agent-Logs-Url: https://github.com/samsta/KeyBpmMap/sessions/c7c66bfa-ce5f-4da5-9ec7-484eaae5f0fd Co-authored-by: samsta <3915450+samsta@users.noreply.github.com> --- package-lock.json | 4 ++-- package.json | 2 +- src/App.tsx | 10 +++++++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index baea878..3058b4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "keybpmmap", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "keybpmmap", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "d3": "^7.9.0", "fast-xml-parser": "^5.8.0", diff --git a/package.json b/package.json index 7957745..7a2778c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "keybpmmap", "private": true, - "version": "0.5.0", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index 8dc47e4..de95ec2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -752,6 +752,7 @@ function App() {
- {track.bpm ? `${track.bpm.toFixed(1)} BPM` : 'No BPM'} + {track.bpm === null ? 'No BPM' : `${track.bpm.toFixed(1)} BPM`} {formatVisibleKey(track.camelotKey)} {formatRating(track)}
diff --git a/src/lib/engineDj.ts b/src/lib/engineDj.ts index 804bb82..68312dc 100644 --- a/src/lib/engineDj.ts +++ b/src/lib/engineDj.ts @@ -41,8 +41,14 @@ function findTableName(tableNames: string[], target: string): string | null { } function getFirstValue(row: SqlRow, names: string[]): T | null { + const valuesByNormalizedName = new Map() for (const [key, value] of Object.entries(row)) { - if (names.some((name) => name.toLowerCase() === key.toLowerCase())) { + valuesByNormalizedName.set(normalizeColumnName(key), value) + } + + for (const name of names) { + const value = valuesByNormalizedName.get(normalizeColumnName(name)) + if (value !== undefined) { return value as T } } @@ -50,6 +56,10 @@ function getFirstValue(row: SqlRow, names: string[]): T | null { return null } +function normalizeColumnName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, '') +} + function asId(value: unknown): string | null { if (typeof value === 'string') { return value.trim() || null @@ -154,7 +164,9 @@ export async function loadEngineDjLibrary(file: File): Promise { return null } - const bpm = asNumber(getFirstValue(row, ['bpmAnalyzed', 'bpm', 'BPM'])) + const bpm = asNumber( + getFirstValue(row, ['bpmAnalyzed', 'bpm', 'BPM', 'tempo', 'tempoAnalyzed']), + ) const rawKey = asNumber(getFirstValue(row, ['key', 'keyAnalyzed', 'musicalKey'])) return { From 8f16b4c6d60e71b2d25df1c52c2bc2ae6c22e414 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 11:41:31 +0000 Subject: [PATCH 09/28] chore: polish engine parser naming Agent-Logs-Url: https://github.com/samsta/KeyBpmMap/sessions/c5027592-d30f-4e33-a337-71e6be0b77be Co-authored-by: samsta <3915450+samsta@users.noreply.github.com> --- src/lib/engineDj.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/engineDj.ts b/src/lib/engineDj.ts index 68312dc..a5c8802 100644 --- a/src/lib/engineDj.ts +++ b/src/lib/engineDj.ts @@ -41,13 +41,13 @@ function findTableName(tableNames: string[], target: string): string | null { } function getFirstValue(row: SqlRow, names: string[]): T | null { - const valuesByNormalizedName = new Map() + const normalizedValues = new Map() for (const [key, value] of Object.entries(row)) { - valuesByNormalizedName.set(normalizeColumnName(key), value) + normalizedValues.set(normalizeColumnName(key), value) } for (const name of names) { - const value = valuesByNormalizedName.get(normalizeColumnName(name)) + const value = normalizedValues.get(normalizeColumnName(name)) if (value !== undefined) { return value as T } From 0ec52e30f98939779d1c7bbb4fb145fbed93a5ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 12:15:08 +0000 Subject: [PATCH 10/28] feat: add path cost metrics sorting and per-step limits Agent-Logs-Url: https://github.com/samsta/KeyBpmMap/sessions/90031805-9e8f-48d8-a14f-f94a889d1db0 Co-authored-by: samsta <3915450+samsta@users.noreply.github.com> --- src/App.css | 6 ++ src/App.tsx | 135 +++++++++++++++++++++++++++++++++---- src/lib/pathFinder.test.ts | 26 +++++++ src/lib/pathFinder.ts | 16 +++++ 4 files changed, 171 insertions(+), 12 deletions(-) diff --git a/src/App.css b/src/App.css index c22a512..9807901 100644 --- a/src/App.css +++ b/src/App.css @@ -268,6 +268,12 @@ input[type='checkbox'] { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } +.pathfinder-panel input[type='number'], +.pathfinder-panel input[type='search'], +.pathfinder-panel select { + min-height: 44px; +} + .path-checkbox { align-self: end; display: flex; diff --git a/src/App.tsx b/src/App.tsx index f9f1b01..c54413c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,7 +49,13 @@ interface OpenFilePickerConfig { } type PolarKeyMode = 'both' | 'A' | 'B' +type PathSortMode = 'total' | 'average' | 'max' const POLAR_KEY_MODES = ['both', 'A', 'B'] as const +const PATH_SORT_MODES: Array<{ value: PathSortMode; label: string }> = [ + { value: 'total', label: 'Total Cost' }, + { value: 'average', label: 'Average Step Cost' }, + { value: 'max', label: 'Max Step Cost' }, +] const KEY_REPRESENTATIONS = ['camelot', 'open-key', 'musical'] as const const KEY_REPRESENTATION_STORAGE_KEY = 'keybpmmap.keyRepresentation' const PATH_FINDER_SETTINGS_STORAGE_KEY = 'keybpmmap.pathFinderSettings' @@ -121,6 +127,7 @@ function App() { } | null>(null) const [pathSearchStatus, setPathSearchStatus] = useState(null) const [selectedPathId, setSelectedPathId] = useState(null) + const [pathSortMode, setPathSortMode] = useState('total') const [showScopeTrackTable, setShowScopeTrackTable] = useState(false) const fileInputRef = useRef(null) const polarRef = useRef(null) @@ -273,19 +280,31 @@ function App() { }, [pathScopeTracks, searchedPathSelection], ) + const sortedNavigationPaths = useMemo( + () => + [...navigationPaths].sort((left, right) => { + const metricDelta = getPathSortMetric(left, pathSortMode) - getPathSortMetric(right, pathSortMode) + if (metricDelta !== 0) { + return metricDelta + } + + return left.totalCost - right.totalCost || left.steps.length - right.steps.length + }), + [navigationPaths, pathSortMode], + ) const selectedPath = useMemo( () => - navigationPaths.find((path) => path.id === selectedPathId) ?? - navigationPaths[0] ?? + sortedNavigationPaths.find((path) => path.id === selectedPathId) ?? + sortedNavigationPaths[0] ?? null, - [navigationPaths, selectedPathId], + [selectedPathId, sortedNavigationPaths], ) const pathGraphMaxNodes = useMemo( - () => Math.max(...navigationPaths.map((path) => path.trackIds.length), 0), - [navigationPaths], + () => Math.max(...sortedNavigationPaths.map((path) => path.trackIds.length), 0), + [sortedNavigationPaths], ) const pathGraphWidth = Math.max(460, pathGraphMaxNodes * 150) - const pathGraphHeight = Math.max(150, navigationPaths.length * 92 + 24) + const pathGraphHeight = Math.max(150, sortedNavigationPaths.length * 92 + 24) const formatVisibleKey = useMemo( () => (camelotKey: string) => formatKey(camelotKey, keyRepresentation), [keyRepresentation], @@ -360,6 +379,34 @@ function App() { ) } + const handlePathMaxStepCostChange = (value: string) => { + const nextValue = Number(value) + if (!Number.isFinite(nextValue)) { + return + } + + setPathFinderSettings((current) => + clampPathFinderSettings({ + ...current, + maxStepCost: nextValue, + }), + ) + } + + const handlePathMaxAverageCostChange = (value: string) => { + const nextValue = Number(value) + if (!Number.isFinite(nextValue)) { + return + } + + setPathFinderSettings((current) => + clampPathFinderSettings({ + ...current, + maxAverageStepCost: nextValue, + }), + ) + } + const handleSetPathTrack = (target: 'startTrack' | 'endTrack', trackId: string) => { const label = pathTrackLabelById.get(trackId) if (!label) { @@ -785,7 +832,7 @@ function App() {

Weights define how expensive each key or tempo move is. Lower values prefer that move. The - search keeps only the 5 best paths under Max Total Cost and runs only when you click Find + search keeps only the 5 best paths under your path limits and runs only when you click Find Path.

@@ -837,6 +884,34 @@ function App() { onChange={(event) => handlePathMaxCostChange(event.target.value)} /> + + +