new song structure

This commit is contained in:
2025-10-22 16:04:12 +02:00
parent 7b68a8c101
commit da001a491b
6 changed files with 420 additions and 86 deletions
+49
View File
@@ -16,6 +16,20 @@ const STRUCTURE_PROMPT_LABELS = {
short_intro: "introduction instrumentale courte",
long_intro: "introduction instrumentale longue",
pre_chorus: "pré-refrain",
pre_refrain_instrumental: "pré-refrain instrumental",
pont: "pont",
solo_de_guitare: "solo de guitare",
solo_de_guitare_electrique: "solo de guitare électrique",
solo_de_batterie: "solo de batterie",
solo_de_saxophone: "solo de saxophone",
solo_de_violon: "solo de violon",
break: "break",
interlude: "interlude",
interlude_melodique: "interlude mélodique",
final_apogee: "final apogée",
arret_net: "arrêt net",
fade_out: "fade out",
transition_douce: "transition douce vers le silence",
};
const STRUCTURE_ALIASES = {
@@ -30,6 +44,20 @@ const STRUCTURE_ALIASES = {
"pre chorus": "pre_chorus",
"pre-chorus": "pre_chorus",
prechorus: "pre_chorus",
"pré-refrain instrumental": "pre_refrain_instrumental",
"pre-refrain instrumental": "pre_refrain_instrumental",
"instrumental pre-chorus": "pre_refrain_instrumental",
"instrumental pre chorus": "pre_refrain_instrumental",
bridge: "pont",
guitar_solo: "solo_de_guitare",
electric_guitar_solo: "solo_de_guitare_electrique",
drum_solo: "solo_de_batterie",
sax_solo: "solo_de_saxophone",
violin_solo: "solo_de_violon",
melodic_interlude: "interlude_melodique",
grand_finale: "final_apogee",
sudden_stop: "arret_net",
soft_transition: "transition_douce",
};
const normalizeStructureValue = (value) => {
@@ -61,6 +89,27 @@ const sanitizeStructureEntries = (structure = []) => {
if (!output.includes("pre_chorus")) output.push("pre_chorus");
return;
}
if (normalized === "pre_refrain_instrumental") {
if (!output.includes("pre_refrain_instrumental")) {
output.push("pre_refrain_instrumental");
}
return;
}
if (
normalized === "final_apogee" ||
normalized === "arret_net" ||
normalized === "fade_out" ||
normalized === "transition_douce"
) {
const outroIndex = output.findIndex((item) =>
["final_apogee", "arret_net", "fade_out", "transition_douce"].includes(
item,
),
);
if (outroIndex !== -1) {
output.splice(outroIndex, 1);
}
}
output.push(normalized);
});
return output;
+2 -2
View File
@@ -363,7 +363,7 @@ const MusicDetails = ({ route }) => {
let type = "section";
if (lower.includes("refrain") || lower.includes("chorus")) type = "refrain";
else if (lower.includes("couplet") || lower.includes("verse")) type = "couplet";
else if (lower.includes("bridge")) type = "bridge";
else if (lower.includes("pont") || lower.includes("bridge")) type = "pont";
else if (lower.includes("intro")) type = "intro";
const indexMatch = label.match(/(\d+)/);
const index = indexMatch ? Number(indexMatch[1]) : undefined;
@@ -373,7 +373,7 @@ const MusicDetails = ({ route }) => {
if (fallback) return fallback;
if (type === "refrain") return index > 1 ? `Refrain ${index}` : "Refrain";
if (type === "couplet") return index > 1 ? `Couplet ${index}` : "Couplet";
if (type === "bridge") return index > 1 ? `Pont ${index}` : "Pont";
if (type === "pont") return index > 1 ? `Pont ${index}` : "Pont";
if (type === "intro") return "Intro";
return index > 1 ? `Section ${index}` : "Section";
};
+2 -2
View File
@@ -445,7 +445,7 @@ const MusicDetails = ({ route }) => {
lower.includes("pre-refrain")
)
type = "pre_chorus";
else if (lower.includes("bridge")) type = "bridge";
else if (lower.includes("pont") || lower.includes("bridge")) type = "pont";
else if (lower.includes("intro")) {
if (lower.includes("long")) type = "long_intro";
else type = "short_intro";
@@ -468,7 +468,7 @@ const MusicDetails = ({ route }) => {
return index > 1 ? `Refrain ${index}` : "Refrain";
if (normalized === "couplet")
return index > 1 ? `Couplet ${index}` : "Couplet";
if (type === "bridge") return index > 1 ? `Pont ${index}` : "Pont";
if (type === "pont") return index > 1 ? `Pont ${index}` : "Pont";
if (type === "intro") return index > 1 ? `Intro ${index}` : "Intro";
return index > 1 ? `Section ${index}` : "Section";
};
+3 -2
View File
@@ -27,6 +27,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
const hasShownErrorAlert = useRef(false);
const { setIsLoading } = useMinuit();
const { updateProjectData } = useUser();
const progressValue = Math.max(0, Math.min(100, Math.round(progress)));
// Reset when becomes active
useEffect(() => {
@@ -301,7 +302,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
: "Ton texte est\nen cours de création"}
</Text>
<View style={{ alignItems: "center", gap: 16 }}>
<ProgressBar gradient progress={progress} />
<ProgressBar gradient progress={progressValue} />
<Text
style={{
fontSize: 14,
@@ -309,7 +310,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{progress}%
{progressValue}%
</Text>
</View>
{!saved && result && !error && (
+132 -34
View File
@@ -1,5 +1,5 @@
import { BlurView } from "expo-blur";
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
FlatList,
Image,
@@ -15,6 +15,7 @@ import { icons } from "../../assets";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
arraysAreSame,
formatStructureLabel,
getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
@@ -40,20 +41,28 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
() => sanitizeStructureList(baseStructure),
[baseStructure]
);
const extractValues = useCallback(
(items) =>
(items || []).map((item) => item?.type || item?.value).filter(Boolean),
[]
);
const [sortableItems, setSortableItems] = useState(() =>
buildSortableItems(sanitizedBase)
);
useEffect(() => {
setSortableItems(buildSortableItems(sanitizedBase));
}, [sanitizedBase]);
setSortableItems((prev) => {
const currentValues = extractValues(prev);
if (arraysAreSame(currentValues, sanitizedBase)) {
return prev;
}
return buildSortableItems(sanitizedBase);
});
}, [sanitizedBase, extractValues]);
useEffect(() => {
const values = sortableItems
.map((item) => item?.type || item?.value)
.filter(Boolean);
const values = extractValues(sortableItems);
onChange?.(values);
}, [sortableItems, onChange]);
}, [sortableItems, onChange, extractValues]);
const labeledItems = useMemo(() => {
const counts = {};
@@ -80,40 +89,63 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
[]
);
const selectedTypes = useMemo(() => {
const set = new Set();
sortableItems.forEach((item) => {
if (item?.type) set.add(item.type);
else if (item?.value) set.add(item.value);
const segmentCounts = useMemo(() => {
const counts = {};
extractValues(sortableItems).forEach((type) => {
counts[type] = (counts[type] || 0) + 1;
});
return set;
}, [sortableItems]);
return counts;
}, [sortableItems, extractValues]);
const toggleOptionalSegment = (type) => {
const handleRemoveItem = useCallback(
(itemId) => {
setSortableItems((prev) => {
const currentValues = prev.map((item) => item.type || item.value);
if (currentValues.includes(type)) {
const filtered = currentValues.filter((value) => value !== type);
return buildSortableItems(filtered);
}
const remaining = prev.filter((item) => item.id !== itemId);
const sanitized = sanitizeStructureList(extractValues(remaining));
return buildSortableItems(sanitized);
});
},
[extractValues]
);
const handleOptionalSegmentPress = useCallback(
(type) => {
const meta = getSegmentMeta(type);
setSortableItems((prev) => {
const currentValues = extractValues(prev);
let nextValues = [...currentValues];
if (meta?.exclusiveGroup) {
nextValues = nextValues.filter((value) => {
const itemMeta = getSegmentMeta(value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
});
}
const allowsMultiple = meta?.allowMultiple !== false;
if (!allowsMultiple && nextValues.includes(type)) {
const filtered = nextValues.filter((value) => value !== type);
const sanitized = sanitizeStructureList(filtered);
return buildSortableItems(sanitized);
}
nextValues.push(type);
return buildSortableItems(nextValues);
const sanitized = sanitizeStructureList(nextValues);
return buildSortableItems(sanitized);
});
};
},
[extractValues]
);
const Row = ({ item: rowData, onPress }) => {
const isButton = typeof onPress === "function";
const Wrapper = isButton ? TouchableOpacity : View;
const selected = rowData?.selected;
const icon = rowData?.icon;
const baseType = rowData?.type || rowData?.value;
const meta = getSegmentMeta(baseType);
const canRemove = rowData?.sortable && meta?.optional === true;
const count = rowData?.count ?? 0;
return (
<Wrapper
onPress={onPress}
@@ -139,14 +171,29 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<Text style={styles.rowText}>{rowData?.label || ""}</Text>
</View>
{rowData?.sortable ? (
<View style={styles.rowActions}>
<Sortable.Handle>
<Image source={icons.dragDots} style={styles.handleIcon} />
</Sortable.Handle>
{canRemove && (
<TouchableOpacity
onPress={() => handleRemoveItem(rowData.id)}
style={styles.removeButton}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
>
<Image source={icons.close} style={styles.removeIcon} />
</TouchableOpacity>
)}
</View>
) : (
<Image
source={icon || (selected ? icons.check : icons.add)}
style={[styles.optionIcon]}
/>
<View
style={[
styles.countBadge,
count > 0 ? styles.countBadgeActive : null,
]}
>
<Text style={styles.countBadgeText}>{count}</Text>
</View>
)}
</BlurView>
</View>
@@ -166,7 +213,9 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
contentContainerStyle={{ paddingVertical: 4, paddingBottom: 24 }}
showsVerticalScrollIndicator={false}
>
<>
<View style={styles.sectionTag}>
<Text style={styles.sectionTagText}>Structure actuelle</Text>
</View>
<Sortable.Grid
columns={1}
rowGap={12}
@@ -175,18 +224,20 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
customHandle
scrollableRef={scrollRef}
onDragEnd={({ data: newData }) => {
const values = (newData || [])
.map((it) => it?.type || it?.value)
.filter(Boolean);
const values = extractValues(newData);
const sanitized = sanitizeStructureList(values);
setSortableItems(buildSortableItems(sanitized));
}}
/>
<View style={[styles.sectionTag, { marginTop: 24 }]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
</View>
<FlatList
data={optionalSegments}
gap={12}
renderItem={({ item }) => {
const selected = selectedTypes.has(item.type);
const count = segmentCounts[item.type] || 0;
const selected = count > 0;
return (
<View style={{ marginTop: 12 }}>
<Row
@@ -194,8 +245,9 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
...item,
sortable: false,
selected,
count,
}}
onPress={() => toggleOptionalSegment(item.type)}
onPress={() => handleOptionalSegmentPress(item.type)}
/>
</View>
);
@@ -205,7 +257,6 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
scrollEnabled={false}
nestedScrollEnabled={false}
/>
</>
</Animated.ScrollView>
</View>
</View>
@@ -267,10 +318,57 @@ const styles = StyleSheet.create({
width: 22,
height: 22,
tintColor: Palette.white,
marginRight: 12,
},
optionIcon: {
width: 20,
height: 20,
tintColor: Palette.white,
},
countBadge: {
minWidth: 28,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.08)",
alignItems: "center",
justifyContent: "center",
},
countBadgeActive: {
backgroundColor: Palette.transparentWhite,
},
countBadgeText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
rowActions: {
flexDirection: "row",
alignItems: "center",
},
removeButton: {
padding: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.08)",
},
removeIcon: {
width: 16,
height: 16,
tintColor: Palette.white,
},
sectionTag: {
alignSelf: "flex-start",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.12)",
marginBottom: 16,
},
sectionTagText: {
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
letterSpacing: 1,
textTransform: "uppercase",
},
});
+186
View File
@@ -65,6 +65,10 @@ export const STRUCTURE_SEGMENTS = {
"intro instrumentale longue",
],
},
// pre_chorus: {
// label: "Pré-refrain",
// promptLabel: "Pré-refrain",
// showIndex: false,
pre_chorus: {
label: "Pré-refrain",
promptLabel: "Pré-refrain",
@@ -81,6 +85,151 @@ export const STRUCTURE_SEGMENTS = {
"pré chorus",
],
},
pre_refrain_instrumental: {
label: "Pré-refrain instrumental",
promptLabel: "Pré-refrain instrumental",
showIndex: false,
allowMultiple: false,
optional: true,
inputHeight: 150,
exclusiveGroup: "pre_chorus",
requiresLyrics: false,
aliases: ["instrumental_pre_chorus"],
},
pont: {
label: "Pont",
promptLabel: "Pont",
showIndex: false,
allowMultiple: true,
optional: true,
inputHeight: 225,
requiresLyrics: true,
aliases: ["bridge"],
},
solo_de_guitare: {
label: "Solo de guitare",
promptLabel: "Solo de guitare",
showIndex: true,
allowMultiple: true,
optional: true,
inputHeight: 150,
requiresLyrics: false,
aliases: ["guitar_solo", "solo_guitare"],
},
solo_de_guitare_electrique: {
label: "Solo de guitare électrique",
promptLabel: "Solo de guitare électrique",
showIndex: true,
allowMultiple: true,
optional: true,
inputHeight: 150,
requiresLyrics: false,
aliases: ["electric_guitar_solo", "solo_guitare_electrique"],
},
solo_de_batterie: {
label: "Solo de batterie",
promptLabel: "Solo de batterie",
showIndex: true,
allowMultiple: true,
optional: true,
inputHeight: 150,
requiresLyrics: false,
aliases: ["drum_solo", "solo_batterie"],
},
solo_de_saxophone: {
label: "Solo de saxophone",
promptLabel: "Solo de saxophone",
showIndex: true,
allowMultiple: true,
optional: true,
inputHeight: 150,
requiresLyrics: false,
aliases: ["sax_solo", "solo_saxo"],
},
solo_de_violon: {
label: "Solo de violon",
promptLabel: "Solo de violon",
showIndex: true,
allowMultiple: true,
optional: true,
inputHeight: 150,
requiresLyrics: false,
aliases: ["violin_solo", "solo_violon"],
},
break: {
label: "Break",
promptLabel: "Break",
showIndex: true,
allowMultiple: true,
optional: true,
inputHeight: 150,
requiresLyrics: false,
aliases: [],
},
interlude: {
label: "Interlude",
promptLabel: "Interlude",
showIndex: true,
allowMultiple: true,
optional: true,
inputHeight: 150,
requiresLyrics: false,
aliases: [],
},
interlude_melodique: {
label: "Interlude mélodique",
promptLabel: "Interlude mélodique",
showIndex: true,
allowMultiple: true,
optional: true,
inputHeight: 150,
requiresLyrics: false,
aliases: ["melodic_interlude"],
},
final_apogee: {
label: "Final apogée",
promptLabel: "Final apogée",
showIndex: false,
allowMultiple: false,
optional: true,
inputHeight: 170,
exclusiveGroup: "outro",
requiresLyrics: false,
aliases: ["grand_finale"],
},
arret_net: {
label: "Arrêt net",
promptLabel: "Arrêt net",
showIndex: false,
allowMultiple: false,
optional: true,
inputHeight: 150,
exclusiveGroup: "outro",
requiresLyrics: false,
aliases: ["sudden_stop"],
},
fade_out: {
label: "Fade out",
promptLabel: "Fade out",
showIndex: false,
allowMultiple: false,
optional: true,
inputHeight: 150,
exclusiveGroup: "outro",
requiresLyrics: false,
aliases: [],
},
transition_douce: {
label: "Transition douce vers le silence",
promptLabel: "Transition douce",
showIndex: false,
allowMultiple: false,
optional: true,
inputHeight: 150,
exclusiveGroup: "outro",
requiresLyrics: false,
aliases: ["soft_transition"],
},
};
const ALIAS_TO_KEY = Object.entries(STRUCTURE_SEGMENTS).reduce(
@@ -133,6 +282,7 @@ export const sanitizeStructureList = (structure = []) => {
if (!Array.isArray(structure)) return [];
const output = [];
let shouldInjectPreChorus = false;
let shouldInjectInstrumentalPreChorus = false;
structure.forEach((segment) => {
const type = normalizeStructureType(segment);
@@ -160,6 +310,10 @@ export const sanitizeStructureList = (structure = []) => {
shouldInjectPreChorus = true;
return;
}
if (type === "pre_refrain_instrumental") {
shouldInjectInstrumentalPreChorus = true;
return;
}
output.push(type);
});
@@ -188,6 +342,38 @@ export const sanitizeStructureList = (structure = []) => {
result = expanded;
}
if (shouldInjectInstrumentalPreChorus) {
const baseWithoutInstrumental = result.filter(
(item) => item !== "pre_refrain_instrumental"
);
const expanded = [];
baseWithoutInstrumental.forEach((item) => {
if (item === "pre_chorus") {
const previous = expanded[expanded.length - 1];
if (previous !== "pre_refrain_instrumental") {
expanded.push("pre_refrain_instrumental");
}
}
if (item === "refrain") {
const previous = expanded[expanded.length - 1];
if (
previous !== "pre_refrain_instrumental" &&
previous !== "pre_chorus"
) {
expanded.push("pre_refrain_instrumental");
}
}
expanded.push(item);
});
if (!expanded.includes("pre_refrain_instrumental")) {
expanded.push("pre_refrain_instrumental");
}
result = expanded;
}
const introIndex = result.findIndex(
(item) => item === "short_intro" || item === "long_intro"
);