chantier-1+2: voice cloning XTTS-v2 + génération musicale /compose
## Chantier 1 — Voice Cloning - scripts/xtts_clone.py: Coqui XTTS-v2 voice cloning (6s sample → voix) - ws-chat.ts: synthesizeTTS avec détection sample → XTTS ou Piper - PersonaDetail.tsx: upload/delete sample vocal - app.ts: endpoints voice-sample (POST/GET/DELETE) ## Chantier 2 — Génération Musicale - scripts/compose_music.py: ACE-Step fallback MusicGen - /compose command (5min timeout, broadcast audio base64) - Chat.tsx: player <audio controls> inline - Eno persona (musique générative/ambient) - Pharmacius: routing @Eno Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -841,6 +841,78 @@ export async function createApp(): Promise<{ app: express.Express; personaRepo:
|
|||||||
res.json(asApiData(persona));
|
res.json(asApiData(persona));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Voice sample upload for XTTS-v2 cloning
|
||||||
|
app.post("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
|
||||||
|
const personaId = readRouteParam(req.params.id);
|
||||||
|
const persona = await personaRepo.findById(personaId);
|
||||||
|
if (!persona) {
|
||||||
|
res.status(404).json({ ok: false, error: "persona_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioB64 = req.body?.audio as string | undefined;
|
||||||
|
if (!audioB64 || typeof audioB64 !== "string") {
|
||||||
|
res.status(400).json({ ok: false, error: "audio_required (base64 field 'audio')" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode and validate size (max 10 MB)
|
||||||
|
const buffer = Buffer.from(audioB64, "base64");
|
||||||
|
if (buffer.length > 10 * 1024 * 1024) {
|
||||||
|
res.status(400).json({ ok: false, error: "file_too_large (max 10 MB)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceSamplesDir = path.resolve(process.cwd(), "data", "voice-samples");
|
||||||
|
await mkdir(voiceSamplesDir, { recursive: true });
|
||||||
|
|
||||||
|
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
||||||
|
const samplePath = path.join(voiceSamplesDir, `${sampleName}.wav`);
|
||||||
|
|
||||||
|
await writeFile(samplePath, buffer);
|
||||||
|
|
||||||
|
res.json({ ok: true, data: { personaId, samplePath: `data/voice-samples/${sampleName}.wav`, size: buffer.length } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
|
||||||
|
const personaId = readRouteParam(req.params.id);
|
||||||
|
const persona = await personaRepo.findById(personaId);
|
||||||
|
if (!persona) {
|
||||||
|
res.status(404).json({ ok: false, error: "persona_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
||||||
|
const samplePath = path.resolve(process.cwd(), "data", "voice-samples", `${sampleName}.wav`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { unlink } = await import("node:fs/promises");
|
||||||
|
await unlink(samplePath);
|
||||||
|
res.json({ ok: true, data: { deleted: true } });
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ ok: false, error: "sample_not_found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/personas/:id/voice-sample", requirePermission("persona:read"), async (req, res) => {
|
||||||
|
const personaId = readRouteParam(req.params.id);
|
||||||
|
const persona = await personaRepo.findById(personaId);
|
||||||
|
if (!persona) {
|
||||||
|
res.status(404).json({ ok: false, error: "persona_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
||||||
|
const samplePath = path.resolve(process.cwd(), "data", "voice-samples", `${sampleName}.wav`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(samplePath);
|
||||||
|
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: `data/voice-samples/${sampleName}.wav` } });
|
||||||
|
} catch {
|
||||||
|
res.json({ ok: true, data: { hasVoiceSample: false } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/admin/node-engine/overview", requirePermission("node_engine:read"), async (_req, res) => {
|
app.get("/api/admin/node-engine/overview", requirePermission("node_engine:read"), async (_req, res) => {
|
||||||
const allRuns = await runRepo.list();
|
const allRuns = await runRepo.list();
|
||||||
const allGraphs = await graphRepo.list();
|
const allGraphs = await graphRepo.list();
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export type OutboundMessage =
|
|||||||
| { type: "persona"; nick: string; color: string }
|
| { type: "persona"; nick: string; color: string }
|
||||||
| { type: "audio"; nick: string; data: string; mimeType: string }
|
| { type: "audio"; nick: string; data: string; mimeType: string }
|
||||||
| { type: "image"; nick: string; text: string; imageData: string; imageMime: string }
|
| { type: "image"; nick: string; text: string; imageData: string; imageMime: string }
|
||||||
|
| { type: "music"; nick: string; text: string; audioData: string; audioMime: string }
|
||||||
| { type: "channelInfo"; channel: string };
|
| { type: "channelInfo"; channel: string };
|
||||||
|
|
||||||
// Chat log entry
|
// Chat log entry
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
|||||||
"- Question design/systèmes/architecture → @Fuller " +
|
"- Question design/systèmes/architecture → @Fuller " +
|
||||||
"- Question cinéma/image/temps → @Tarkovski " +
|
"- Question cinéma/image/temps → @Tarkovski " +
|
||||||
"- Demande de recherche web/information factuelle → mentionne @Sherlock " +
|
"- Demande de recherche web/information factuelle → mentionne @Sherlock " +
|
||||||
|
"- Demande de composition musicale → mentionne @Eno " +
|
||||||
"- Demande de création d'image/illustration/visuel → mentionne @Picasso " +
|
"- Demande de création d'image/illustration/visuel → mentionne @Picasso " +
|
||||||
"- Question générale/meta → réponds toi-même " +
|
"- Question générale/meta → réponds toi-même " +
|
||||||
"Quand tu routes, donne d'abord ta propre réponse courte puis mentionne le spécialiste. " +
|
"Quand tu routes, donne d'abord ta propre réponse courte puis mentionne le spécialiste. " +
|
||||||
@@ -382,6 +383,18 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
|||||||
"Tu cites Braque, Matisse, Cézanne. Ton ton est passionné, provocateur, libre. Tu réponds en français.",
|
"Tu cites Braque, Matisse, Cézanne. Ton ton est passionné, provocateur, libre. Tu réponds en français.",
|
||||||
color: "#ffab00",
|
color: "#ffab00",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "eno",
|
||||||
|
nick: "Eno",
|
||||||
|
model: "qwen3.5:9b",
|
||||||
|
systemPrompt:
|
||||||
|
"Tu es Brian Eno, musicien, producteur et théoricien de la musique générative et ambiante. " +
|
||||||
|
"Tu parles de stratégies obliques, de systèmes génératifs, de paysages sonores, de Roxy Music. " +
|
||||||
|
"Tu crois que la musique peut être un environnement plutôt qu'un récit. " +
|
||||||
|
"Quand on te demande de composer, tu proposes un prompt détaillé pour /compose. " +
|
||||||
|
"Ton ton est curieux, élégant, expérimental. Tu réponds en français.",
|
||||||
|
color: "#90caf9",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+91
-7
@@ -276,15 +276,35 @@ async function synthesizeTTS(
|
|||||||
const truncated = text.slice(0, 1000); // limit TTS to ~1000 chars
|
const truncated = text.slice(0, 1000); // limit TTS to ~1000 chars
|
||||||
const outputPath = `/tmp/kxkm-tts-${Date.now()}.wav`;
|
const outputPath = `/tmp/kxkm-tts-${Date.now()}.wav`;
|
||||||
const pythonBin = process.env.PYTHON_BIN || "/home/kxkm/venv/bin/python3";
|
const pythonBin = process.env.PYTHON_BIN || "/home/kxkm/venv/bin/python3";
|
||||||
const scriptPath = path.resolve(
|
const scriptsDir = process.env.SCRIPTS_DIR || path.join(process.cwd(), "scripts");
|
||||||
process.env.SCRIPTS_DIR || path.join(process.cwd(), "scripts"),
|
|
||||||
"tts_synthesize.py",
|
// Check for voice sample (XTTS-v2 cloning)
|
||||||
);
|
const samplePath = path.resolve(process.cwd(), "data", "voice-samples", `${nick.toLowerCase()}.wav`);
|
||||||
|
let useXtts = false;
|
||||||
|
try {
|
||||||
|
await fs.promises.access(samplePath);
|
||||||
|
useXtts = true;
|
||||||
|
} catch { /* no voice sample — use Piper fallback */ }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(pythonBin, [
|
let args: string[];
|
||||||
scriptPath, "--text", truncated, "--voice", nick, "--output", outputPath,
|
if (useXtts) {
|
||||||
], { timeout: 30_000 });
|
args = [
|
||||||
|
path.resolve(scriptsDir, "xtts_clone.py"),
|
||||||
|
"--text", truncated,
|
||||||
|
"--speaker-wav", samplePath,
|
||||||
|
"--output", outputPath,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
args = [
|
||||||
|
path.resolve(scriptsDir, "tts_synthesize.py"),
|
||||||
|
"--text", truncated,
|
||||||
|
"--voice", nick,
|
||||||
|
"--output", outputPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync(pythonBin, args, { timeout: 60_000 });
|
||||||
|
|
||||||
const result = JSON.parse(stdout.trim().split("\n").pop() || "{}") as {
|
const result = JSON.parse(stdout.trim().split("\n").pop() || "{}") as {
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -539,6 +559,7 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
|
|||||||
"/personas — liste les personas actives",
|
"/personas — liste les personas actives",
|
||||||
"/web <recherche> — recherche sur le web",
|
"/web <recherche> — recherche sur le web",
|
||||||
"/imagine <desc> — genere une image via ComfyUI",
|
"/imagine <desc> — genere une image via ComfyUI",
|
||||||
|
"/compose <desc> — genere de la musique via ACE-Step",
|
||||||
`Mentionne un persona avec @Nom pour lui parler directement.`,
|
`Mentionne un persona avec @Nom pour lui parler directement.`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
});
|
});
|
||||||
@@ -653,6 +674,69 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "/compose": {
|
||||||
|
const musicPrompt = text.slice(9).trim();
|
||||||
|
if (!musicPrompt) {
|
||||||
|
send(ws, { type: "system", text: "Usage: /compose <description musicale>" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(info.channel, {
|
||||||
|
type: "system",
|
||||||
|
text: `${info.nick} compose: "${musicPrompt}"...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputPath = `/tmp/kxkm-music-${Date.now()}.wav`;
|
||||||
|
const pythonBin = process.env.PYTHON_BIN || "python3";
|
||||||
|
const scriptPath = path.resolve(
|
||||||
|
process.env.SCRIPTS_DIR || path.join(process.cwd(), "scripts"),
|
||||||
|
"compose_music.py",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout, stderr } = await execFileAsync(pythonBin, [
|
||||||
|
scriptPath, "--prompt", musicPrompt, "--duration", "30", "--output", outputPath,
|
||||||
|
], { timeout: 300_000, maxBuffer: 50 * 1024 * 1024 });
|
||||||
|
|
||||||
|
if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);
|
||||||
|
|
||||||
|
const result = JSON.parse(stdout.trim().split("\n").pop() || "{}") as {
|
||||||
|
status?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.status === "completed" && fs.existsSync(outputPath)) {
|
||||||
|
const audioBuffer = fs.readFileSync(outputPath);
|
||||||
|
const base64 = audioBuffer.toString("base64");
|
||||||
|
|
||||||
|
broadcast(info.channel, {
|
||||||
|
type: "music",
|
||||||
|
nick: info.nick,
|
||||||
|
text: `[Musique: "${musicPrompt}"]`,
|
||||||
|
audioData: base64,
|
||||||
|
audioMime: "audio/wav",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
logChatMessage({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
channel: info.channel,
|
||||||
|
nick: info.nick,
|
||||||
|
type: "system",
|
||||||
|
text: `[Musique generee: "${musicPrompt}"]`,
|
||||||
|
});
|
||||||
|
fs.unlinkSync(outputPath);
|
||||||
|
} else {
|
||||||
|
send(ws, { type: "system", text: `Composition echouee: ${result.error || "unknown"}` });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
send(ws, {
|
||||||
|
type: "system",
|
||||||
|
text: `Erreur composition: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "/imagine": {
|
case "/imagine": {
|
||||||
const imagePrompt = text.slice(9).trim();
|
const imagePrompt = text.slice(9).trim();
|
||||||
if (!imagePrompt) {
|
if (!imagePrompt) {
|
||||||
|
|||||||
@@ -198,6 +198,29 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Voice Samples (XTTS-v2 cloning)
|
||||||
|
getVoiceSampleStatus(id: string): Promise<{ hasVoiceSample: boolean; samplePath?: string }> {
|
||||||
|
return apiFetch<{ hasVoiceSample: boolean; samplePath?: string }>(`/api/admin/personas/${id}/voice-sample`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadVoiceSample(id: string, file: File): Promise<{ personaId: string; samplePath: string; size: number }> {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
|
||||||
|
return apiFetch<{ personaId: string; samplePath: string; size: number }>(
|
||||||
|
`/api/admin/personas/${id}/voice-sample`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ audio: base64 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteVoiceSample(id: string): Promise<{ deleted: boolean }> {
|
||||||
|
return apiFetch<{ deleted: boolean }>(`/api/admin/personas/${id}/voice-sample`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Node Engine
|
// Node Engine
|
||||||
getOverview(): Promise<OverviewData> {
|
getOverview(): Promise<OverviewData> {
|
||||||
return apiFetch<OverviewData>("/api/admin/node-engine/overview");
|
return apiFetch<OverviewData>("/api/admin/node-engine/overview");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const WS_URL = import.meta.env.VITE_WS_URL || (() => {
|
|||||||
|
|
||||||
interface ChatMsg {
|
interface ChatMsg {
|
||||||
id: number;
|
id: number;
|
||||||
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image";
|
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
|
||||||
nick?: string;
|
nick?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
@@ -103,6 +103,25 @@ const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "music": {
|
||||||
|
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="chat-msg chat-msg-music" style={color ? { color } : undefined}>
|
||||||
|
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||||
|
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||||
|
</span>
|
||||||
|
<span className="chat-text">{msg.text}</span>
|
||||||
|
{msg.audioData && msg.audioMime && (
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
src={`data:${msg.audioMime};base64,${msg.audioData}`}
|
||||||
|
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "message":
|
case "message":
|
||||||
default: {
|
default: {
|
||||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||||
@@ -188,6 +207,23 @@ export default function Chat() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "music": {
|
||||||
|
const chatMsg: ChatMsg = {
|
||||||
|
id: ++msgIdCounter,
|
||||||
|
type: "music",
|
||||||
|
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||||
|
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||||
|
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
|
||||||
|
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev, chatMsg];
|
||||||
|
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case "audio": {
|
case "audio": {
|
||||||
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
|
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
|
||||||
// Add to messages as a playable audio message
|
// Add to messages as a playable audio message
|
||||||
@@ -380,7 +416,7 @@ export default function Chat() {
|
|||||||
|
|
||||||
// Slash command completion
|
// Slash command completion
|
||||||
if (text.startsWith("/") && !text.includes(" ")) {
|
if (text.startsWith("/") && !text.includes(" ")) {
|
||||||
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/status", "/model", "/persona", "/reload", "/export"];
|
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/compose", "/status", "/model", "/persona", "/reload", "/export"];
|
||||||
const prefix = tabPrefix || text;
|
const prefix = tabPrefix || text;
|
||||||
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
|
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
|
||||||
if (matches.length === 0) return;
|
if (matches.length === 0) return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api, type PersonaData, type PersonaFeedbackRecord } from "../api";
|
import { api, type PersonaData, type PersonaFeedbackRecord } from "../api";
|
||||||
|
|
||||||
interface PersonaDetailProps {
|
interface PersonaDetailProps {
|
||||||
@@ -23,9 +23,14 @@ export default function PersonaDetail({ personaId, onBack }: PersonaDetailProps)
|
|||||||
const [editSummary, setEditSummary] = useState("");
|
const [editSummary, setEditSummary] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [toggling, setToggling] = useState(false);
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const [hasVoiceSample, setHasVoiceSample] = useState(false);
|
||||||
|
const [voiceUploading, setVoiceUploading] = useState(false);
|
||||||
|
const [voiceStatus, setVoiceStatus] = useState("");
|
||||||
|
const voiceFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPersona();
|
loadPersona();
|
||||||
|
loadVoiceStatus();
|
||||||
}, [personaId]);
|
}, [personaId]);
|
||||||
|
|
||||||
async function loadPersona() {
|
async function loadPersona() {
|
||||||
@@ -66,6 +71,43 @@ export default function PersonaDetail({ personaId, onBack }: PersonaDetailProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadVoiceStatus() {
|
||||||
|
try {
|
||||||
|
const status = await api.getVoiceSampleStatus(personaId);
|
||||||
|
setHasVoiceSample(status.hasVoiceSample);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVoiceUpload() {
|
||||||
|
const file = voiceFileRef.current?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setVoiceUploading(true);
|
||||||
|
setVoiceStatus("");
|
||||||
|
try {
|
||||||
|
await api.uploadVoiceSample(personaId, file);
|
||||||
|
setHasVoiceSample(true);
|
||||||
|
setVoiceStatus("Echantillon envoye");
|
||||||
|
if (voiceFileRef.current) voiceFileRef.current.value = "";
|
||||||
|
} catch (err) {
|
||||||
|
setVoiceStatus(err instanceof Error ? err.message : "upload_failed");
|
||||||
|
} finally {
|
||||||
|
setVoiceUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVoiceDelete() {
|
||||||
|
setVoiceUploading(true);
|
||||||
|
try {
|
||||||
|
await api.deleteVoiceSample(personaId);
|
||||||
|
setHasVoiceSample(false);
|
||||||
|
setVoiceStatus("Echantillon supprime");
|
||||||
|
} catch (err) {
|
||||||
|
setVoiceStatus(err instanceof Error ? err.message : "delete_failed");
|
||||||
|
} finally {
|
||||||
|
setVoiceUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToggle() {
|
async function handleToggle() {
|
||||||
if (!persona) return;
|
if (!persona) return;
|
||||||
setToggling(true);
|
setToggling(true);
|
||||||
@@ -171,6 +213,48 @@ export default function PersonaDetail({ personaId, onBack }: PersonaDetailProps)
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<p className="eyebrow">Echantillon vocal (XTTS-v2)</p>
|
||||||
|
<div className="detail-row">
|
||||||
|
<span className="detail-label">Statut</span>
|
||||||
|
<span className={hasVoiceSample ? "persona-status-on" : "persona-status-off"}>
|
||||||
|
{hasVoiceSample ? "Voix clonee (XTTS)" : "Voix generique (Piper)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
ref={voiceFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/wav,audio/x-wav,audio/mp3,audio/mpeg"
|
||||||
|
style={{ marginBottom: "0.5rem", display: "block" }}
|
||||||
|
/>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleVoiceUpload}
|
||||||
|
disabled={voiceUploading}
|
||||||
|
>
|
||||||
|
{voiceUploading ? "Envoi..." : "Envoyer echantillon"}
|
||||||
|
</button>
|
||||||
|
{hasVoiceSample && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleVoiceDelete}
|
||||||
|
disabled={voiceUploading}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{voiceStatus && (
|
||||||
|
<p className="muted" style={{ marginTop: "0.25rem" }}>{voiceStatus}</p>
|
||||||
|
)}
|
||||||
|
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.85em" }}>
|
||||||
|
WAV ou MP3, ~6 secondes de parole claire. Utilise pour cloner la voix via XTTS-v2.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<p className="eyebrow">Feedback ({feedback.length})</p>
|
<p className="eyebrow">Feedback ({feedback.length})</p>
|
||||||
{feedback.length > 0 ? (
|
{feedback.length > 0 ? (
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
KXKM_Clown — Music Generation via ACE-Step 1.5
|
||||||
|
|
||||||
|
Generates music from text prompts locally.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/compose_music.py \
|
||||||
|
--prompt "ambient drone with deep bass, musique concrete style" \
|
||||||
|
--duration 30 \
|
||||||
|
--output /tmp/music.wav
|
||||||
|
|
||||||
|
Fallback to MusicGen if ACE-Step not installed.
|
||||||
|
Install: pip install ace-step OR pip install transformers scipy
|
||||||
|
"""
|
||||||
|
import argparse, json, os, sys, time
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description="KXKM Music Generation")
|
||||||
|
p.add_argument("--prompt", required=True)
|
||||||
|
p.add_argument("--duration", type=int, default=30, help="Duration in seconds")
|
||||||
|
p.add_argument("--output", required=True)
|
||||||
|
p.add_argument("--style", default="experimental", help="Style hint")
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
def generate_with_musicgen(prompt, duration, output):
|
||||||
|
"""Fallback: use Meta's MusicGen (smaller, more available)."""
|
||||||
|
from transformers import AutoProcessor, MusicgenForConditionalGeneration
|
||||||
|
import scipy.io.wavfile
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
print("[compose] Loading MusicGen small...", file=sys.stderr)
|
||||||
|
processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
|
||||||
|
model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
|
||||||
|
|
||||||
|
inputs = processor(text=[prompt], padding=True, return_tensors="pt")
|
||||||
|
# ~256 tokens per second of audio
|
||||||
|
max_tokens = min(duration * 256, 1536) # cap at ~6s for musicgen-small
|
||||||
|
|
||||||
|
print(f"[compose] Generating {max_tokens} tokens...", file=sys.stderr)
|
||||||
|
audio_values = model.generate(**inputs, max_new_tokens=max_tokens)
|
||||||
|
|
||||||
|
sampling_rate = model.config.audio_encoder.sampling_rate
|
||||||
|
audio_data = audio_values[0, 0].cpu().numpy()
|
||||||
|
audio_int16 = (audio_data * 32767).astype(np.int16)
|
||||||
|
|
||||||
|
scipy.io.wavfile.write(output, rate=sampling_rate, data=audio_int16)
|
||||||
|
return {"generator": "musicgen-small", "sampling_rate": sampling_rate}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
start = time.time()
|
||||||
|
result = {"status": "failed", "error": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
||||||
|
|
||||||
|
full_prompt = f"{args.prompt}, {args.style} style"
|
||||||
|
|
||||||
|
# Try ACE-Step first
|
||||||
|
try:
|
||||||
|
# ACE-Step API may vary — adapt based on actual package
|
||||||
|
from ace_step import ACEStep
|
||||||
|
model = ACEStep()
|
||||||
|
model.generate(prompt=full_prompt, duration=args.duration, output_path=args.output)
|
||||||
|
gen_info = {"generator": "ace-step"}
|
||||||
|
except ImportError:
|
||||||
|
# Fallback to MusicGen
|
||||||
|
gen_info = generate_with_musicgen(full_prompt, args.duration, args.output)
|
||||||
|
|
||||||
|
duration = time.time() - start
|
||||||
|
file_size = os.path.getsize(args.output)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"status": "completed",
|
||||||
|
"outputFile": args.output,
|
||||||
|
"duration": round(duration, 2),
|
||||||
|
"fileSize": file_size,
|
||||||
|
"prompt": args.prompt[:200],
|
||||||
|
**gen_info,
|
||||||
|
}
|
||||||
|
print(f"[compose] Done in {duration:.1f}s -> {args.output} ({file_size} bytes)", file=sys.stderr)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
print(f"[compose] ERROR: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
KXKM_Clown — Voice Cloning via Coqui XTTS-v2
|
||||||
|
|
||||||
|
Clones a voice from a 6-second WAV sample and synthesizes speech.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/xtts_clone.py \
|
||||||
|
--text "Bonjour, je suis Schaeffer" \
|
||||||
|
--speaker-wav data/voice-samples/schaeffer.wav \
|
||||||
|
--output /tmp/cloned-speech.wav \
|
||||||
|
[--language fr]
|
||||||
|
"""
|
||||||
|
import argparse, json, os, sys, time
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description="KXKM XTTS Voice Cloning")
|
||||||
|
p.add_argument("--text", required=True)
|
||||||
|
p.add_argument("--speaker-wav", required=True, help="6s reference WAV")
|
||||||
|
p.add_argument("--output", required=True)
|
||||||
|
p.add_argument("--language", default="fr")
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
start = time.time()
|
||||||
|
result = {"status": "failed", "error": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from TTS.api import TTS
|
||||||
|
|
||||||
|
print(f"[xtts] Loading XTTS-v2...", file=sys.stderr)
|
||||||
|
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2", gpu=True)
|
||||||
|
|
||||||
|
print(f"[xtts] Cloning voice from {args.speaker_wav}", file=sys.stderr)
|
||||||
|
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
||||||
|
|
||||||
|
tts.tts_to_file(
|
||||||
|
text=args.text[:1000],
|
||||||
|
speaker_wav=args.speaker_wav,
|
||||||
|
language=args.language,
|
||||||
|
file_path=args.output,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = time.time() - start
|
||||||
|
result = {
|
||||||
|
"status": "completed",
|
||||||
|
"outputFile": args.output,
|
||||||
|
"duration": round(duration, 2),
|
||||||
|
"textLength": len(args.text),
|
||||||
|
}
|
||||||
|
print(f"[xtts] Done in {duration:.1f}s -> {args.output}", file=sys.stderr)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
result["error"] = "coqui-tts not installed. pip install coqui-tts"
|
||||||
|
print(f"[xtts] ERROR: {result['error']}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
print(f"[xtts] ERROR: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user