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", short_intro: "introduction instrumentale courte",
long_intro: "introduction instrumentale longue", long_intro: "introduction instrumentale longue",
pre_chorus: "pré-refrain", 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 = { const STRUCTURE_ALIASES = {
@@ -30,6 +44,20 @@ const STRUCTURE_ALIASES = {
"pre chorus": "pre_chorus", "pre chorus": "pre_chorus",
"pre-chorus": "pre_chorus", "pre-chorus": "pre_chorus",
prechorus: "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) => { const normalizeStructureValue = (value) => {
@@ -61,6 +89,27 @@ const sanitizeStructureEntries = (structure = []) => {
if (!output.includes("pre_chorus")) output.push("pre_chorus"); if (!output.includes("pre_chorus")) output.push("pre_chorus");
return; 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); output.push(normalized);
}); });
return output; return output;
+2 -2
View File
@@ -363,7 +363,7 @@ const MusicDetails = ({ route }) => {
let type = "section"; let type = "section";
if (lower.includes("refrain") || lower.includes("chorus")) type = "refrain"; if (lower.includes("refrain") || lower.includes("chorus")) type = "refrain";
else if (lower.includes("couplet") || lower.includes("verse")) type = "couplet"; 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"; else if (lower.includes("intro")) type = "intro";
const indexMatch = label.match(/(\d+)/); const indexMatch = label.match(/(\d+)/);
const index = indexMatch ? Number(indexMatch[1]) : undefined; const index = indexMatch ? Number(indexMatch[1]) : undefined;
@@ -373,7 +373,7 @@ const MusicDetails = ({ route }) => {
if (fallback) return fallback; if (fallback) return fallback;
if (type === "refrain") return index > 1 ? `Refrain ${index}` : "Refrain"; if (type === "refrain") return index > 1 ? `Refrain ${index}` : "Refrain";
if (type === "couplet") return index > 1 ? `Couplet ${index}` : "Couplet"; 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"; if (type === "intro") return "Intro";
return index > 1 ? `Section ${index}` : "Section"; return index > 1 ? `Section ${index}` : "Section";
}; };
+2 -2
View File
@@ -445,7 +445,7 @@ const MusicDetails = ({ route }) => {
lower.includes("pre-refrain") lower.includes("pre-refrain")
) )
type = "pre_chorus"; 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")) { else if (lower.includes("intro")) {
if (lower.includes("long")) type = "long_intro"; if (lower.includes("long")) type = "long_intro";
else type = "short_intro"; else type = "short_intro";
@@ -468,7 +468,7 @@ const MusicDetails = ({ route }) => {
return index > 1 ? `Refrain ${index}` : "Refrain"; return index > 1 ? `Refrain ${index}` : "Refrain";
if (normalized === "couplet") if (normalized === "couplet")
return index > 1 ? `Couplet ${index}` : "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"; if (type === "intro") return index > 1 ? `Intro ${index}` : "Intro";
return index > 1 ? `Section ${index}` : "Section"; return index > 1 ? `Section ${index}` : "Section";
}; };
+3 -2
View File
@@ -27,6 +27,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
const hasShownErrorAlert = useRef(false); const hasShownErrorAlert = useRef(false);
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
const { updateProjectData } = useUser(); const { updateProjectData } = useUser();
const progressValue = Math.max(0, Math.min(100, Math.round(progress)));
// Reset when becomes active // Reset when becomes active
useEffect(() => { useEffect(() => {
@@ -301,7 +302,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
: "Ton texte est\nen cours de création"} : "Ton texte est\nen cours de création"}
</Text> </Text>
<View style={{ alignItems: "center", gap: 16 }}> <View style={{ alignItems: "center", gap: 16 }}>
<ProgressBar gradient progress={progress} /> <ProgressBar gradient progress={progressValue} />
<Text <Text
style={{ style={{
fontSize: 14, fontSize: 14,
@@ -309,7 +310,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
}} }}
> >
{progress}% {progressValue}%
</Text> </Text>
</View> </View>
{!saved && result && !error && ( {!saved && result && !error && (
+132 -34
View File
@@ -1,5 +1,5 @@
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import React, { useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { import {
FlatList, FlatList,
Image, Image,
@@ -15,6 +15,7 @@ import { icons } from "../../assets";
import { Palette } from "../../styles"; import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import { import {
arraysAreSame,
formatStructureLabel, formatStructureLabel,
getSegmentMeta, getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS, OPTIONAL_STRUCTURE_SEGMENTS,
@@ -40,20 +41,28 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
() => sanitizeStructureList(baseStructure), () => sanitizeStructureList(baseStructure),
[baseStructure] [baseStructure]
); );
const extractValues = useCallback(
(items) =>
(items || []).map((item) => item?.type || item?.value).filter(Boolean),
[]
);
const [sortableItems, setSortableItems] = useState(() => const [sortableItems, setSortableItems] = useState(() =>
buildSortableItems(sanitizedBase) buildSortableItems(sanitizedBase)
); );
useEffect(() => { useEffect(() => {
setSortableItems(buildSortableItems(sanitizedBase)); setSortableItems((prev) => {
}, [sanitizedBase]); const currentValues = extractValues(prev);
if (arraysAreSame(currentValues, sanitizedBase)) {
return prev;
}
return buildSortableItems(sanitizedBase);
});
}, [sanitizedBase, extractValues]);
useEffect(() => { useEffect(() => {
const values = sortableItems const values = extractValues(sortableItems);
.map((item) => item?.type || item?.value)
.filter(Boolean);
onChange?.(values); onChange?.(values);
}, [sortableItems, onChange]); }, [sortableItems, onChange, extractValues]);
const labeledItems = useMemo(() => { const labeledItems = useMemo(() => {
const counts = {}; const counts = {};
@@ -80,40 +89,63 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
[] []
); );
const selectedTypes = useMemo(() => { const segmentCounts = useMemo(() => {
const set = new Set(); const counts = {};
sortableItems.forEach((item) => { extractValues(sortableItems).forEach((type) => {
if (item?.type) set.add(item.type); counts[type] = (counts[type] || 0) + 1;
else if (item?.value) set.add(item.value);
}); });
return set; return counts;
}, [sortableItems]); }, [sortableItems, extractValues]);
const toggleOptionalSegment = (type) => { const handleRemoveItem = useCallback(
(itemId) => {
setSortableItems((prev) => { setSortableItems((prev) => {
const currentValues = prev.map((item) => item.type || item.value); const remaining = prev.filter((item) => item.id !== itemId);
if (currentValues.includes(type)) { const sanitized = sanitizeStructureList(extractValues(remaining));
const filtered = currentValues.filter((value) => value !== type); return buildSortableItems(sanitized);
return buildSortableItems(filtered); });
} },
[extractValues]
);
const handleOptionalSegmentPress = useCallback(
(type) => {
const meta = getSegmentMeta(type); const meta = getSegmentMeta(type);
setSortableItems((prev) => {
const currentValues = extractValues(prev);
let nextValues = [...currentValues]; let nextValues = [...currentValues];
if (meta?.exclusiveGroup) { if (meta?.exclusiveGroup) {
nextValues = nextValues.filter((value) => { nextValues = nextValues.filter((value) => {
const itemMeta = getSegmentMeta(value); const itemMeta = getSegmentMeta(value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup; 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); nextValues.push(type);
return buildSortableItems(nextValues); const sanitized = sanitizeStructureList(nextValues);
return buildSortableItems(sanitized);
}); });
}; },
[extractValues]
);
const Row = ({ item: rowData, onPress }) => { const Row = ({ item: rowData, onPress }) => {
const isButton = typeof onPress === "function"; const isButton = typeof onPress === "function";
const Wrapper = isButton ? TouchableOpacity : View; const Wrapper = isButton ? TouchableOpacity : View;
const selected = rowData?.selected; const selected = rowData?.selected;
const icon = rowData?.icon; 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 ( return (
<Wrapper <Wrapper
onPress={onPress} onPress={onPress}
@@ -139,14 +171,29 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<Text style={styles.rowText}>{rowData?.label || ""}</Text> <Text style={styles.rowText}>{rowData?.label || ""}</Text>
</View> </View>
{rowData?.sortable ? ( {rowData?.sortable ? (
<View style={styles.rowActions}>
<Sortable.Handle> <Sortable.Handle>
<Image source={icons.dragDots} style={styles.handleIcon} /> <Image source={icons.dragDots} style={styles.handleIcon} />
</Sortable.Handle> </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 <View
source={icon || (selected ? icons.check : icons.add)} style={[
style={[styles.optionIcon]} styles.countBadge,
/> count > 0 ? styles.countBadgeActive : null,
]}
>
<Text style={styles.countBadgeText}>{count}</Text>
</View>
)} )}
</BlurView> </BlurView>
</View> </View>
@@ -166,7 +213,9 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
contentContainerStyle={{ paddingVertical: 4, paddingBottom: 24 }} contentContainerStyle={{ paddingVertical: 4, paddingBottom: 24 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<> <View style={styles.sectionTag}>
<Text style={styles.sectionTagText}>Structure actuelle</Text>
</View>
<Sortable.Grid <Sortable.Grid
columns={1} columns={1}
rowGap={12} rowGap={12}
@@ -175,18 +224,20 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
customHandle customHandle
scrollableRef={scrollRef} scrollableRef={scrollRef}
onDragEnd={({ data: newData }) => { onDragEnd={({ data: newData }) => {
const values = (newData || []) const values = extractValues(newData);
.map((it) => it?.type || it?.value)
.filter(Boolean);
const sanitized = sanitizeStructureList(values); const sanitized = sanitizeStructureList(values);
setSortableItems(buildSortableItems(sanitized)); setSortableItems(buildSortableItems(sanitized));
}} }}
/> />
<View style={[styles.sectionTag, { marginTop: 24 }]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
</View>
<FlatList <FlatList
data={optionalSegments} data={optionalSegments}
gap={12} gap={12}
renderItem={({ item }) => { renderItem={({ item }) => {
const selected = selectedTypes.has(item.type); const count = segmentCounts[item.type] || 0;
const selected = count > 0;
return ( return (
<View style={{ marginTop: 12 }}> <View style={{ marginTop: 12 }}>
<Row <Row
@@ -194,8 +245,9 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
...item, ...item,
sortable: false, sortable: false,
selected, selected,
count,
}} }}
onPress={() => toggleOptionalSegment(item.type)} onPress={() => handleOptionalSegmentPress(item.type)}
/> />
</View> </View>
); );
@@ -205,7 +257,6 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
scrollEnabled={false} scrollEnabled={false}
nestedScrollEnabled={false} nestedScrollEnabled={false}
/> />
</>
</Animated.ScrollView> </Animated.ScrollView>
</View> </View>
</View> </View>
@@ -267,10 +318,57 @@ const styles = StyleSheet.create({
width: 22, width: 22,
height: 22, height: 22,
tintColor: Palette.white, tintColor: Palette.white,
marginRight: 12,
}, },
optionIcon: { optionIcon: {
width: 20, width: 20,
height: 20, height: 20,
tintColor: Palette.white, 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", "intro instrumentale longue",
], ],
}, },
// pre_chorus: {
// label: "Pré-refrain",
// promptLabel: "Pré-refrain",
// showIndex: false,
pre_chorus: { pre_chorus: {
label: "Pré-refrain", label: "Pré-refrain",
promptLabel: "Pré-refrain", promptLabel: "Pré-refrain",
@@ -81,6 +85,151 @@ export const STRUCTURE_SEGMENTS = {
"pré chorus", "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( const ALIAS_TO_KEY = Object.entries(STRUCTURE_SEGMENTS).reduce(
@@ -133,6 +282,7 @@ export const sanitizeStructureList = (structure = []) => {
if (!Array.isArray(structure)) return []; if (!Array.isArray(structure)) return [];
const output = []; const output = [];
let shouldInjectPreChorus = false; let shouldInjectPreChorus = false;
let shouldInjectInstrumentalPreChorus = false;
structure.forEach((segment) => { structure.forEach((segment) => {
const type = normalizeStructureType(segment); const type = normalizeStructureType(segment);
@@ -160,6 +310,10 @@ export const sanitizeStructureList = (structure = []) => {
shouldInjectPreChorus = true; shouldInjectPreChorus = true;
return; return;
} }
if (type === "pre_refrain_instrumental") {
shouldInjectInstrumentalPreChorus = true;
return;
}
output.push(type); output.push(type);
}); });
@@ -188,6 +342,38 @@ export const sanitizeStructureList = (structure = []) => {
result = expanded; 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( const introIndex = result.findIndex(
(item) => item === "short_intro" || item === "long_intro" (item) => item === "short_intro" || item === "long_intro"
); );