new song structure
This commit is contained in:
+80
-7
@@ -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()
|
||||||
|
|||||||
@@ -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";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user