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 admin = require("firebase-admin");
const { refList } = require("../index"); 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 = [ const TOXIC_KEYWORDS = [
/\bnazis?\b/i, /\bnazis?\b/i,
/\bnazisme\b/i, /\bnazisme\b/i,
@@ -39,8 +100,9 @@ const buildModerationBrief = ({
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`, `<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
]; ];
if (Array.isArray(structure) && structure.length) { const promptStructure = mapStructureToPrompt(structure);
sections.push(`<STRUCTURE>${structure.join(" | ")}</STRUCTURE>`); if (promptStructure.length) {
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
} }
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`; return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
@@ -76,17 +138,29 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
style = "", style = "",
audience = "", audience = "",
emotion = "", emotion = "",
structure = ["couplet", "refrain", "couplet", "refrain"], structure: rawStructure = [
"couplet",
"refrain",
"couplet",
"refrain",
],
rhymes = "", rhymes = "",
} = data; } = data;
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
const promptStructure = sanitizedStructure.length
? sanitizedStructure.map(
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
)
: [];
const moderationPayload = { const moderationPayload = {
objective, objective,
context, context,
style, style,
audience, audience,
emotion, emotion,
structure, structure: promptStructure,
rhymes, 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. Retourne uniquement un JSON conforme au schéma fourni, sans ajouter d'autres textes.
`.trim(); `.trim();
const structureTags = (Array.isArray(structure) ? structure : []) const structureTags = (Array.isArray(promptStructure) ? promptStructure : [])
.map((part, index) => { .map((part, index) => {
const content = part || ""; const content = part || "";
return ` <SECTION ordre="${index + 1}">${content}</SECTION>`; return ` <SECTION ordre="${index + 1}">${content}</SECTION>`;
@@ -191,8 +265,7 @@ ${structureTags || fallbackStructureTags}
type: z type: z
.string() .string()
.describe( .describe(
'Type de section : "couplet" ou "refrain" ' + "Type de section : respecter exactement le nom de la section demandé dans la structure",
"selon la structure",
), ),
lyrics: z lyrics: z
.string() .string()
+32 -6
View File
@@ -37,6 +37,11 @@ import Page from "../../layouts/Page";
import { Palette } from "../../styles"; import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import Style, { gutters, size } from "../../styles/Style"; import Style, { gutters, size } from "../../styles/Style";
import {
formatStructureLabel,
getSegmentMeta,
normalizeStructureType,
} from "../../utils/songStructure";
// 20 secondes // 20 secondes
const timeBeforeIncrement = 20000; const timeBeforeIncrement = 20000;
@@ -325,12 +330,14 @@ const MusicDetails = ({ route }) => {
const description = useMemo(() => { const description = useMemo(() => {
// Build a readable text from lyrics with section labels // Build a readable text from lyrics with section labels
if (Array.isArray(project?.lyrics)) { if (Array.isArray(project?.lyrics)) {
const counts = {};
return project.lyrics return project.lyrics
.map((s) => { .map((s) => {
const body = (s?.lyrics || "").trim(); const body = (s?.lyrics || "").trim();
if (!body) return null; if (!body) return null;
const t = (s?.type || "").toLowerCase(); const type = normalizeStructureType(s?.type);
const label = t === "refrain" ? "Refrain" : "Couplet"; counts[type] = (counts[type] || 0) + 1;
const label = formatStructureLabel(type, counts[type]);
return `[${label}]\n${body}`; return `[${label}]\n${body}`;
}) })
.filter(Boolean) .filter(Boolean)
@@ -370,22 +377,41 @@ const MusicDetails = ({ route }) => {
if (!match) return null; if (!match) return null;
const label = match[1]?.trim() || ""; const label = match[1]?.trim() || "";
const lower = label.toLowerCase(); const lower = label.toLowerCase();
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")) else if (lower.includes("couplet") || lower.includes("verse"))
type = "couplet"; 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("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 indexMatch = label.match(/(\d+)/);
const index = indexMatch ? Number(indexMatch[1]) : undefined; const index = indexMatch ? Number(indexMatch[1]) : undefined;
return { label, type, index }; return { label, type, index };
}; };
const formatSectionLabel = (type, index, fallback) => { const formatSectionLabel = (type, index, fallback) => {
if (fallback) return fallback; if (fallback) return fallback;
if (type === "refrain") return index > 1 ? `Refrain ${index}` : "Refrain"; const normalized = normalizeStructureType(type);
if (type === "couplet") return index > 1 ? `Couplet ${index}` : "Couplet"; 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 === "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"; 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 { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters } from "../../styles";
import { normalizeStructureType } from "../../utils/songStructure";
import ChooseGenre from "./ChooseGenre"; import ChooseGenre from "./ChooseGenre";
import ChooseInstruments from "./ChooseInstruments"; import ChooseInstruments from "./ChooseInstruments";
import ChooseRhythm from "./ChooseRhythm"; import ChooseRhythm from "./ChooseRhythm";
@@ -51,7 +52,7 @@ const ComposeSong = () => {
let lyricsArr = []; let lyricsArr = [];
if (Array.isArray(selectedProject?.lyrics)) { if (Array.isArray(selectedProject?.lyrics)) {
lyricsArr = selectedProject.lyrics.map((s) => ({ lyricsArr = selectedProject.lyrics.map((s) => ({
type: (s?.type || "").toLowerCase(), type: normalizeStructureType(s?.type),
lyrics: s?.lyrics || "", lyrics: s?.lyrics || "",
})); }));
} else { } else {
+2 -1
View File
@@ -15,6 +15,7 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters } from "../../styles";
import { normalizeStructureType } from "../../utils/songStructure";
import ChooseGenre from "./ChooseGenre"; import ChooseGenre from "./ChooseGenre";
import ChooseInstruments from "./ChooseInstruments"; import ChooseInstruments from "./ChooseInstruments";
import ChooseRhythm from "./ChooseRhythm"; import ChooseRhythm from "./ChooseRhythm";
@@ -54,7 +55,7 @@ const ComposeSong = () => {
let lyricsArr = []; let lyricsArr = [];
if (Array.isArray(selectedProject?.lyrics)) { if (Array.isArray(selectedProject?.lyrics)) {
lyricsArr = selectedProject.lyrics.map((s) => ({ lyricsArr = selectedProject.lyrics.map((s) => ({
type: (s?.type || "").toLowerCase(), type: normalizeStructureType(s?.type),
lyrics: s?.lyrics || "", lyrics: s?.lyrics || "",
})); }));
} else { } else {
+36 -11
View File
@@ -12,6 +12,7 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters } from "../../styles";
import { sanitizeStructureList } from "../../utils/songStructure";
import CreatingLyrics from "./CreatingLyrics"; import CreatingLyrics from "./CreatingLyrics";
import CustomizeSongStructure from "./CustomizeSongStructure"; import CustomizeSongStructure from "./CustomizeSongStructure";
import EmotionConvey from "./EmotionConvey"; import EmotionConvey from "./EmotionConvey";
@@ -140,11 +141,24 @@ const CreateLyricsWithAi = () => {
// Structure personnalisée: prioriser selections.customStructure puis config.structure // Structure personnalisée: prioriser selections.customStructure puis config.structure
const savedCustom = Array.isArray(sel.customStructure) const savedCustom = Array.isArray(sel.customStructure)
? sel.customStructure ? sanitizeStructureList(sel.customStructure)
: null; : null;
const cfgStructure = Array.isArray(cfg.structure) ? cfg.structure : null; const cfgStructure = Array.isArray(cfg.structure)
if (!Array.isArray(customStructure) && (savedCustom || cfgStructure)) { ? sanitizeStructureList(cfg.structure)
setCustomStructure(savedCustom || cfgStructure); : 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]); }, [selectedProject]);
@@ -167,7 +181,8 @@ const CreateLyricsWithAi = () => {
for (let i = 0; i < count; i++) result.push("refrain"); 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) { } catch (e) {
return null; return null;
} }
@@ -187,6 +202,13 @@ const CreateLyricsWithAi = () => {
const resolvedStyle = shouldUseOtherStyle const resolvedStyle = shouldUseOtherStyle
? persistedOtherStyle || undefined ? persistedOtherStyle || undefined
: style || undefined; : style || undefined;
const sanitizedCustom =
Array.isArray(customStructure) && customStructure.length > 0
? sanitizeStructureList(customStructure)
: [];
const sanitizedParsed = Array.isArray(parsedStructure)
? sanitizeStructureList(parsedStructure)
: [];
return { return {
objective: resolvedObjective, objective: resolvedObjective,
@@ -198,9 +220,11 @@ const CreateLyricsWithAi = () => {
style: resolvedStyle, style: resolvedStyle,
audience: audience?.trim() ? audience.trim() : undefined, audience: audience?.trim() ? audience.trim() : undefined,
structure: structure:
(Array.isArray(customStructure) && customStructure.length > 0 sanitizedCustom.length > 0
? customStructure ? sanitizedCustom
: parsedStructure) || undefined, : sanitizedParsed.length > 0
? sanitizedParsed
: undefined,
rhymes: rhymes || 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), // Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics // sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
if (hasLyrics && selectedIndex === 6) { if (hasLyrics && selectedIndex === 6) {
const chosenStructure = const chosenStructure = sanitizeStructureList(
Array.isArray(customStructure) && customStructure.length > 0 Array.isArray(customStructure) && customStructure.length > 0
? customStructure ? customStructure
: Array.isArray(parsedStructure) : Array.isArray(parsedStructure)
? parsedStructure ? parsedStructure
: []; : []
);
await updateProjectData({ await updateProjectData({
config: { structure: chosenStructure }, config: { structure: chosenStructure },
selections: { selections: {
@@ -294,7 +319,7 @@ const CreateLyricsWithAi = () => {
audience, audience,
structure, structure,
parsedStructure, parsedStructure,
customStructure, customStructure: sanitizeStructureList(customStructure),
rhymes, rhymes,
}, },
hasLyrics: true, hasLyrics: true,
+36 -11
View File
@@ -10,6 +10,7 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters } from "../../styles";
import { sanitizeStructureList } from "../../utils/songStructure";
import CreatingLyrics from "./CreatingLyrics"; import CreatingLyrics from "./CreatingLyrics";
import CustomizeSongStructure from "./CustomizeSongStructure"; import CustomizeSongStructure from "./CustomizeSongStructure";
import EmotionConvey from "./EmotionConvey"; import EmotionConvey from "./EmotionConvey";
@@ -138,11 +139,24 @@ const CreateLyricsWithAi = () => {
// Structure personnalisée: prioriser selections.customStructure puis config.structure // Structure personnalisée: prioriser selections.customStructure puis config.structure
const savedCustom = Array.isArray(sel.customStructure) const savedCustom = Array.isArray(sel.customStructure)
? sel.customStructure ? sanitizeStructureList(sel.customStructure)
: null; : null;
const cfgStructure = Array.isArray(cfg.structure) ? cfg.structure : null; const cfgStructure = Array.isArray(cfg.structure)
if (!Array.isArray(customStructure) && (savedCustom || cfgStructure)) { ? sanitizeStructureList(cfg.structure)
setCustomStructure(savedCustom || cfgStructure); : 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]); }, [selectedProject]);
@@ -172,7 +186,8 @@ const CreateLyricsWithAi = () => {
for (let i = 0; i < count; i++) result.push("refrain"); 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) { } catch (e) {
return null; return null;
} }
@@ -192,6 +207,13 @@ const CreateLyricsWithAi = () => {
const resolvedStyle = shouldUseOtherStyle const resolvedStyle = shouldUseOtherStyle
? persistedOtherStyle || undefined ? persistedOtherStyle || undefined
: style || undefined; : style || undefined;
const sanitizedCustom =
Array.isArray(customStructure) && customStructure.length > 0
? sanitizeStructureList(customStructure)
: [];
const sanitizedParsed = Array.isArray(parsedStructure)
? sanitizeStructureList(parsedStructure)
: [];
return { return {
objective: resolvedObjective, objective: resolvedObjective,
@@ -203,9 +225,11 @@ const CreateLyricsWithAi = () => {
style: resolvedStyle, style: resolvedStyle,
audience: audience?.trim() ? audience.trim() : undefined, audience: audience?.trim() ? audience.trim() : undefined,
structure: structure:
(Array.isArray(customStructure) && customStructure.length > 0 sanitizedCustom.length > 0
? customStructure ? sanitizedCustom
: parsedStructure) || undefined, : sanitizedParsed.length > 0
? sanitizedParsed
: undefined,
rhymes: rhymes || 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), // Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics // sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
if (hasLyrics && selectedIndex === 6) { if (hasLyrics && selectedIndex === 6) {
const chosenStructure = const chosenStructure = sanitizeStructureList(
Array.isArray(customStructure) && customStructure.length > 0 Array.isArray(customStructure) && customStructure.length > 0
? customStructure ? customStructure
: Array.isArray(parsedStructure) : Array.isArray(parsedStructure)
? parsedStructure ? parsedStructure
: []; : []
);
await updateProjectData({ await updateProjectData({
config: { structure: chosenStructure }, config: { structure: chosenStructure },
selections: { selections: {
@@ -299,7 +324,7 @@ const CreateLyricsWithAi = () => {
audience, audience,
structure, structure,
parsedStructure, parsedStructure,
customStructure, customStructure: sanitizeStructureList(customStructure),
rhymes, rhymes,
}, },
hasLyrics: true, hasLyrics: true,
+60 -4
View File
@@ -12,6 +12,7 @@ import { navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { Palette, Style } from "../../styles"; import { Palette, Style } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import { sanitizeStructureList } from "../../utils/songStructure";
const CreatingLyrics = ({ active, config, selections }) => { const CreatingLyrics = ({ active, config, selections }) => {
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@@ -58,6 +59,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
console.log("🚀 [CreatingLyrics] Lancement de la génération", { console.log("🚀 [CreatingLyrics] Lancement de la génération", {
platform: Platform.OS, platform: Platform.OS,
}); });
const sanitizedStructure = sanitizeStructureList(config?.structure);
const callable = firebase const callable = firebase
.functions() .functions()
.httpsCallable("lyrics-generateLyrics"); .httpsCallable("lyrics-generateLyrics");
@@ -67,10 +69,32 @@ const CreatingLyrics = ({ active, config, selections }) => {
emotion: config?.emotion, emotion: config?.emotion,
style: config?.style, style: config?.style,
audience: config?.audience, audience: config?.audience,
structure: config?.structure, structure: sanitizedStructure,
rhymes: config?.rhymes, 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); setProgress(100);
console.log("✨ [CreatingLyrics] Génération réussie"); console.log("✨ [CreatingLyrics] Génération réussie");
} catch (e) { } catch (e) {
@@ -106,12 +130,44 @@ const CreatingLyrics = ({ active, config, selections }) => {
setSaved(true); setSaved(true);
await setIsLoading(true); await setIsLoading(true);
console.log("💾 [CreatingLyrics] Sauvegarde automatique des paroles"); 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({ await updateProjectData({
title: result?.title || "", title: result?.title || "",
lyrics: Array.isArray(result?.lyrics) ? result.lyrics : [], lyrics: Array.isArray(result?.lyrics) ? result.lyrics : [],
description: result?.lyricsDescription, description: result?.lyricsDescription,
config: config || null, config: persistedConfig,
selections: selections || null, selections: persistedSelections,
hasLyrics: false, hasLyrics: false,
}); });
navigate(Routes.Lyrics); navigate(Routes.Lyrics);
+189 -87
View File
@@ -1,103 +1,165 @@
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
FlatList, FlatList,
Image, Image,
Platform, Platform,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import Animated, { useAnimatedRef } from "react-native-reanimated"; import Animated, { useAnimatedRef } from "react-native-reanimated";
import Sortable from "react-native-sortables"; import Sortable from "react-native-sortables";
import { icons } from "../../assets"; import { icons } from "../../assets";
import { Palette, Style } from "../../styles"; import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import {
formatStructureLabel,
getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
sanitizeStructureList,
} from "../../utils/songStructure";
import CreateLyricsHeader from "./components/CreateLyricsHeader"; import CreateLyricsHeader from "./components/CreateLyricsHeader";
// baseStructure: array like ['couplet','refrain',...] const randomSuffix = () => Math.random().toString(36).slice(2, 8);
// onChange: callback that receives array like ['couplet','refrain',...] 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 CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const scrollRef = useAnimatedRef(); const scrollRef = useAnimatedRef();
const sanitizedBase = useMemo(
() => sanitizeStructureList(baseStructure),
[baseStructure]
);
const [sortableItems, setSortableItems] = useState(() =>
buildInitialItems(sanitizedBase)
);
// Build stable keyed data like { 'couplet-1': { label, value }, ... } useEffect(() => {
const data = useMemo(() => { setSortableItems((prev) => {
const counters = { couplet: 0, refrain: 0 }; const prevTypes = prev.map((item) => item.type);
const out = {}; if (
(baseStructure || []).forEach((type) => { prevTypes.length === sanitizedBase.length &&
const key = (type || "").toLowerCase(); prevTypes.every((type, index) => type === sanitizedBase[index])
counters[key] = (counters[key] || 0) + 1; ) {
const idx = counters[key]; return prev;
const id = `${key}-${idx}`; }
out[id] = { return buildInitialItems(sanitizedBase);
id, });
label: `${key === "couplet" ? "Couplet" : "Refrain"} ${idx}`, }, [sanitizedBase]);
value: key,
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; }, [sortableItems]);
}, [baseStructure]);
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 = [ const selectedTypes = useMemo(() => {
{ const set = new Set();
id: "short_intro", sortableItems.forEach((item) => {
label: "Introduction instrumentale courte", if (item?.type) set.add(item.type);
value: "short_intro", else if (item?.value) set.add(item.value);
}, });
{ return set;
id: "long_intro", }, [sortableItems]);
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 Row = ({ item: rowData }) => ( const toggleOptionalSegment = (type) => {
<View style={styles.itemContainer}> setSortableItems((prev) => {
<BlurView const existsIndex = prev.findIndex((item) => item.type === type);
intensity={Platform.OS !== "ios" ? 10 : 30} if (existsIndex !== -1) {
style={styles.rowBlur} return prev.filter((item) => item.type !== type);
// experimentalBlurMethod={Platform.OS !== "ios" ? "dimezisBlurView" : "none"} }
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={{ ...Style.containerSpaceBetween, alignItems: "center" }}> <View
<View> style={[
<View> styles.itemContainer,
<View> selected && styles.selectedItem,
<View /> rowData?.sortable && styles.sortableItem,
</View> ]}
</View> >
<View> <BlurView
<View /> intensity={Platform.OS !== "ios" ? 10 : 30}
</View> style={[
</View> styles.rowBlur,
<View style={{ flex: 1, justifyContent: "center" }}> selected && styles.selectedBlur,
<View> isButton && styles.buttonBlur,
]}
>
<View style={styles.rowContent}>
<Text style={styles.rowText}>{rowData?.label || ""}</Text> <Text style={styles.rowText}>{rowData?.label || ""}</Text>
</View> </View>
</View> {rowData?.sortable ? (
{/* Render handle only for items that come from the sortable data (have an id like 'couplet-1') */} <Sortable.Handle>
{String(rowData?.id || "").includes("-") ? ( <Image source={icons.dragDots} style={styles.handleIcon} />
<Sortable.Handle> </Sortable.Handle>
<Image source={icons.dragDots} style={styles.handleIcon} /> ) : (
</Sortable.Handle> <Image
) : null} source={icon || (selected ? icons.check : icons.add)}
style={[styles.optionIcon]}
/>
)}
</BlurView>
</View> </View>
</BlurView> </Wrapper>
</View> );
); };
return ( return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}> <View style={{ flex: 1, gap: 10, marginTop: 16 }}>
@@ -115,29 +177,42 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<Sortable.Grid <Sortable.Grid
columns={1} columns={1}
rowGap={12} rowGap={12}
data={items} data={labeledItems}
renderItem={({ item }) => <Row item={item} />} renderItem={({ item }) => <Row item={item} />}
customHandle customHandle
// Enable auto-scroll when dragging near edges
scrollableRef={scrollRef} scrollableRef={scrollRef}
onDragEnd={({ data: newData }) => { onDragEnd={({ data: newData }) => {
const values = (newData || []) const next = (newData || []).map((it, index) => {
.map((it) => it?.value) const nextType = it?.type || it?.value;
.filter(Boolean); return {
onChange?.(values); id: it?.id || createItemId(nextType || `section-${index}`),
type: nextType,
value: nextType,
};
});
setSortableItems(next.filter((item) => item.type));
}} }}
/> />
<FlatList <FlatList
data={newItems} data={optionalSegments}
gap={12} gap={12}
renderItem={({ item }) => ( renderItem={({ item }) => {
<View style={{ marginTop: 12 }}> const selected = selectedTypes.has(item.type);
<Row item={item} /> return (
</View> <View style={{ marginTop: 12 }}>
)} <Row
keyExtractor={(item) => item.id} item={{
...item,
sortable: false,
selected,
}}
onPress={() => toggleOptionalSegment(item.type)}
/>
</View>
);
}}
keyExtractor={(item) => item.type}
contentContainerStyle={{ paddingBottom: 24 }} contentContainerStyle={{ paddingBottom: 24 }}
// Prevent FlatList from capturing scroll gestures so Sortable can handle drags
scrollEnabled={false} scrollEnabled={false}
nestedScrollEnabled={false} nestedScrollEnabled={false}
/> />
@@ -151,6 +226,9 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
export default CustomizeSongStructure; export default CustomizeSongStructure;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
touchable: {
width: "100%",
},
itemContainer: { itemContainer: {
height: 54, height: 54,
backgroundColor: Palette.glass, backgroundColor: Palette.glass,
@@ -165,13 +243,32 @@ const styles = StyleSheet.create({
shadowRadius: 3.84, shadowRadius: 3.84,
elevation: 5, elevation: 5,
}, },
sortableItem: {
marginBottom: 0,
},
selectedItem: {
borderWidth: 1,
borderColor: Palette.white,
},
rowBlur: { rowBlur: {
flex: 1, flex: 1,
backgroundColor: Palette.glass, backgroundColor: Palette.glass,
justifyContent: "center", justifyContent: "center",
paddingHorizontal: 12, paddingHorizontal: 12,
flexDirection: "row",
alignItems: "center",
height: "100%", height: "100%",
}, },
buttonBlur: {
justifyContent: "space-between",
},
selectedBlur: {
backgroundColor: Palette.transparentWhite,
},
rowContent: {
flex: 1,
justifyContent: "center",
},
rowText: { rowText: {
fontSize: 16, fontSize: 16,
color: Palette.white, color: Palette.white,
@@ -182,4 +279,9 @@ const styles = StyleSheet.create({
height: 22, height: 22,
tintColor: Palette.white, tintColor: Palette.white,
}, },
optionIcon: {
width: 20,
height: 20,
tintColor: Palette.white,
},
}); });
+144 -22
View File
@@ -1,30 +1,30 @@
import React from "react"; 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 { icons } from "../../assets";
import { Palette } from "../../styles"; import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import {
formatStructureLabel,
getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
sanitizeStructureList,
} from "../../utils/songStructure";
import CreateLyricsHeader from "./components/CreateLyricsHeader"; import CreateLyricsHeader from "./components/CreateLyricsHeader";
const ROW_HEIGHT = 54; const ROW_HEIGHT = 54;
const VALID_SEGMENTS = ["couplet", "refrain"];
const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0); const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
const sanitizeStructure = (structure = []) => const randomSuffix = () => Math.random().toString(36).slice(2, 8);
structure
.map((segment) => `${segment}`.trim().toLowerCase())
.filter((segment) => VALID_SEGMENTS.includes(segment));
const buildItems = (structure = []) => { const buildItems = (structure = []) => {
const counters = { couplet: 0, refrain: 0 }; const counters = {};
return structure.map((segment, index) => { return structure.map((segment, index) => {
counters[segment] += 1; counters[segment] = (counters[segment] || 0) + 1;
return { return {
id: `${segment}-${index}-${counters[segment]}`, id: `${segment}-${index}-${randomSuffix()}`,
type: segment,
value: 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 CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const sanitized = React.useMemo( const sanitized = React.useMemo(
() => sanitizeStructure(baseStructure), () => sanitizeStructureList(baseStructure),
[baseStructure] [baseStructure]
); );
const [items, setItems] = React.useState(() => buildItems(sanitized)); const [items, setItems] = React.useState(() => buildItems(sanitized));
@@ -55,11 +55,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
React.useEffect(() => { React.useEffect(() => {
console.debug("[CustomizeSongStructure.web] sanitized", sanitized); console.debug("[CustomizeSongStructure.web] sanitized", sanitized);
setItems((prev) => { setItems((prev) => {
if ( const prevTypes = prev.map((item) => item.type);
prev.length === sanitized.length && if (prevTypes.length === sanitized.length) {
prev.every((item, idx) => item.value === sanitized[idx]) const same = prevTypes.every((type, idx) => type === sanitized[idx]);
) { if (same) return prev;
return prev;
} }
return buildItems(sanitized); return buildItems(sanitized);
}); });
@@ -69,11 +68,59 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
React.useEffect(() => { React.useEffect(() => {
console.debug("[CustomizeSongStructure.web] items", items); console.debug("[CustomizeSongStructure.web] items", items);
onChange?.(items.map((item) => item.value)); onChange?.(items.map((item) => item.type));
}, [items, onChange]); }, [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 handleGrant = (index, event) => {
const item = items[index]; const item = labeledItems[index];
if (!item) return; if (!item) return;
const rect = containerRef.current?.getBoundingClientRect(); const rect = containerRef.current?.getBoundingClientRect();
const scrollY = getScrollY(); const scrollY = getScrollY();
@@ -132,13 +179,32 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
} }
}; };
if (!items.length) { if (!labeledItems.length) {
return ( return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}> <View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader <CreateLyricsHeader
title="Personnalise la structure de ta chanson" title="Personnalise la structure de ta chanson"
subTitle="Sélectionne une structure pour commencer" 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> </View>
); );
} }
@@ -150,7 +216,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
subTitle="Glisse-dépose pour réorganiser" subTitle="Glisse-dépose pour réorganiser"
/> />
<View style={styles.listContainer} ref={containerRef}> <View style={styles.listContainer} ref={containerRef}>
{items.map((item, index) => ( {labeledItems.map((item, index) => (
<View <View
key={item.id} key={item.id}
style={[ style={[
@@ -170,6 +236,28 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
</View> </View>
))} ))}
</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> </View>
); );
}; };
@@ -214,4 +302,38 @@ const styles = StyleSheet.create({
tintColor: Palette.white, tintColor: Palette.white,
cursor: "grab", 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 { useUser } from "../../providers/UserDataProvider";
import { Palette, gutters } from "../../styles"; import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import {
formatStructureLabel,
getSegmentMeta,
normalizeStructureType,
sanitizeStructureList,
} from "../../utils/songStructure";
import CustomInput from "./components/CustomInput"; import CustomInput from "./components/CustomInput";
const Lyrics = ({ navigation }) => { const Lyrics = ({ navigation }) => {
@@ -36,26 +42,23 @@ const Lyrics = ({ navigation }) => {
const initial = useMemo(() => { const initial = useMemo(() => {
const title = projectTitle || ""; const title = projectTitle || "";
const aiSections = Array.isArray(projectLyrics) ? projectLyrics : []; const normalizedSections = Array.isArray(projectLyrics)
// Respecter l'ordre de la structure choisie si disponible ? projectLyrics.map((s) => ({
type: normalizeStructureType(s?.type),
lyrics: s?.lyrics || "",
}))
: [];
const targetStructure = Array.isArray(projectConfig?.structure) const targetStructure = Array.isArray(projectConfig?.structure)
? projectConfig.structure.map((t) => (t || "").toLowerCase()) ? sanitizeStructureList(projectConfig.structure)
: null; : null;
// 1) Si des paroles existent déjà, les utiliser en priorité if (normalizedSections.length) {
if (aiSections.length) {
// Optionnel: si une structure cible de même longueur existe, garder l'ordre courant
// et harmoniser les types en minuscule.
return { return {
title, title,
sections: aiSections.map((s) => ({ sections: normalizedSections,
type: (s?.type || "").toLowerCase(),
lyrics: s?.lyrics || "",
})),
}; };
} }
// 2) Sinon, créer à partir de la structure si fournie
if (targetStructure && targetStructure.length) { if (targetStructure && targetStructure.length) {
return { return {
title, title,
@@ -63,7 +66,6 @@ const Lyrics = ({ navigation }) => {
}; };
} }
// 3) Fallback vide
return { title, sections: [] }; return { title, sections: [] };
}, [projectTitle, projectLyrics, projectConfig]); }, [projectTitle, projectLyrics, projectConfig]);
@@ -133,17 +135,17 @@ const Lyrics = ({ navigation }) => {
if (invalid) { if (invalid) {
alertMessage( alertMessage(
"Champs incomplets", "Champs incomplets",
"Chaque couplet et refrain doit contenir du texte." "Chaque section doit contenir du texte."
); );
return; return;
} }
const normalizedNewLyrics = (sections || []).map((s) => ({ const normalizedNewLyrics = (sections || []).map((s) => ({
type: (s?.type || "").toLowerCase(), type: normalizeStructureType(s?.type),
lyrics: (s?.lyrics || "").trim(), lyrics: (s?.lyrics || "").trim(),
})); }));
const normalizedOldLyrics = Array.isArray(projectLyrics) const normalizedOldLyrics = Array.isArray(projectLyrics)
? projectLyrics.map((s) => ({ ? projectLyrics.map((s) => ({
type: (s?.type || "").toLowerCase(), type: normalizeStructureType(s?.type),
lyrics: (s?.lyrics || "").trim(), lyrics: (s?.lyrics || "").trim(),
})) }))
: []; : [];
@@ -264,6 +266,8 @@ const Lyrics = ({ navigation }) => {
projectLyrics, projectLyrics,
]); ]);
const typeOccurrences = {};
return ( return (
<Page <Page
backgroundImg={isWeb ? background.libraryBgWeb : background.writingBG} backgroundImg={isWeb ? background.libraryBgWeb : background.writingBG}
@@ -332,19 +336,25 @@ const Lyrics = ({ navigation }) => {
}} }}
/> />
{sections.map((s, idx) => { {sections.map((s, idx) => {
// Calculer l'index humain par type const normalizedType = normalizeStructureType(s?.type);
const type = (s?.type || "").toLowerCase(); const key = normalizedType || "section";
const countBefore = sections typeOccurrences[key] = (typeOccurrences[key] || 0) + 1;
.slice(0, idx) const occurrence = typeOccurrences[key];
.filter((x) => (x?.type || "").toLowerCase() === type).length; const label = formatStructureLabel(key, occurrence);
const labelBase = type === "refrain" ? "Refrain" : "Couplet"; const meta = getSegmentMeta(key);
const label = `${labelBase} ${countBefore + 1}`; const inputHeight =
typeof meta?.inputHeight === "number"
? meta.inputHeight
: key === "refrain"
? 170
: 225;
const placeholder = meta?.label || label;
return ( return (
<CustomInput <CustomInput
key={idx} key={idx}
label={label} label={label}
placeholder={labelBase} placeholder={placeholder}
height={type === "refrain" ? 170 : 225} height={inputHeight}
value={s?.lyrics || ""} value={s?.lyrics || ""}
setValue={(val) => setSectionAt(idx, val)} setValue={(val) => setSectionAt(idx, val)}
onFocus={() => { 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;
};