diff --git a/frontend/src/melodies/MelodyComposer.jsx b/frontend/src/melodies/MelodyComposer.jsx
index 00c9ec9..6965400 100644
--- a/frontend/src/melodies/MelodyComposer.jsx
+++ b/frontend/src/melodies/MelodyComposer.jsx
@@ -345,8 +345,18 @@ export default function MelodyComposer() {
Clear
-
@@ -193,6 +209,10 @@ export default function MelodyDetail() {
const info = melody.information || {};
const settings = melody.default_settings || {};
+ const speedMs = mapPercentageToStepDelay(settings.speed, info.minSpeed, info.maxSpeed);
+ const speedBpm = formatBpm(speedMs);
+ const minBpm = formatBpm(info.minSpeed);
+ const maxBpm = formatBpm(info.maxSpeed);
const languages = melodySettings?.available_languages || ["en"];
const displayName = getLocalizedValue(info.name, displayLang, "Untitled Melody");
@@ -313,8 +333,22 @@ export default function MelodyDetail() {
{info.steps}
{info.totalNotes}
{info.totalActiveBells ?? "-"}
-
{info.minSpeed}
-
{info.maxSpeed}
+
+ {info.minSpeed ? (
+
+
{minBpm} BPM
+
{info.minSpeed} ms
+
+ ) : "-"}
+
+
+ {info.maxSpeed ? (
+
+
{maxBpm} BPM
+
{info.maxSpeed} ms
+
+ ) : "-"}
+
{info.color ? (
@@ -391,7 +425,13 @@ export default function MelodyDetail() {
Default Settings
- {settings.speed}%
+
+ {settings.speed != null ? (
+
+ {settings.speed}%{speedBpm ? ` · ${speedBpm} BPM` : ""}{speedMs ? ` · ${speedMs} ms` : ""}
+
+ ) : "-"}
+
{formatDuration(settings.duration)}
{settings.totalRunDuration}
{settings.pauseDuration}
diff --git a/frontend/src/melodies/MelodyForm.jsx b/frontend/src/melodies/MelodyForm.jsx
index f29a0c0..1817cf2 100644
--- a/frontend/src/melodies/MelodyForm.jsx
+++ b/frontend/src/melodies/MelodyForm.jsx
@@ -53,6 +53,22 @@ const headingStyle = { color: "var(--text-heading)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
+function formatBpm(ms) {
+ const value = Number(ms);
+ if (!value || value <= 0) return null;
+ return Math.round(60000 / value);
+}
+
+function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
+ if (minSpeed == null || maxSpeed == null) return null;
+ const p = Math.max(0, Math.min(100, Number(percent || 0)));
+ const t = p / 100;
+ const a = Number(minSpeed);
+ const b = Number(maxSpeed);
+ if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t);
+ return Math.round(a * Math.pow(b / a, t));
+}
+
export default function MelodyForm() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -308,6 +324,10 @@ export default function MelodyForm() {
const durationIndex = durationValues.indexOf(settings.duration);
const currentDurationIdx = durationIndex >= 0 ? durationIndex : 0;
+ const speedMs = mapPercentageToStepDelay(settings.speed, information.minSpeed, information.maxSpeed);
+ const speedBpm = formatBpm(speedMs);
+ const minBpm = formatBpm(information.minSpeed);
+ const maxBpm = formatBpm(information.maxSpeed);
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
@@ -508,11 +528,17 @@ export default function MelodyForm() {
updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
+ {information.minSpeed > 0 && (
+
{minBpm} BPM · {information.minSpeed} ms
+ )}
updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
+ {information.maxSpeed > 0 && (
+
{maxBpm} BPM · {information.maxSpeed} ms
+ )}
{/* Color */}
@@ -594,6 +620,9 @@ export default function MelodyForm() {
updateSettings("speed", parseInt(e.target.value, 10))} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
{settings.speed}%
+
+ {speedBpm && speedMs ? `${speedBpm} BPM · ${speedMs} ms` : "Set MIN/MAX speed to compute BPM"}
+
diff --git a/frontend/src/melodies/MelodyList.jsx b/frontend/src/melodies/MelodyList.jsx
index 35a416b..353c68a 100644
--- a/frontend/src/melodies/MelodyList.jsx
+++ b/frontend/src/melodies/MelodyList.jsx
@@ -64,10 +64,38 @@ function getDefaultVisibleColumns() {
function speedBarColor(speedPercent) {
const v = Math.max(0, Math.min(100, Number(speedPercent || 0)));
- const hue = (v / 100) * 120;
+ if (v <= 50) {
+ const t = v / 50;
+ const hue = 120 + (210 - 120) * t; // green -> blue
+ return `hsl(${hue}, 85%, 46%)`;
+ }
+ const t = (v - 50) / 50;
+ const hue = 210 + (0 - 210) * t; // blue -> red
return `hsl(${hue}, 85%, 46%)`;
}
+function durationBarColor(percent) {
+ const v = Math.max(0, Math.min(100, Number(percent || 0)));
+ const hue = 220 + (0 - 220) * (v / 100); // blue -> red
+ return `hsl(${hue}, 85%, 46%)`;
+}
+
+function formatBpm(ms) {
+ const value = Number(ms);
+ if (!value || value <= 0) return null;
+ return Math.round(60000 / value);
+}
+
+function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
+ if (minSpeed == null || maxSpeed == null) return null;
+ const p = Math.max(0, Math.min(100, Number(percent || 0)));
+ const t = p / 100;
+ const a = Number(minSpeed);
+ const b = Number(maxSpeed);
+ if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t);
+ return Math.round(a * Math.pow(b / a, t));
+}
+
function parseDateValue(isoValue) {
if (!isoValue) return 0;
const time = new Date(isoValue).getTime();
@@ -373,7 +401,19 @@ export default function MelodyList() {
);
}
case "type":
- return
{row.type};
+ {
+ const typeStyles = {
+ orthodox: { color: "#7dd3fc", backgroundColor: "rgba(14,165,233,0.15)" },
+ catholic: { color: "#fda4af", backgroundColor: "rgba(244,63,94,0.15)" },
+ all: { color: "#86efac", backgroundColor: "rgba(34,197,94,0.14)" },
+ };
+ const style = typeStyles[row.type] || { color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" };
+ return (
+
+ {row.type || "-"}
+
+ );
+ }
case "tone":
return
{info.melodyTone || "-"};
case "totalNotes":
@@ -381,9 +421,21 @@ export default function MelodyList() {
case "totalActiveBells":
return info.totalActiveBells ?? "-";
case "minSpeed":
- return info.minSpeed ?? "-";
+ if (!info.minSpeed) return "-";
+ return (
+
+
{formatBpm(info.minSpeed)} BPM
+
{info.minSpeed} ms
+
+ );
case "maxSpeed":
- return info.maxSpeed ?? "-";
+ if (!info.maxSpeed) return "-";
+ return (
+
+
{formatBpm(info.maxSpeed)} BPM
+
{info.maxSpeed} ms
+
+ );
case "tags":
return info.customTags?.length > 0 ? (
@@ -402,10 +454,13 @@ export default function MelodyList() {
);
case "speed":
if (ds.speed == null) return "-";
+ {
+ const speedMs = mapPercentageToStepDelay(ds.speed, info.minSpeed, info.maxSpeed);
+ const bpm = formatBpm(speedMs);
return (
- {ds.speed}%
+ {ds.speed}%{bpm ? ` · ${bpm} BPM` : ""}{speedMs ? ` · ${speedMs} ms` : ""}
);
+ }
case "duration":
if (ds.duration == null) return "-";
if (Number(ds.duration) === 0) {
@@ -453,7 +509,7 @@ export default function MelodyList() {
className="h-full rounded-full transition-all"
style={{
width: `${Math.max(0, Math.min(100, percent))}%`,
- backgroundColor: speedBarColor(percent),
+ backgroundColor: durationBarColor(percent),
}}
/>
@@ -766,7 +822,7 @@ export default function MelodyList() {
@@ -813,7 +869,7 @@ export default function MelodyList() {
|
|