new song structure

This commit is contained in:
2025-10-21 11:38:48 +02:00
parent 59328aaaed
commit 3a5d449424
11 changed files with 807 additions and 175 deletions
+80 -7
View File
@@ -10,6 +10,67 @@ const axios = require("axios");
const admin = require("firebase-admin");
const { refList } = require("../index");
const STRUCTURE_PROMPT_LABELS = {
couplet: "couplet",
refrain: "refrain",
short_intro: "introduction instrumentale courte",
long_intro: "introduction instrumentale longue",
pre_chorus: "pré-refrain",
};
const STRUCTURE_ALIASES = {
"short intro": "short_intro",
"introduction instrumentale courte": "short_intro",
"intro instrumentale courte": "short_intro",
"long intro": "long_intro",
"introduction instrumentale longue": "long_intro",
"intro instrumentale longue": "long_intro",
"pré-refrain": "pre_chorus",
"pre-refrain": "pre_chorus",
"pre chorus": "pre_chorus",
"pre-chorus": "pre_chorus",
prechorus: "pre_chorus",
};
const normalizeStructureValue = (value) => {
const raw = String(value || "").trim().toLowerCase();
if (!raw) return "";
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
if (STRUCTURE_PROMPT_LABELS[sanitized]) return sanitized;
if (STRUCTURE_ALIASES[sanitized]) return STRUCTURE_ALIASES[sanitized];
return sanitized;
};
const sanitizeStructureEntries = (structure = []) => {
if (!Array.isArray(structure)) return [];
const output = [];
structure.forEach((entry) => {
const normalized = normalizeStructureValue(entry);
if (!normalized) return;
if (normalized === "short_intro" || normalized === "long_intro") {
const existingIndex = output.findIndex(
(item) => item === "short_intro" || item === "long_intro",
);
if (existingIndex !== -1) output.splice(existingIndex, 1);
output.push(normalized);
return;
}
if (normalized === "pre_chorus") {
if (!output.includes("pre_chorus")) output.push("pre_chorus");
return;
}
output.push(normalized);
});
return output;
};
const mapStructureToPrompt = (structure = []) =>
sanitizeStructureEntries(structure).map(
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
);
const TOXIC_KEYWORDS = [
/\bnazis?\b/i,
/\bnazisme\b/i,
@@ -39,8 +100,9 @@ const buildModerationBrief = ({
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
];
if (Array.isArray(structure) && structure.length) {
sections.push(`<STRUCTURE>${structure.join(" | ")}</STRUCTURE>`);
const promptStructure = mapStructureToPrompt(structure);
if (promptStructure.length) {
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
}
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
@@ -76,17 +138,29 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
style = "",
audience = "",
emotion = "",
structure = ["couplet", "refrain", "couplet", "refrain"],
structure: rawStructure = [
"couplet",
"refrain",
"couplet",
"refrain",
],
rhymes = "",
} = data;
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
const promptStructure = sanitizedStructure.length
? sanitizedStructure.map(
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
)
: [];
const moderationPayload = {
objective,
context,
style,
audience,
emotion,
structure,
structure: promptStructure,
rhymes,
};
@@ -137,7 +211,7 @@ Génère un titre accrocheur et mémorable en plus des paroles, tout en respecta
Retourne uniquement un JSON conforme au schéma fourni, sans ajouter d'autres textes.
`.trim();
const structureTags = (Array.isArray(structure) ? structure : [])
const structureTags = (Array.isArray(promptStructure) ? promptStructure : [])
.map((part, index) => {
const content = part || "";
return ` <SECTION ordre="${index + 1}">${content}</SECTION>`;
@@ -191,8 +265,7 @@ ${structureTags || fallbackStructureTags}
type: z
.string()
.describe(
'Type de section : "couplet" ou "refrain" ' +
"selon la structure",
"Type de section : respecter exactement le nom de la section demandé dans la structure",
),
lyrics: z
.string()
+32 -6
View File
@@ -37,6 +37,11 @@ import Page from "../../layouts/Page";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import Style, { gutters, size } from "../../styles/Style";
import {
formatStructureLabel,
getSegmentMeta,
normalizeStructureType,
} from "../../utils/songStructure";
// 20 secondes
const timeBeforeIncrement = 20000;
@@ -325,12 +330,14 @@ const MusicDetails = ({ route }) => {
const description = useMemo(() => {
// Build a readable text from lyrics with section labels
if (Array.isArray(project?.lyrics)) {
const counts = {};
return project.lyrics
.map((s) => {
const body = (s?.lyrics || "").trim();
if (!body) return null;
const t = (s?.type || "").toLowerCase();
const label = t === "refrain" ? "Refrain" : "Couplet";
const type = normalizeStructureType(s?.type);
counts[type] = (counts[type] || 0) + 1;
const label = formatStructureLabel(type, counts[type]);
return `[${label}]\n${body}`;
})
.filter(Boolean)
@@ -370,22 +377,41 @@ const MusicDetails = ({ route }) => {
if (!match) return null;
const label = match[1]?.trim() || "";
const lower = label.toLowerCase();
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("pré") ||
lower.includes("prechorus") ||
lower.includes("pre-chorus") ||
lower.includes("pre chorus") ||
lower.includes("pre-refrain")
)
type = "pre_chorus";
else if (lower.includes("bridge")) type = "bridge";
else if (lower.includes("intro")) type = "intro";
else if (lower.includes("intro")) {
if (lower.includes("long")) type = "long_intro";
else type = "short_intro";
}
const indexMatch = label.match(/(\d+)/);
const index = indexMatch ? Number(indexMatch[1]) : undefined;
return { label, type, index };
};
const formatSectionLabel = (type, index, fallback) => {
if (fallback) return fallback;
if (type === "refrain") return index > 1 ? `Refrain ${index}` : "Refrain";
if (type === "couplet") return index > 1 ? `Couplet ${index}` : "Couplet";
const normalized = normalizeStructureType(type);
const meta = getSegmentMeta(normalized);
if (meta) {
return formatStructureLabel(normalized, index);
}
if (normalized === "refrain")
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 === "intro") return "Intro";
if (type === "intro") return index > 1 ? `Intro ${index}` : "Intro";
return index > 1 ? `Section ${index}` : "Section";
};
+2 -1
View File
@@ -10,6 +10,7 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles";
import { normalizeStructureType } from "../../utils/songStructure";
import ChooseGenre from "./ChooseGenre";
import ChooseInstruments from "./ChooseInstruments";
import ChooseRhythm from "./ChooseRhythm";
@@ -51,7 +52,7 @@ const ComposeSong = () => {
let lyricsArr = [];
if (Array.isArray(selectedProject?.lyrics)) {
lyricsArr = selectedProject.lyrics.map((s) => ({
type: (s?.type || "").toLowerCase(),
type: normalizeStructureType(s?.type),
lyrics: s?.lyrics || "",
}));
} else {
+2 -1
View File
@@ -15,6 +15,7 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles";
import { normalizeStructureType } from "../../utils/songStructure";
import ChooseGenre from "./ChooseGenre";
import ChooseInstruments from "./ChooseInstruments";
import ChooseRhythm from "./ChooseRhythm";
@@ -54,7 +55,7 @@ const ComposeSong = () => {
let lyricsArr = [];
if (Array.isArray(selectedProject?.lyrics)) {
lyricsArr = selectedProject.lyrics.map((s) => ({
type: (s?.type || "").toLowerCase(),
type: normalizeStructureType(s?.type),
lyrics: s?.lyrics || "",
}));
} else {
+36 -11
View File
@@ -12,6 +12,7 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles";
import { sanitizeStructureList } from "../../utils/songStructure";
import CreatingLyrics from "./CreatingLyrics";
import CustomizeSongStructure from "./CustomizeSongStructure";
import EmotionConvey from "./EmotionConvey";
@@ -140,11 +141,24 @@ const CreateLyricsWithAi = () => {
// Structure personnalisée: prioriser selections.customStructure puis config.structure
const savedCustom = Array.isArray(sel.customStructure)
? sel.customStructure
? sanitizeStructureList(sel.customStructure)
: null;
const cfgStructure = Array.isArray(cfg.structure) ? cfg.structure : null;
if (!Array.isArray(customStructure) && (savedCustom || cfgStructure)) {
setCustomStructure(savedCustom || cfgStructure);
const cfgStructure = Array.isArray(cfg.structure)
? sanitizeStructureList(cfg.structure)
: null;
const fallbackStructure =
savedCustom && savedCustom.length ? savedCustom : cfgStructure;
if (!Array.isArray(customStructure) && fallbackStructure?.length) {
setCustomStructure(fallbackStructure);
} else if (
Array.isArray(customStructure) &&
fallbackStructure?.length &&
(customStructure.length !== fallbackStructure.length ||
customStructure.some(
(value, idx) => value !== fallbackStructure[idx]
))
) {
setCustomStructure(fallbackStructure);
}
}, [selectedProject]);
@@ -167,7 +181,8 @@ const CreateLyricsWithAi = () => {
for (let i = 0; i < count; i++) result.push("refrain");
}
});
return result.length ? result : null;
const sanitizedResult = sanitizeStructureList(result);
return sanitizedResult.length ? sanitizedResult : null;
} catch (e) {
return null;
}
@@ -187,6 +202,13 @@ const CreateLyricsWithAi = () => {
const resolvedStyle = shouldUseOtherStyle
? persistedOtherStyle || undefined
: style || undefined;
const sanitizedCustom =
Array.isArray(customStructure) && customStructure.length > 0
? sanitizeStructureList(customStructure)
: [];
const sanitizedParsed = Array.isArray(parsedStructure)
? sanitizeStructureList(parsedStructure)
: [];
return {
objective: resolvedObjective,
@@ -198,9 +220,11 @@ const CreateLyricsWithAi = () => {
style: resolvedStyle,
audience: audience?.trim() ? audience.trim() : undefined,
structure:
(Array.isArray(customStructure) && customStructure.length > 0
? customStructure
: parsedStructure) || undefined,
sanitizedCustom.length > 0
? sanitizedCustom
: sanitizedParsed.length > 0
? sanitizedParsed
: undefined,
rhymes: rhymes || undefined,
};
}, [
@@ -276,12 +300,13 @@ const CreateLyricsWithAi = () => {
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
if (hasLyrics && selectedIndex === 6) {
const chosenStructure =
const chosenStructure = sanitizeStructureList(
Array.isArray(customStructure) && customStructure.length > 0
? customStructure
: Array.isArray(parsedStructure)
? parsedStructure
: [];
: []
);
await updateProjectData({
config: { structure: chosenStructure },
selections: {
@@ -294,7 +319,7 @@ const CreateLyricsWithAi = () => {
audience,
structure,
parsedStructure,
customStructure,
customStructure: sanitizeStructureList(customStructure),
rhymes,
},
hasLyrics: true,
+36 -11
View File
@@ -10,6 +10,7 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles";
import { sanitizeStructureList } from "../../utils/songStructure";
import CreatingLyrics from "./CreatingLyrics";
import CustomizeSongStructure from "./CustomizeSongStructure";
import EmotionConvey from "./EmotionConvey";
@@ -138,11 +139,24 @@ const CreateLyricsWithAi = () => {
// Structure personnalisée: prioriser selections.customStructure puis config.structure
const savedCustom = Array.isArray(sel.customStructure)
? sel.customStructure
? sanitizeStructureList(sel.customStructure)
: null;
const cfgStructure = Array.isArray(cfg.structure) ? cfg.structure : null;
if (!Array.isArray(customStructure) && (savedCustom || cfgStructure)) {
setCustomStructure(savedCustom || cfgStructure);
const cfgStructure = Array.isArray(cfg.structure)
? sanitizeStructureList(cfg.structure)
: null;
const fallbackStructure =
savedCustom && savedCustom.length ? savedCustom : cfgStructure;
if (!Array.isArray(customStructure) && fallbackStructure?.length) {
setCustomStructure(fallbackStructure);
} else if (
Array.isArray(customStructure) &&
fallbackStructure?.length &&
(customStructure.length !== fallbackStructure.length ||
customStructure.some(
(value, idx) => value !== fallbackStructure[idx]
))
) {
setCustomStructure(fallbackStructure);
}
}, [selectedProject]);
@@ -172,7 +186,8 @@ const CreateLyricsWithAi = () => {
for (let i = 0; i < count; i++) result.push("refrain");
}
});
return result.length ? result : null;
const sanitizedResult = sanitizeStructureList(result);
return sanitizedResult.length ? sanitizedResult : null;
} catch (e) {
return null;
}
@@ -192,6 +207,13 @@ const CreateLyricsWithAi = () => {
const resolvedStyle = shouldUseOtherStyle
? persistedOtherStyle || undefined
: style || undefined;
const sanitizedCustom =
Array.isArray(customStructure) && customStructure.length > 0
? sanitizeStructureList(customStructure)
: [];
const sanitizedParsed = Array.isArray(parsedStructure)
? sanitizeStructureList(parsedStructure)
: [];
return {
objective: resolvedObjective,
@@ -203,9 +225,11 @@ const CreateLyricsWithAi = () => {
style: resolvedStyle,
audience: audience?.trim() ? audience.trim() : undefined,
structure:
(Array.isArray(customStructure) && customStructure.length > 0
? customStructure
: parsedStructure) || undefined,
sanitizedCustom.length > 0
? sanitizedCustom
: sanitizedParsed.length > 0
? sanitizedParsed
: undefined,
rhymes: rhymes || undefined,
};
}, [
@@ -281,12 +305,13 @@ const CreateLyricsWithAi = () => {
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
if (hasLyrics && selectedIndex === 6) {
const chosenStructure =
const chosenStructure = sanitizeStructureList(
Array.isArray(customStructure) && customStructure.length > 0
? customStructure
: Array.isArray(parsedStructure)
? parsedStructure
: [];
: []
);
await updateProjectData({
config: { structure: chosenStructure },
selections: {
@@ -299,7 +324,7 @@ const CreateLyricsWithAi = () => {
audience,
structure,
parsedStructure,
customStructure,
customStructure: sanitizeStructureList(customStructure),
rhymes,
},
hasLyrics: true,
+60 -4
View File
@@ -12,6 +12,7 @@ import { navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { Palette, Style } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { sanitizeStructureList } from "../../utils/songStructure";
const CreatingLyrics = ({ active, config, selections }) => {
const [progress, setProgress] = useState(0);
@@ -58,6 +59,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
console.log("🚀 [CreatingLyrics] Lancement de la génération", {
platform: Platform.OS,
});
const sanitizedStructure = sanitizeStructureList(config?.structure);
const callable = firebase
.functions()
.httpsCallable("lyrics-generateLyrics");
@@ -67,10 +69,32 @@ const CreatingLyrics = ({ active, config, selections }) => {
emotion: config?.emotion,
style: config?.style,
audience: config?.audience,
structure: config?.structure,
structure: sanitizedStructure,
rhymes: config?.rhymes,
});
setResult(data);
const rawLyrics = Array.isArray(data?.lyrics) ? data.lyrics : [];
const normalizedLyrics = rawLyrics.map((section) => {
const sanitizedType =
sanitizeStructureList([section?.type])[0] || section?.type || "";
return {
...section,
type: sanitizedType,
};
});
const alignedLyrics =
sanitizedStructure.length > 0 &&
sanitizedStructure.length === normalizedLyrics.length
? normalizedLyrics.map((section, index) => ({
...section,
type: sanitizedStructure[index],
}))
: normalizedLyrics;
const processedResult = {
...data,
lyrics: alignedLyrics,
structure: sanitizedStructure,
};
setResult(processedResult);
setProgress(100);
console.log("✨ [CreatingLyrics] Génération réussie");
} catch (e) {
@@ -106,12 +130,44 @@ const CreatingLyrics = ({ active, config, selections }) => {
setSaved(true);
await setIsLoading(true);
console.log("💾 [CreatingLyrics] Sauvegarde automatique des paroles");
const sanitizedStructure = sanitizeStructureList(
result?.structure || config?.structure
);
const baseConfig =
config && typeof config === "object" && !Array.isArray(config)
? { ...config }
: {};
if (sanitizedStructure.length > 0) {
baseConfig.structure = sanitizedStructure;
} else if (Object.prototype.hasOwnProperty.call(baseConfig, "structure")) {
delete baseConfig.structure;
}
const persistedConfig =
Object.keys(baseConfig).length > 0 ? baseConfig : null;
let persistedSelections =
selections && typeof selections === "object" && !Array.isArray(selections)
? { ...selections }
: null;
if (persistedSelections) {
if ("customStructure" in persistedSelections) {
persistedSelections.customStructure = sanitizeStructureList(
persistedSelections.customStructure
);
}
if ("parsedStructure" in persistedSelections) {
persistedSelections.parsedStructure = sanitizeStructureList(
persistedSelections.parsedStructure
);
}
}
await updateProjectData({
title: result?.title || "",
lyrics: Array.isArray(result?.lyrics) ? result.lyrics : [],
description: result?.lyricsDescription,
config: config || null,
selections: selections || null,
config: persistedConfig,
selections: persistedSelections,
hasLyrics: false,
});
navigate(Routes.Lyrics);
+180 -78
View File
@@ -1,103 +1,165 @@
import { BlurView } from "expo-blur";
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
FlatList,
Image,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import Animated, { useAnimatedRef } from "react-native-reanimated";
import Sortable from "react-native-sortables";
import { icons } from "../../assets";
import { Palette, Style } from "../../styles";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
formatStructureLabel,
getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
sanitizeStructureList,
} from "../../utils/songStructure";
import CreateLyricsHeader from "./components/CreateLyricsHeader";
// baseStructure: array like ['couplet','refrain',...]
// onChange: callback that receives array like ['couplet','refrain',...]
const randomSuffix = () => Math.random().toString(36).slice(2, 8);
const createItemId = (type) => `${type}-${Date.now()}-${randomSuffix()}`;
const createSortableItem = (type, index = 0) => ({
id: `${type}-${index}-${randomSuffix()}`,
type,
value: type,
});
const buildInitialItems = (structure = []) =>
sanitizeStructureList(structure).map((type, index) =>
createSortableItem(type, index)
);
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const scrollRef = useAnimatedRef();
const sanitizedBase = useMemo(
() => sanitizeStructureList(baseStructure),
[baseStructure]
);
const [sortableItems, setSortableItems] = useState(() =>
buildInitialItems(sanitizedBase)
);
// Build stable keyed data like { 'couplet-1': { label, value }, ... }
const data = useMemo(() => {
const counters = { couplet: 0, refrain: 0 };
const out = {};
(baseStructure || []).forEach((type) => {
const key = (type || "").toLowerCase();
counters[key] = (counters[key] || 0) + 1;
const idx = counters[key];
const id = `${key}-${idx}`;
out[id] = {
id,
label: `${key === "couplet" ? "Couplet" : "Refrain"} ${idx}`,
value: key,
useEffect(() => {
setSortableItems((prev) => {
const prevTypes = prev.map((item) => item.type);
if (
prevTypes.length === sanitizedBase.length &&
prevTypes.every((type, index) => type === sanitizedBase[index])
) {
return prev;
}
return buildInitialItems(sanitizedBase);
});
}, [sanitizedBase]);
useEffect(() => {
const values = sortableItems.map((item) => item.type).filter(Boolean);
onChange?.(values);
}, [sortableItems, onChange]);
const labeledItems = useMemo(() => {
const counts = {};
return sortableItems.map((item) => {
const baseType = item.type || item.value;
const nextCount = (counts[baseType] || 0) + 1;
counts[baseType] = nextCount;
return {
...item,
type: baseType,
value: baseType,
label: formatStructureLabel(baseType, nextCount),
sortable: true,
};
});
return out;
}, [baseStructure]);
}, [sortableItems]);
const items = useMemo(() => Object.values(data), [data]);
const optionalSegments = useMemo(
() =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(type);
return { type, label: meta?.label || formatStructureLabel(type, 1) };
}),
[]
);
const newItems = [
{
id: "short_intro",
label: "Introduction instrumentale courte",
value: "short_intro",
},
{
id: "long_intro",
label: "Introduction instrumentale longue",
value: "long_intro",
},
{
id: "pre_chorus",
label: "Pré-refrain",
value: "pre_chorus",
},
];
// Initialize parent with current order (so it's saved even without drag)
useEffect(() => {
const initialValues = (items || []).map((it) => it?.value).filter(Boolean);
onChange?.(initialValues);
// We only want to run when baseStructure-derived items change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length]);
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);
});
return set;
}, [sortableItems]);
const Row = ({ item: rowData }) => (
<View style={styles.itemContainer}>
const toggleOptionalSegment = (type) => {
setSortableItems((prev) => {
const existsIndex = prev.findIndex((item) => item.type === type);
if (existsIndex !== -1) {
return prev.filter((item) => item.type !== type);
}
const meta = getSegmentMeta(type);
let next = [...prev];
if (meta?.exclusiveGroup) {
next = next.filter((item) => {
const itemMeta = getSegmentMeta(item.type || item.value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
});
}
next.push(createSortableItem(type, next.length));
return next;
});
};
const Row = ({ item: rowData, onPress }) => {
const isButton = typeof onPress === "function";
const Wrapper = isButton ? TouchableOpacity : View;
const selected = rowData?.selected;
const icon = rowData?.icon;
return (
<Wrapper
onPress={onPress}
activeOpacity={0.8}
style={isButton ? styles.touchable : undefined}
>
<View
style={[
styles.itemContainer,
selected && styles.selectedItem,
rowData?.sortable && styles.sortableItem,
]}
>
<BlurView
intensity={Platform.OS !== "ios" ? 10 : 30}
style={styles.rowBlur}
// experimentalBlurMethod={Platform.OS !== "ios" ? "dimezisBlurView" : "none"}
style={[
styles.rowBlur,
selected && styles.selectedBlur,
isButton && styles.buttonBlur,
]}
>
<View style={{ ...Style.containerSpaceBetween, alignItems: "center" }}>
<View>
<View>
<View>
<View />
</View>
</View>
<View>
<View />
</View>
</View>
<View style={{ flex: 1, justifyContent: "center" }}>
<View>
<View style={styles.rowContent}>
<Text style={styles.rowText}>{rowData?.label || ""}</Text>
</View>
</View>
{/* Render handle only for items that come from the sortable data (have an id like 'couplet-1') */}
{String(rowData?.id || "").includes("-") ? (
{rowData?.sortable ? (
<Sortable.Handle>
<Image source={icons.dragDots} style={styles.handleIcon} />
</Sortable.Handle>
) : null}
</View>
) : (
<Image
source={icon || (selected ? icons.check : icons.add)}
style={[styles.optionIcon]}
/>
)}
</BlurView>
</View>
</Wrapper>
);
};
return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
@@ -115,29 +177,42 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<Sortable.Grid
columns={1}
rowGap={12}
data={items}
data={labeledItems}
renderItem={({ item }) => <Row item={item} />}
customHandle
// Enable auto-scroll when dragging near edges
scrollableRef={scrollRef}
onDragEnd={({ data: newData }) => {
const values = (newData || [])
.map((it) => it?.value)
.filter(Boolean);
onChange?.(values);
const next = (newData || []).map((it, index) => {
const nextType = it?.type || it?.value;
return {
id: it?.id || createItemId(nextType || `section-${index}`),
type: nextType,
value: nextType,
};
});
setSortableItems(next.filter((item) => item.type));
}}
/>
<FlatList
data={newItems}
data={optionalSegments}
gap={12}
renderItem={({ item }) => (
renderItem={({ item }) => {
const selected = selectedTypes.has(item.type);
return (
<View style={{ marginTop: 12 }}>
<Row item={item} />
<Row
item={{
...item,
sortable: false,
selected,
}}
onPress={() => toggleOptionalSegment(item.type)}
/>
</View>
)}
keyExtractor={(item) => item.id}
);
}}
keyExtractor={(item) => item.type}
contentContainerStyle={{ paddingBottom: 24 }}
// Prevent FlatList from capturing scroll gestures so Sortable can handle drags
scrollEnabled={false}
nestedScrollEnabled={false}
/>
@@ -151,6 +226,9 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
export default CustomizeSongStructure;
const styles = StyleSheet.create({
touchable: {
width: "100%",
},
itemContainer: {
height: 54,
backgroundColor: Palette.glass,
@@ -165,13 +243,32 @@ const styles = StyleSheet.create({
shadowRadius: 3.84,
elevation: 5,
},
sortableItem: {
marginBottom: 0,
},
selectedItem: {
borderWidth: 1,
borderColor: Palette.white,
},
rowBlur: {
flex: 1,
backgroundColor: Palette.glass,
justifyContent: "center",
paddingHorizontal: 12,
flexDirection: "row",
alignItems: "center",
height: "100%",
},
buttonBlur: {
justifyContent: "space-between",
},
selectedBlur: {
backgroundColor: Palette.transparentWhite,
},
rowContent: {
flex: 1,
justifyContent: "center",
},
rowText: {
fontSize: 16,
color: Palette.white,
@@ -182,4 +279,9 @@ const styles = StyleSheet.create({
height: 22,
tintColor: Palette.white,
},
optionIcon: {
width: 20,
height: 20,
tintColor: Palette.white,
},
});
+144 -22
View File
@@ -1,30 +1,30 @@
import React from "react";
import { Image, StyleSheet, Text, View } from "react-native";
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { icons } from "../../assets";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
formatStructureLabel,
getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
sanitizeStructureList,
} from "../../utils/songStructure";
import CreateLyricsHeader from "./components/CreateLyricsHeader";
const ROW_HEIGHT = 54;
const VALID_SEGMENTS = ["couplet", "refrain"];
const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
const sanitizeStructure = (structure = []) =>
structure
.map((segment) => `${segment}`.trim().toLowerCase())
.filter((segment) => VALID_SEGMENTS.includes(segment));
const randomSuffix = () => Math.random().toString(36).slice(2, 8);
const buildItems = (structure = []) => {
const counters = { couplet: 0, refrain: 0 };
const counters = {};
return structure.map((segment, index) => {
counters[segment] += 1;
counters[segment] = (counters[segment] || 0) + 1;
return {
id: `${segment}-${index}-${counters[segment]}`,
id: `${segment}-${index}-${randomSuffix()}`,
type: segment,
value: segment,
label: `${segment === "couplet" ? "Couplet" : "Refrain"} ${
counters[segment]
}`,
};
});
};
@@ -41,7 +41,7 @@ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const sanitized = React.useMemo(
() => sanitizeStructure(baseStructure),
() => sanitizeStructureList(baseStructure),
[baseStructure]
);
const [items, setItems] = React.useState(() => buildItems(sanitized));
@@ -55,11 +55,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
React.useEffect(() => {
console.debug("[CustomizeSongStructure.web] sanitized", sanitized);
setItems((prev) => {
if (
prev.length === sanitized.length &&
prev.every((item, idx) => item.value === sanitized[idx])
) {
return prev;
const prevTypes = prev.map((item) => item.type);
if (prevTypes.length === sanitized.length) {
const same = prevTypes.every((type, idx) => type === sanitized[idx]);
if (same) return prev;
}
return buildItems(sanitized);
});
@@ -69,11 +68,59 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
React.useEffect(() => {
console.debug("[CustomizeSongStructure.web] items", items);
onChange?.(items.map((item) => item.value));
onChange?.(items.map((item) => item.type));
}, [items, onChange]);
const labeledItems = React.useMemo(() => {
const counts = {};
return items.map((item) => {
counts[item.type] = (counts[item.type] || 0) + 1;
return {
...item,
label: formatStructureLabel(item.type, counts[item.type]),
};
});
}, [items]);
const selectedTypes = React.useMemo(
() => new Set(items.map((item) => item.type)),
[items]
);
const optionalSegments = React.useMemo(
() =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(type);
return { type, label: meta?.label };
}),
[]
);
const toggleOptionalSegment = React.useCallback((type) => {
setItems((prev) => {
const exists = prev.some((item) => item.type === type);
if (exists) {
return prev.filter((item) => item.type !== type);
}
const meta = getSegmentMeta(type);
let next = [...prev];
if (meta?.exclusiveGroup) {
next = next.filter((item) => {
const itemMeta = getSegmentMeta(item.type);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
});
}
next.push({
id: `${type}-${Date.now()}-${randomSuffix()}`,
type,
value: type,
});
return next;
});
}, []);
const handleGrant = (index, event) => {
const item = items[index];
const item = labeledItems[index];
if (!item) return;
const rect = containerRef.current?.getBoundingClientRect();
const scrollY = getScrollY();
@@ -132,13 +179,32 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
}
};
if (!items.length) {
if (!labeledItems.length) {
return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader
title="Personnalise la structure de ta chanson"
subTitle="Sélectionne une structure pour commencer"
/>
<View style={styles.optionsContainer}>
{optionalSegments.map((segment) => {
const selected = selectedTypes.has(segment.type);
return (
<TouchableOpacity
key={segment.type}
activeOpacity={0.85}
onPress={() => toggleOptionalSegment(segment.type)}
style={[styles.optionItem, selected && styles.selectedItem]}
>
<Text style={styles.optionText}>{segment.label}</Text>
<Image
source={selected ? icons.check : icons.add}
style={[styles.optionIcon]}
/>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
@@ -150,7 +216,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
subTitle="Glisse-dépose pour réorganiser"
/>
<View style={styles.listContainer} ref={containerRef}>
{items.map((item, index) => (
{labeledItems.map((item, index) => (
<View
key={item.id}
style={[
@@ -170,6 +236,28 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
</View>
))}
</View>
<View style={styles.optionsContainer}>
{optionalSegments.map((segment) => {
const selected = selectedTypes.has(segment.type);
return (
<TouchableOpacity
key={segment.type}
activeOpacity={0.85}
onPress={() => toggleOptionalSegment(segment.type)}
style={[styles.optionItem, selected && styles.selectedItem]}
>
<Text style={styles.optionText}>{segment.label}</Text>
<Image
source={selected ? icons.check : icons.add}
style={[
styles.optionIcon,
selected && styles.optionIconSelected,
]}
/>
</TouchableOpacity>
);
})}
</View>
</View>
);
};
@@ -214,4 +302,38 @@ const styles = StyleSheet.create({
tintColor: Palette.white,
cursor: "grab",
},
optionsContainer: {
marginTop: 16,
gap: 12,
},
optionItem: {
height: ROW_HEIGHT,
borderRadius: 14,
backgroundColor: Palette.glass,
shadowColor: "#00000040",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
overflow: "hidden",
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
optionText: {
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
optionIcon: {
width: 20,
height: 20,
tintColor: Palette.white,
},
selectedItem: {
borderWidth: 1,
borderColor: Palette.white,
},
});
+35 -25
View File
@@ -15,6 +15,12 @@ import { navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
formatStructureLabel,
getSegmentMeta,
normalizeStructureType,
sanitizeStructureList,
} from "../../utils/songStructure";
import CustomInput from "./components/CustomInput";
const Lyrics = ({ navigation }) => {
@@ -36,26 +42,23 @@ const Lyrics = ({ navigation }) => {
const initial = useMemo(() => {
const title = projectTitle || "";
const aiSections = Array.isArray(projectLyrics) ? projectLyrics : [];
// Respecter l'ordre de la structure choisie si disponible
const normalizedSections = Array.isArray(projectLyrics)
? projectLyrics.map((s) => ({
type: normalizeStructureType(s?.type),
lyrics: s?.lyrics || "",
}))
: [];
const targetStructure = Array.isArray(projectConfig?.structure)
? projectConfig.structure.map((t) => (t || "").toLowerCase())
? sanitizeStructureList(projectConfig.structure)
: null;
// 1) Si des paroles existent déjà, les utiliser en priorité
if (aiSections.length) {
// Optionnel: si une structure cible de même longueur existe, garder l'ordre courant
// et harmoniser les types en minuscule.
if (normalizedSections.length) {
return {
title,
sections: aiSections.map((s) => ({
type: (s?.type || "").toLowerCase(),
lyrics: s?.lyrics || "",
})),
sections: normalizedSections,
};
}
// 2) Sinon, créer à partir de la structure si fournie
if (targetStructure && targetStructure.length) {
return {
title,
@@ -63,7 +66,6 @@ const Lyrics = ({ navigation }) => {
};
}
// 3) Fallback vide
return { title, sections: [] };
}, [projectTitle, projectLyrics, projectConfig]);
@@ -133,17 +135,17 @@ const Lyrics = ({ navigation }) => {
if (invalid) {
alertMessage(
"Champs incomplets",
"Chaque couplet et refrain doit contenir du texte."
"Chaque section doit contenir du texte."
);
return;
}
const normalizedNewLyrics = (sections || []).map((s) => ({
type: (s?.type || "").toLowerCase(),
type: normalizeStructureType(s?.type),
lyrics: (s?.lyrics || "").trim(),
}));
const normalizedOldLyrics = Array.isArray(projectLyrics)
? projectLyrics.map((s) => ({
type: (s?.type || "").toLowerCase(),
type: normalizeStructureType(s?.type),
lyrics: (s?.lyrics || "").trim(),
}))
: [];
@@ -264,6 +266,8 @@ const Lyrics = ({ navigation }) => {
projectLyrics,
]);
const typeOccurrences = {};
return (
<Page
backgroundImg={isWeb ? background.libraryBgWeb : background.writingBG}
@@ -332,19 +336,25 @@ const Lyrics = ({ navigation }) => {
}}
/>
{sections.map((s, idx) => {
// Calculer l'index humain par type
const type = (s?.type || "").toLowerCase();
const countBefore = sections
.slice(0, idx)
.filter((x) => (x?.type || "").toLowerCase() === type).length;
const labelBase = type === "refrain" ? "Refrain" : "Couplet";
const label = `${labelBase} ${countBefore + 1}`;
const normalizedType = normalizeStructureType(s?.type);
const key = normalizedType || "section";
typeOccurrences[key] = (typeOccurrences[key] || 0) + 1;
const occurrence = typeOccurrences[key];
const label = formatStructureLabel(key, occurrence);
const meta = getSegmentMeta(key);
const inputHeight =
typeof meta?.inputHeight === "number"
? meta.inputHeight
: key === "refrain"
? 170
: 225;
const placeholder = meta?.label || label;
return (
<CustomInput
key={idx}
label={label}
placeholder={labelBase}
height={type === "refrain" ? 170 : 225}
placeholder={placeholder}
height={inputHeight}
value={s?.lyrics || ""}
setValue={(val) => setSectionAt(idx, val)}
onFocus={() => {
+191
View File
@@ -0,0 +1,191 @@
const START_CASE_REGEX = /[_\-\s]+/g;
const toStartCase = (value) => {
const str = String(value || "")
.replace(START_CASE_REGEX, " ")
.trim();
if (!str) return "";
return str
.split(" ")
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
};
export const STRUCTURE_SEGMENTS = {
couplet: {
label: "Couplet",
promptLabel: "Couplet",
showIndex: true,
allowMultiple: true,
optional: false,
inputHeight: 225,
aliases: ["vers", "verse", "couplet"],
},
refrain: {
label: "Refrain",
promptLabel: "Refrain",
showIndex: true,
allowMultiple: true,
optional: false,
inputHeight: 170,
aliases: ["chorus", "refrain"],
},
short_intro: {
label: "Introduction instrumentale courte",
promptLabel: "Introduction instrumentale courte",
showIndex: false,
allowMultiple: false,
optional: true,
inputHeight: 150,
exclusiveGroup: "intro",
aliases: [
"introduction instrumentale courte",
"intro courte",
"intro instrumentale courte",
"short intro",
],
},
long_intro: {
label: "Introduction instrumentale longue",
promptLabel: "Introduction instrumentale longue",
showIndex: false,
allowMultiple: false,
optional: true,
inputHeight: 150,
exclusiveGroup: "intro",
aliases: [
"introduction instrumentale longue",
"intro longue",
"long intro",
"intro instrumentale longue",
],
},
pre_chorus: {
label: "Pré-refrain",
promptLabel: "Pré-refrain",
showIndex: false,
allowMultiple: false,
optional: true,
inputHeight: 150,
aliases: [
"pré-refrain",
"pre-refrain",
"prechorus",
"pre chorus",
"pré chorus",
],
},
};
const ALIAS_TO_KEY = Object.entries(STRUCTURE_SEGMENTS).reduce(
(acc, [key, meta]) => {
const aliases = Array.isArray(meta.aliases) ? meta.aliases : [];
aliases.forEach((alias) => {
acc[String(alias).toLowerCase()] = key;
});
return acc;
},
{}
);
const sanitizeToken = (value) =>
String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
export const getSegmentMeta = (type) => {
const key = String(type || "").toLowerCase();
return STRUCTURE_SEGMENTS[key] || null;
};
export const OPTIONAL_STRUCTURE_SEGMENTS = Object.keys(
STRUCTURE_SEGMENTS
).filter((key) => STRUCTURE_SEGMENTS[key]?.optional);
export const normalizeStructureType = (value) => {
const raw = String(value || "").trim();
if (!raw) return "";
const lower = raw.toLowerCase();
if (STRUCTURE_SEGMENTS[lower]) return lower;
if (ALIAS_TO_KEY[lower]) return ALIAS_TO_KEY[lower];
const sanitized = sanitizeToken(raw);
if (STRUCTURE_SEGMENTS[sanitized]) return sanitized;
if (ALIAS_TO_KEY[sanitized]) return ALIAS_TO_KEY[sanitized];
return sanitized;
};
export const sanitizeStructureList = (structure = []) => {
if (!Array.isArray(structure)) return [];
const output = [];
structure.forEach((segment) => {
const type = normalizeStructureType(segment);
if (!type) return;
const meta = getSegmentMeta(type);
if (meta) {
if (meta.allowMultiple === false) {
const alreadyIncluded = output.some((item) => item === type);
if (alreadyIncluded) return;
}
if (meta.exclusiveGroup) {
const previousIndex = output.findIndex((item) => {
const existingMeta = getSegmentMeta(item);
return existingMeta?.exclusiveGroup === meta.exclusiveGroup;
});
if (previousIndex !== -1) {
output.splice(previousIndex, 1);
}
}
}
output.push(type);
});
return output;
};
export const formatStructureLabel = (type, occurrence = 1) => {
const normalized = normalizeStructureType(type);
const meta = getSegmentMeta(normalized);
const baseLabel = meta?.label || toStartCase(normalized || type);
const showIndex = meta?.showIndex === true;
if (showIndex) return `${baseLabel} ${occurrence}`;
if (!meta && occurrence > 1) return `${baseLabel} ${occurrence}`;
return baseLabel;
};
export const getStructureInputHeight = (type) => {
const normalized = normalizeStructureType(type);
const meta = getSegmentMeta(normalized);
return typeof meta?.inputHeight === "number" ? meta.inputHeight : undefined;
};
export const getPromptLabelForStructure = (type) => {
const normalized = normalizeStructureType(type);
const meta = getSegmentMeta(normalized);
if (meta?.promptLabel) return meta.promptLabel;
if (meta?.label) return meta.label;
return toStartCase(normalized || type);
};
export const isOptionalStructureType = (type) => {
const normalized = normalizeStructureType(type);
const meta = getSegmentMeta(normalized);
return !!meta?.optional;
};
export const arraysAreSame = (a = [], b = []) => {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
};