Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -22,3 +22,6 @@ data/*
|
||||
!data/manifeste.md
|
||||
!data/manifeste_references_nouvelles.md
|
||||
.claude/
|
||||
apps/*
|
||||
test-results/playwright-html/*
|
||||
test-results/
|
||||
@@ -257,12 +257,50 @@ stateDiagram-v2
|
||||
|
||||
### Coordinateur
|
||||
- [x] Audit SOTA 2026 intelligence + affectation agents par module
|
||||
- [x] lot-552: cloture documentaire lot-192 + sync QA/veille OSS
|
||||
|
||||
### Backend API
|
||||
- [x] lot-192: ws-commands modular extraction phases 1-2 (`/comp`, `/layer`, `/mix`, `/voice`, gestion composition)
|
||||
- [ ] lot-192: phase 3 extraction edition avancee (`/concat`, `/silence`, `/template`, `/marker`, `/metronome`, `/delete`, `/suggest`, `/snapshot`, `/randomize`)
|
||||
- [x] lot-192: ws-commands modular extraction phases 1-3 (`/comp`, `/layer`, `/mix`, `/voice`, gestion composition, edition avancee)
|
||||
- [ ] lot-549: renfort tests composition et export
|
||||
## Delta Session 2026-03-24 — phase 3
|
||||
|
||||
### Backend API
|
||||
- [x] lot-192: extraction complete du bloc compose avance (`/concat`, `/silence`, `/template`, `/marker`, `/metronome`, `/delete`, `/suggest`, `/snapshot`, `/randomize`)
|
||||
- [ ] lot-193: renfort tests composition et export
|
||||
- [x] lot-552: cloture documentaire et durcissement final lot-192
|
||||
- [ ] lot-549: renfort tests composition et export
|
||||
- [ ] lot-548: waveform timeline UI v1
|
||||
## Affectations Session 2026-03-24 — suite immediate
|
||||
|
||||
| Lot | Agent pilote | Sous-agents / competences | Portee immediate |
|
||||
|---|---|---|---|
|
||||
| lot-549 | Backend API | Polyglot Test Agent, QA, context-map | Etendre `apps/api/src/ws-commands.test.ts` et `apps/api/src/composition-store.test.ts`, verifier non-regression composition |
|
||||
| lot-548 | Frontend | Expert React Frontend Engineer, gem-browser-tester, context-map | Remplacer la waveform canvas par une timeline waveform plus lisible sans regression mobile |
|
||||
| lot-552 | Coordinateur | Planner/Docs, gem-researcher, refactor-plan | Maintenir PLAN/TODO/AGENTS/QA/OSS alignes avec l'etat reel et tracer les prochains deltas |
|
||||
|
||||
## Skills privilegies
|
||||
|
||||
- context-map: cartographie des fichiers et dependances avant modification.
|
||||
- refactor-plan: sequencing prudent des changements multi-fichiers.
|
||||
- polyglot-test-agent: lot-549 pour la recherche, le plan et l'implementation de tests additionnels.
|
||||
## Delta Session 2026-03-24 — validation compose + waveform
|
||||
|
||||
### Backend API
|
||||
- [x] lot-549: renfort tests composition et export
|
||||
- Validation ciblee: `tsx --test src/composition-store.test.ts src/ws-commands.test.ts` => 44/44 OK
|
||||
|
||||
### Frontend
|
||||
- [x] lot-548: waveform timeline UI v1
|
||||
- Implementation: `wavesurfer.js` branche dans `apps/web/src/components/ComposePage.tsx` pour apercu piste + bloc timeline
|
||||
- Validation: `npm run check` dans `apps/web` OK
|
||||
## Delta Session 2026-03-24 — cleanup compose
|
||||
|
||||
### Frontend
|
||||
- [x] lot-553: suppression du doublon `apps/web/src/components/ComposePage 2.tsx`
|
||||
- Validation: `npm run check` dans `apps/web` OK
|
||||
- Impact: reduction dette technique UI compose et elimination d'un faux point d'entree potentiel
|
||||
## Delta Session 2026-03-24 — QA visuelle compose
|
||||
|
||||
### Frontend
|
||||
- [x] lot-554: validation Playwright ciblee compose timeline
|
||||
- Commande: `npx playwright test e2e/visual-qa.spec.ts --project=chromium --grep "ComposePage|composition timeline"`
|
||||
- Resultat: 2/2 OK
|
||||
@@ -1022,13 +1022,34 @@ Updated: 2026-03-20T12:00:00Z
|
||||
|
||||
## Delta Session 2026-03-24
|
||||
|
||||
- lot-192-ws-commands-extraction reste en cours.
|
||||
- Phases 1-2 executees: extraction de /comp, /layer, /mix, /voice, /tracks, /undo, /solo, /unsolo, /rename, /dup, /bpm, /clear-comp, /preview, /gain, /loop, /swap et /info vers apps/api/src/ws-commands-compose.ts.
|
||||
- Validation: diagnostics TS OK, suite @kxkm/api relancee avec ws-commands.test.ts OK.
|
||||
- Prochaine etape: phase 3 sur concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize, puis lot-193 tests composition.
|
||||
- lot-192-ws-commands-extraction est cloture.
|
||||
- Phases 1-3 executees: extraction de /comp, /layer, /mix, /voice, /tracks, /undo, /solo, /unsolo, /rename, /dup, /bpm, /clear-comp, /preview, /gain, /loop, /swap, /info, /concat, /silence, /template, /marker, /metronome, /delete, /suggest, /snapshot et /randomize vers apps/api/src/ws-commands-compose.ts.
|
||||
- Validation: diagnostics TS OK, suite @kxkm/api relancee avec ws-commands.test.ts OK, cloture documentaire lot-552 appliquee.
|
||||
- Prochaine etape: lot-549 tests composition, puis lot-548 waveform/timeline UI.
|
||||
## Delta Session 2026-03-24 — phase 3
|
||||
|
||||
- lot-192-ws-commands-extraction: phases 1-3 executees.
|
||||
- Bloc compose complet extrait vers apps/api/src/ws-commands-compose.ts, incluant maintenant concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize.
|
||||
- Validation: diagnostics TS OK, suite @kxkm/api relancee avec ws-commands.test.ts OK.
|
||||
- Prochaine etape: lot-193 tests composition, puis lot-194 waveform/timeline UI.
|
||||
- lot-552: synchronisation PLAN/TODO/AGENTS + rapport QA webdesign + veille OSS ciblee sur waveform/tests/orchestration.
|
||||
- Prochaine etape: lot-549 tests composition, puis lot-194 waveform/timeline UI.
|
||||
## Delta Session 2026-03-24 — validation compose + waveform
|
||||
|
||||
- lot-549-composition-tests est valide.
|
||||
- Couverture ajoutee sur `apps/api/src/composition-store.test.ts` et `apps/api/src/ws-commands.test.ts` pour rebind composition active, clamps timeline, `/comp load`, `/mix`, `/snapshot`, `/template` et `/delete`.
|
||||
- Validation lot-549: `../../node_modules/.bin/tsx --test src/composition-store.test.ts src/ws-commands.test.ts` => 44/44 tests OK.
|
||||
- lot-548-waveform-ui-v1 est implemente dans `apps/web/src/components/ComposePage.tsx` avec `wavesurfer.js` pour les apercus de pistes et blocs timeline.
|
||||
- Validation lot-548: `npm run check` dans `apps/web` OK.
|
||||
- Prochaine etape recommandee: capture visuelle Playwright du compose timeline + nettoyage du doublon `ComposePage 2.tsx`.
|
||||
## Delta Session 2026-03-24 — cleanup compose
|
||||
|
||||
- lot-553-cleanup-compose-doublon execute.
|
||||
- Suppression du fichier duplique non reference `apps/web/src/components/ComposePage 2.tsx`.
|
||||
- Validation: `npm run check` dans `apps/web` OK.
|
||||
- Risque reduit: suppression d'une source de divergence entre UI compose legacy et UI active.
|
||||
## Delta Session 2026-03-24 — QA visuelle compose
|
||||
|
||||
- lot-554-qa-compose-timeline execute.
|
||||
- Validation Playwright ciblee sur `apps/web/e2e/visual-qa.spec.ts` (grep `ComposePage|composition timeline`).
|
||||
- Resultat: 2/2 tests OK, captures regenerees `10-compose-page.png` et `18-composition-timeline.png`.
|
||||
- Conclusion: rendu Compose/timeline stable apres migration waveform + cleanup doublon.
|
||||
@@ -412,12 +412,28 @@ Fait sur ce lot:
|
||||
|
||||
## Delta Session 2026-03-24
|
||||
|
||||
- [ ] lot-550 (en cours): lot-192 ws-commands extraction phases 1-2 (compose core + gestion composition) + audit SOTA 2026
|
||||
- [ ] lot-551: lot-192 phase 3 extraction edition avancee (concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize)
|
||||
- [ ] lot-549: composition tests renfort (store/remix/export/timeline)
|
||||
- [x] lot-550: lot-192 ws-commands extraction phases 1-2 (compose core + gestion composition) + audit SOTA 2026
|
||||
- [x] lot-551: lot-192 phase 3 extraction edition avancee (concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize)
|
||||
- [x] lot-552: cloture documentaire et durcissement final lot-192
|
||||
- [ ] lot-549 (en cours): composition tests renfort (store/remix/export/timeline)
|
||||
## Delta Session 2026-03-24 — phase 3
|
||||
|
||||
- [x] lot-551: lot-192 phase 3 extraction edition avancee (concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize)
|
||||
- [ ] lot-552: cloture documentaire et durcissement final lot-192
|
||||
- [ ] lot-549: composition tests renfort (store/remix/export/timeline)
|
||||
- [x] lot-552: cloture documentaire et durcissement final lot-192
|
||||
- [ ] lot-549 (en cours): composition tests renfort (store/remix/export/timeline)
|
||||
- [ ] lot-548: waveform timeline UI v1 (desktop + mobile)
|
||||
## Delta Session 2026-03-24 — validation compose + waveform
|
||||
|
||||
- [x] lot-549: composition tests renfort (store/remix/export/timeline)
|
||||
- [x] lot-548: waveform timeline UI v1 (desktop + mobile)
|
||||
- [ ] prochain lot suggere: capture QA visuelle compose timeline + nettoyage des doublons UI compose
|
||||
## Delta Session 2026-03-24 — cleanup compose
|
||||
|
||||
- [x] lot-553: cleanup doublon ComposePage non reference
|
||||
- [x] validation frontend apres cleanup (`npm run check`)
|
||||
- [ ] prochain lot suggere: passe QA visuelle Playwright sur timeline compose waveform
|
||||
## Delta Session 2026-03-24 — QA visuelle compose
|
||||
|
||||
- [x] lot-554: passe Playwright visuelle ciblee Compose/timeline
|
||||
- [x] verification captures `10-compose-page.png` et `18-composition-timeline.png`
|
||||
- [ ] prochain lot suggere: triage et purge artefacts test-results non necessaires au commit
|
||||
@@ -2,5 +2,5 @@
|
||||
"nick": "Pharmacius",
|
||||
"facts": [],
|
||||
"summary": "",
|
||||
"lastUpdated": "2026-03-24T16:42:42.979Z"
|
||||
"lastUpdated": "2026-03-24T17:51:36.369Z"
|
||||
}
|
||||
@@ -229,4 +229,62 @@ describe("composition-store", () => {
|
||||
assert.equal(onDisk.timeline.markers.length, 1);
|
||||
assert.equal(onDisk.timeline.markers[0].label, "Intro");
|
||||
});
|
||||
|
||||
it("setActiveComposition rebinds nick/channel while preserving timeline", async () => {
|
||||
const {
|
||||
createComposition,
|
||||
addTrack,
|
||||
setActiveComposition,
|
||||
getActiveComposition,
|
||||
getTimeline,
|
||||
} = await storePromise;
|
||||
const comp = createComposition("louis", "#old", "Rebind test");
|
||||
const track = addTrack(comp.id, {
|
||||
type: "music",
|
||||
prompt: "slow pulse",
|
||||
duration: 6,
|
||||
volume: 55,
|
||||
startMs: 1200,
|
||||
});
|
||||
|
||||
assert.ok(track);
|
||||
|
||||
const rebound = setActiveComposition("marie", "#new", comp.id);
|
||||
assert.ok(rebound);
|
||||
assert.equal(rebound!.nick, "marie");
|
||||
assert.equal(rebound!.channel, "#new");
|
||||
|
||||
const active = getActiveComposition("marie", "#new");
|
||||
assert.ok(active);
|
||||
assert.equal(active!.id, comp.id);
|
||||
|
||||
const timeline = getTimeline(comp.id);
|
||||
assert.ok(timeline);
|
||||
assert.equal(timeline!.tracks.length, 1);
|
||||
assert.equal(timeline!.clips.length, 1);
|
||||
assert.equal(timeline!.clips[0].trackId, track!.id);
|
||||
});
|
||||
|
||||
it("clamps timeline settings and sorts markers by position", async () => {
|
||||
const {
|
||||
createComposition,
|
||||
updateTimelineSettings,
|
||||
addTimelineMarker,
|
||||
listTimelineMarkers,
|
||||
} = await storePromise;
|
||||
const comp = createComposition("nina", "#sort", "Clamp test");
|
||||
|
||||
const timeline = updateTimelineSettings(comp.id, { bpm: 999, timeSignature: [0, 0] });
|
||||
assert.ok(timeline);
|
||||
assert.equal(timeline!.bpm, 300);
|
||||
assert.deepEqual(timeline!.timeSignature, [1, 1]);
|
||||
|
||||
addTimelineMarker(comp.id, { label: "Outro", atMs: 9000 });
|
||||
addTimelineMarker(comp.id, { label: "Intro", atMs: 1000 });
|
||||
addTimelineMarker(comp.id, { label: "Middle", atMs: 5000 });
|
||||
|
||||
const markers = listTimelineMarkers(comp.id);
|
||||
assert.deepEqual(markers.map((marker) => marker.label), ["Intro", "Middle", "Outro"]);
|
||||
assert.deepEqual(markers.map((marker) => marker.atMs), [1000, 5000, 9000]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, mock, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createCommandHandler } from "./ws-commands.js";
|
||||
import { addTrack, createComposition } from "./composition-store.js";
|
||||
import { addTrack, createComposition, getActiveComposition } from "./composition-store.js";
|
||||
import type { ClientInfo, ChatPersona, OutboundMessage } from "./chat-types.js";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
@@ -22,6 +25,14 @@ function makeInfo(overrides?: Partial<ClientInfo>): ClientInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function makeComposeInfo(prefix: string): ClientInfo {
|
||||
const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return makeInfo({
|
||||
nick: `${prefix}-${suffix}`,
|
||||
channel: `#${prefix.toLowerCase()}-${suffix}`,
|
||||
});
|
||||
}
|
||||
|
||||
function makeDeps(overrides?: Partial<Record<string, unknown>>) {
|
||||
return {
|
||||
send: mock.fn<(ws: WebSocket, msg: OutboundMessage) => void>(),
|
||||
@@ -388,13 +399,94 @@ describe("/compose", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /comp and extracted composition commands */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/comp", () => {
|
||||
it("creates a composition and emits the load payload", async () => {
|
||||
const info = makeComposeInfo("ComposerNew");
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
|
||||
await handle({ ws: fakeWs, info, text: "/comp new Session test" });
|
||||
|
||||
assert.equal(deps.send.mock.callCount(), 2);
|
||||
const createdMsg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
const loadedMsg = deps.send.mock.calls[1].arguments[1] as { type: string; text: string };
|
||||
assert.equal(createdMsg.type, "system");
|
||||
assert.ok(createdMsg.text.includes("Composition creee"));
|
||||
assert.ok(createdMsg.text.includes("Session test"));
|
||||
assert.ok(loadedMsg.text.startsWith("__comp_loaded__"));
|
||||
|
||||
const match = createdMsg.text.match(/\((comp_[^)]+)\)/);
|
||||
assert.ok(match, "should expose created composition id");
|
||||
rmSync(path.join(process.cwd(), "data", "compositions", match![1]), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("loads an existing composition by id", async () => {
|
||||
const info = makeComposeInfo("ComposerLoad");
|
||||
const comp = createComposition("OtherNick", "#other", "Existing comp");
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
|
||||
try {
|
||||
await handle({ ws: fakeWs, info, text: `/comp load ${comp.id}` });
|
||||
|
||||
assert.equal(deps.send.mock.callCount(), 2);
|
||||
const statusMsg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
const loadedMsg = deps.send.mock.calls[1].arguments[1] as { type: string; text: string };
|
||||
assert.equal(statusMsg.type, "system");
|
||||
assert.ok(statusMsg.text.includes("Composition chargee"));
|
||||
assert.ok(statusMsg.text.includes("Existing comp"));
|
||||
assert.ok(loadedMsg.text.startsWith("__comp_loaded__"));
|
||||
} finally {
|
||||
rmSync(path.join(process.cwd(), "data", "compositions", comp.id), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* composition management routing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/mix", () => {
|
||||
it("mixes a single available track and exposes download paths", async () => {
|
||||
const info = makeComposeInfo("ComposerMix");
|
||||
const comp = createComposition(info.nick, info.channel, "Mix test");
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "kxkm-mix-"));
|
||||
const sourcePath = path.join(tempDir, "track.wav");
|
||||
writeFileSync(sourcePath, Buffer.from("RIFFtest-wave"));
|
||||
const track = addTrack(comp.id, { type: "music", prompt: "drone test", duration: 7, volume: 80, startMs: 0 });
|
||||
assert.ok(track);
|
||||
track!.filePath = sourcePath;
|
||||
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
|
||||
try {
|
||||
await handle({ ws: fakeWs, info, text: "/mix" });
|
||||
|
||||
const broadcastMusic = deps.broadcast.mock.calls.find((call) => {
|
||||
const msg = call.arguments[1] as { type?: string };
|
||||
return msg.type === "music";
|
||||
});
|
||||
assert.ok(broadcastMusic, "should broadcast mixed audio");
|
||||
|
||||
const systemMsg = deps.send.mock.calls.at(-1)?.arguments[1] as { type: string; text: string };
|
||||
assert.equal(systemMsg.type, "system");
|
||||
assert.ok(systemMsg.text.includes(`/api/v2/media/compositions/${comp.id}/mix`));
|
||||
assert.ok(existsSync(path.join(process.cwd(), "data", "compositions", comp.id, "mix.wav")));
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
rmSync(path.join(process.cwd(), "data", "compositions", comp.id), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("/rename", () => {
|
||||
it("renames the active composition through the extracted compose handler", async () => {
|
||||
const info = makeInfo({ nick: "ComposerRename", channel: "#compose-rename" });
|
||||
const info = makeComposeInfo("ComposerRename");
|
||||
createComposition(info.nick, info.channel, "Ancien nom");
|
||||
|
||||
const deps = makeDeps();
|
||||
@@ -409,7 +501,7 @@ describe("/rename", () => {
|
||||
|
||||
describe("/tracks", () => {
|
||||
it("lists tracks through the extracted compose management handler", async () => {
|
||||
const info = makeInfo({ nick: "ComposerTracks", channel: "#compose-tracks" });
|
||||
const info = makeComposeInfo("ComposerTracks");
|
||||
const comp = createComposition(info.nick, info.channel, "Track test");
|
||||
addTrack(comp.id, { type: "music", prompt: "drone test", duration: 12, volume: 80, startMs: 0 });
|
||||
|
||||
@@ -426,10 +518,12 @@ describe("/tracks", () => {
|
||||
|
||||
describe("/delete", () => {
|
||||
it("deletes a track through the extracted compose advanced handler", async () => {
|
||||
const info = makeInfo({ nick: "ComposerDelete", channel: "#compose-delete" });
|
||||
const comp = createComposition(info.nick, info.channel, "Delete test");
|
||||
addTrack(comp.id, { type: "music", prompt: "first track", duration: 8, volume: 100, startMs: 0 });
|
||||
addTrack(comp.id, { type: "music", prompt: "second track", duration: 9, volume: 100, startMs: 0 });
|
||||
const info = makeComposeInfo("ComposerDelete");
|
||||
createComposition(info.nick, info.channel, "Delete test");
|
||||
let activeComp = getActiveComposition(info.nick, info.channel);
|
||||
assert.ok(activeComp);
|
||||
addTrack(activeComp!.id, { type: "music", prompt: "first track", duration: 8, volume: 100, startMs: 0 });
|
||||
addTrack(activeComp!.id, { type: "music", prompt: "second track", duration: 9, volume: 100, startMs: 0 });
|
||||
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
@@ -438,14 +532,16 @@ describe("/delete", () => {
|
||||
const msg = deps.send.mock.calls.at(-1)?.arguments[1] as { type: string; text: string };
|
||||
assert.equal(msg.type, "system");
|
||||
assert.ok(msg.text.includes("supprimee"));
|
||||
assert.equal(comp.tracks.length, 1);
|
||||
assert.ok(comp.tracks[0].prompt.includes("second track"));
|
||||
activeComp = getActiveComposition(info.nick, info.channel);
|
||||
assert.ok(activeComp);
|
||||
assert.equal(activeComp!.tracks.length, 1);
|
||||
assert.ok(activeComp!.tracks[0].prompt.includes("second track"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("/marker", () => {
|
||||
it("adds and lists markers through the extracted compose advanced handler", async () => {
|
||||
const info = makeInfo({ nick: "ComposerMarker", channel: "#compose-marker" });
|
||||
const info = makeComposeInfo("ComposerMarker");
|
||||
createComposition(info.nick, info.channel, "Marker test");
|
||||
|
||||
const deps = makeDeps();
|
||||
@@ -459,3 +555,41 @@ describe("/marker", () => {
|
||||
assert.ok(lastMsg.text.includes("12s"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("/snapshot", () => {
|
||||
it("writes a snapshot file for the active composition", async () => {
|
||||
const info = makeComposeInfo("ComposerSnapshot");
|
||||
const comp = createComposition(info.nick, info.channel, "Snapshot test");
|
||||
addTrack(comp.id, { type: "music", prompt: "frozen pulse", duration: 11, volume: 70, startMs: 0 });
|
||||
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
const snapshotPath = path.join(process.cwd(), "data", "compositions", comp.id, "snapshots", "v1.json");
|
||||
|
||||
try {
|
||||
await handle({ ws: fakeWs, info, text: "/snapshot v1" });
|
||||
|
||||
const msg = deps.send.mock.calls.at(-1)?.arguments[1] as { type: string; text: string };
|
||||
assert.equal(msg.type, "system");
|
||||
assert.ok(msg.text.includes('Snapshot "v1" sauvegarde'));
|
||||
assert.ok(existsSync(snapshotPath));
|
||||
} finally {
|
||||
rmSync(path.join(process.cwd(), "data", "compositions", comp.id), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("/template", () => {
|
||||
it("lists available templates when the requested one is unknown", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/template inconnu" });
|
||||
|
||||
const msg = deps.send.mock.calls.at(-1)?.arguments[1] as { type: string; text: string };
|
||||
assert.equal(msg.type, "system");
|
||||
assert.ok(msg.text.includes("Templates:"));
|
||||
assert.ok(msg.text.includes("ambient-4"));
|
||||
assert.ok(msg.text.includes("Usage: /template <nom>"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { VideotexPageHeader, VideotexSeparator } from "./VideotexMosaic";
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
prompt: string;
|
||||
style: string;
|
||||
duration: number;
|
||||
volume: number;
|
||||
type: "music" | "voice" | "sfx";
|
||||
audioData?: string;
|
||||
audioMime?: string;
|
||||
}
|
||||
|
||||
export default function ComposePage() {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [style, setStyle] = useState("experimental");
|
||||
const [duration, setDuration] = useState(30);
|
||||
const [tracks, setTracks] = useState<Track[]>([]);
|
||||
const [mixing, setMixing] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [compName, setCompName] = useState("Ma composition");
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Connect to WebSocket
|
||||
useEffect(() => {
|
||||
const nick = sessionStorage.getItem("kxkm-nick") || "composer";
|
||||
const wsUrl = (location.protocol === "https:" ? "wss:" : "ws:") + "//" + location.host + "/ws?nick=" + encodeURIComponent(nick);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const m = JSON.parse(event.data);
|
||||
|
||||
// Capture music results (from /layer, /noise, /mix)
|
||||
if (m.type === "music" && m.audioData) {
|
||||
setTracks(prev => [...prev, {
|
||||
id: Date.now() + Math.random(),
|
||||
prompt: m.text || "Sans titre",
|
||||
style,
|
||||
duration,
|
||||
volume: 100,
|
||||
type: (m.text || "").includes("Noise") ? "sfx" : (m.text || "").includes("Mix") ? "music" : "music",
|
||||
audioData: m.audioData,
|
||||
audioMime: m.audioMime || "audio/wav",
|
||||
}]);
|
||||
setGenerating(false);
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
// Capture voice results (from /voice)
|
||||
if (m.type === "audio" && m.data) {
|
||||
setTracks(prev => [...prev, {
|
||||
id: Date.now() + Math.random(),
|
||||
prompt: m.nick ? m.nick + " (voix)" : "Voix",
|
||||
style: "voice",
|
||||
duration: 10,
|
||||
volume: 100,
|
||||
type: "voice",
|
||||
audioData: m.data,
|
||||
audioMime: m.mimeType || "audio/wav",
|
||||
}]);
|
||||
setGenerating(false);
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
// Status messages
|
||||
if (m.type === "system" && m.text) {
|
||||
const t = m.text;
|
||||
if (t.includes("ajoute une piste") || t.includes("Generation") || t.includes("generation") || t.includes("Mixage") || t.includes("compose")) {
|
||||
setStatus(t.slice(0, 100));
|
||||
}
|
||||
if (t.includes("Erreur") || t.includes("echouee")) {
|
||||
setGenerating(false);
|
||||
setStatus("Erreur: " + t.slice(0, 80));
|
||||
}
|
||||
if (t.includes("Mix termine")) {
|
||||
setMixing(false);
|
||||
setStatus("Mix termine!");
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return () => { ws.close(); };
|
||||
}, []);
|
||||
|
||||
function sendCmd(cmd: string) {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "command", text: cmd }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddTrack(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!prompt.trim() || generating) return;
|
||||
setGenerating(true);
|
||||
setStatus("Generation en cours...");
|
||||
sendCmd("/layer " + prompt.trim() + ", " + style + " style, " + duration + "s");
|
||||
}
|
||||
|
||||
function handleAddVoice() {
|
||||
if (!prompt.trim() || generating) return;
|
||||
setGenerating(true);
|
||||
setStatus("Voix en cours...");
|
||||
sendCmd("/voice Pharmacius \"" + prompt.trim() + "\"");
|
||||
}
|
||||
|
||||
function handleAddNoise(type: string) {
|
||||
setGenerating(true);
|
||||
setStatus("Noise " + type + "...");
|
||||
sendCmd("/noise " + type + " " + duration);
|
||||
}
|
||||
|
||||
function handleMix() {
|
||||
setMixing(true);
|
||||
setStatus("Mixage...");
|
||||
sendCmd("/mix");
|
||||
}
|
||||
|
||||
function handleNewComp() {
|
||||
sendCmd("/comp new " + compName);
|
||||
setTracks([]);
|
||||
setStatus("Nouvelle composition");
|
||||
}
|
||||
|
||||
const maxDuration = Math.max(30, ...tracks.map(t => t.duration));
|
||||
|
||||
return (
|
||||
<div className="compose-page">
|
||||
<VideotexPageHeader title="COMPOSE" subtitle="Studio multi-pistes" color="magenta" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="compose-comp-header">
|
||||
<input type="text" value={compName} onChange={e => setCompName(e.target.value)} className="minitel-input" placeholder="Nom" style={{flex:1}} />
|
||||
<button className="minitel-nav-btn" onClick={handleNewComp}>Nouvelle</button>
|
||||
<span className="compose-track-count">{tracks.length} piste{tracks.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
|
||||
{status && <div className="compose-status">{status}</div>}
|
||||
|
||||
<VideotexSeparator color="magenta" />
|
||||
|
||||
{/* Generator */}
|
||||
<form onSubmit={handleAddTrack} className="compose-form">
|
||||
<div className="compose-header">{"> AJOUTER UNE PISTE"}</div>
|
||||
<textarea value={prompt} onChange={e => setPrompt(e.target.value)} placeholder="dark ambient drone, musique concrete..." className="minitel-input compose-textarea" rows={2} maxLength={500} />
|
||||
<div className="compose-options">
|
||||
<select value={style} onChange={e => setStyle(e.target.value)} className="minitel-input" style={{flex:1}}>
|
||||
<optgroup label="Electronique">
|
||||
<option value="experimental">Experimental</option>
|
||||
<option value="ambient">Ambient</option>
|
||||
<option value="drone">Drone</option>
|
||||
<option value="noise">Noise</option>
|
||||
<option value="glitch">Glitch</option>
|
||||
<option value="industrial">Industrial</option>
|
||||
<option value="techno">Techno</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="synthwave">Synthwave</option>
|
||||
</optgroup>
|
||||
<optgroup label="Concrete / Acoustique">
|
||||
<option value="concrete">Musique concrete</option>
|
||||
<option value="electroacoustique">Electroacoustique</option>
|
||||
<option value="acousmatic">Acousmatique</option>
|
||||
<option value="field-recording">Field recording</option>
|
||||
</optgroup>
|
||||
<optgroup label="Jazz / Classique">
|
||||
<option value="jazz">Jazz</option>
|
||||
<option value="free-jazz">Free jazz</option>
|
||||
<option value="classical">Classique</option>
|
||||
<option value="cinematic">Cinematique</option>
|
||||
</optgroup>
|
||||
<optgroup label="Rock / Urbain">
|
||||
<option value="post-rock">Post-rock</option>
|
||||
<option value="metal">Metal</option>
|
||||
<option value="hip-hop">Hip-hop</option>
|
||||
<option value="lo-fi">Lo-fi</option>
|
||||
<option value="trap">Trap</option>
|
||||
</optgroup>
|
||||
<optgroup label="World / Dark">
|
||||
<option value="folk">Folk</option>
|
||||
<option value="world">World</option>
|
||||
<option value="dark">Dark ambient</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<select value={duration} onChange={e => setDuration(Number(e.target.value))} className="minitel-input" style={{width:70}}>
|
||||
<option value={10}>10s</option>
|
||||
<option value={30}>30s</option>
|
||||
<option value={60}>60s</option>
|
||||
<option value={120}>2min</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="compose-btn-row">
|
||||
<span className="compose-btn-label">Generer:</span>
|
||||
<button type="submit" className="minitel-nav-btn" disabled={generating || !prompt.trim()}>Musique</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={handleAddVoice} disabled={generating || !prompt.trim()}>Voix</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("drone")} disabled={generating}>Drone</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("pink")} disabled={generating}>Pink</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("white")} disabled={generating}>White</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("sine")} disabled={generating}>Sine</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("brown")} disabled={generating}>Brown</button>
|
||||
</div>
|
||||
{tracks.length > 0 && (
|
||||
<div className="compose-btn-row">
|
||||
<span className="compose-btn-label">Edition:</span>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd("/undo")}>Undo</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd("/tracks")}>Tracks</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => { const n = prompt.trim() || "1"; sendCmd(`/fx ${n} reverse`); }}>Reverse</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => { const n = prompt.trim() || "1"; sendCmd(`/fx ${n} reverb`); }}>Reverb</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => { const n = prompt.trim() || "1"; sendCmd(`/fx ${n} echo`); }}>Echo</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => { const n = prompt.trim() || "1"; sendCmd(`/fx ${n} distortion`); }}>Distort</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd(`/stutter ${tracks.length} 8`)}>Stutter</button>
|
||||
</div>
|
||||
)}
|
||||
{tracks.length > 1 && (
|
||||
<div className="compose-btn-row">
|
||||
<span className="compose-btn-label">Sortie:</span>
|
||||
<button type="button" className="minitel-login-btn" onClick={handleMix} disabled={mixing}>
|
||||
{mixing ? "Mixage..." : `Mix ${tracks.length}p`}
|
||||
</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd("/master")}>Master</button>
|
||||
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd("/export")}>Export</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Timeline */}
|
||||
{tracks.length > 0 && (
|
||||
<>
|
||||
<VideotexSeparator color="yellow" />
|
||||
<div className="compose-header">{"> TIMELINE"}</div>
|
||||
<div className="compose-timeline">
|
||||
<div className="timeline-ruler">
|
||||
{Array.from({ length: Math.ceil(maxDuration / 5) + 1 }, (_, i) => (
|
||||
<span key={i} className="timeline-tick" style={{ left: (i * 5 / maxDuration) * 100 + "%" }}>{i * 5}s</span>
|
||||
))}
|
||||
</div>
|
||||
{tracks.map((track, i) => {
|
||||
const icon = track.type === "voice" ? "V" : track.type === "sfx" ? "N" : "M";
|
||||
const colors = ["#c84c0c", "#2c6e49", "#7c3aed", "#0f766e", "#b45309", "#1d4ed8"];
|
||||
return (
|
||||
<div key={track.id} className="timeline-lane">
|
||||
<span className="timeline-label">{icon}{i + 1}</span>
|
||||
<div className="timeline-track-area">
|
||||
<div className="timeline-block" style={{
|
||||
width: Math.max((track.duration / maxDuration) * 100, 8) + "%",
|
||||
backgroundColor: colors[i % colors.length],
|
||||
opacity: track.volume / 100,
|
||||
}} title={track.prompt + " (" + track.duration + "s)"}>
|
||||
<span className="timeline-block-text">{track.prompt.slice(0, 25)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tracks detail */}
|
||||
{tracks.length > 0 && (
|
||||
<>
|
||||
<VideotexSeparator color="cyan" />
|
||||
<div className="compose-header">{"> PISTES"}</div>
|
||||
{tracks.map((track, i) => (
|
||||
<div key={track.id} className="compose-track">
|
||||
<span className="compose-track-num">#{i + 1}</span>
|
||||
<span className="compose-track-prompt">{track.prompt.slice(0, 30)}</span>
|
||||
<input type="range" min={0} max={100} value={track.volume} onChange={e => setTracks(prev => prev.map((t, j) => j === i ? { ...t, volume: +e.target.value } : t))} className="compose-volume" title={`Vol: ${track.volume}%`} />
|
||||
<div className="compose-track-actions">
|
||||
<button className="compose-track-btn" onClick={() => sendCmd(`/solo ${i+1}`)} title="Solo">S</button>
|
||||
<button className="compose-track-btn" onClick={() => sendCmd(`/loop ${i+1} 2`)} title="Loop x2">{"\u27f3"}</button>
|
||||
<button className="compose-track-btn" onClick={() => sendCmd(`/fx ${i+1} reverse`)} title="Reverse">{"\u21c6"}</button>
|
||||
</div>
|
||||
{track.audioData && <audio controls src={`data:${track.audioMime};base64,${track.audioData}`} className="compose-audio" />}
|
||||
<button className="compose-track-del" onClick={() => setTracks(prev => prev.filter((_, j) => j !== i))}>X</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="compose-status-bar">
|
||||
<span>{compName} | {tracks.length}p | {tracks.reduce((s, t) => s + t.duration, 0)}s total</span>
|
||||
{status && <span className="compose-status-msg">{status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { VideotexPageHeader } from "./VideotexMosaic";
|
||||
|
||||
interface Track {
|
||||
@@ -43,32 +44,80 @@ const NOISE_TYPES = ["drone", "pink", "white", "brown", "sine"];
|
||||
const STYLES = ["experimental", "ambient", "drone", "noise", "glitch", "industrial", "techno", "minimal", "concrete", "jazz", "classical", "dark", "lo-fi", "post-rock"];
|
||||
const PERSONAS = ["Pharmacius", "Docteur Maboul", "Gargantua", "Nostradamus", "Piaf"];
|
||||
|
||||
function drawWaveform(canvas: HTMLCanvasElement, audioData: string, _audioMime: string, color: string) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
// Decode base64 audio to get raw samples (simplified: use byte visualization from data)
|
||||
const bytes = atob(audioData.slice(0, 2000)); // sample first 2KB
|
||||
const w = canvas.width, h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < w; i++) {
|
||||
const byteIdx = Math.floor((i / w) * bytes.length);
|
||||
const val = (bytes.charCodeAt(byteIdx % bytes.length) / 255) * h;
|
||||
if (i === 0) ctx.moveTo(i, val); else ctx.lineTo(i, val);
|
||||
}
|
||||
ctx.stroke();
|
||||
function toAudioDataUrl(audioData: string, audioMime: string) {
|
||||
return `data:${audioMime};base64,${audioData}`;
|
||||
}
|
||||
|
||||
function WaveformCanvas({ audioData, audioMime, color }: { audioData: string; audioMime: string; color: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
function WaveformPreview({
|
||||
audioData,
|
||||
audioMime,
|
||||
color,
|
||||
className,
|
||||
height,
|
||||
barWidth,
|
||||
}: {
|
||||
audioData: string;
|
||||
audioMime: string;
|
||||
color: string;
|
||||
className: string;
|
||||
height: number;
|
||||
barWidth?: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current && audioData) {
|
||||
drawWaveform(canvasRef.current, audioData, audioMime, color);
|
||||
}
|
||||
}, [audioData, audioMime, color]);
|
||||
return <canvas ref={canvasRef} width={120} height={30} className="cmp-waveform" />;
|
||||
if (!containerRef.current || !audioData) return;
|
||||
|
||||
const wave = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
waveColor: `${color}55`,
|
||||
progressColor: color,
|
||||
cursorColor: "rgba(255,255,255,0.18)",
|
||||
height,
|
||||
normalize: true,
|
||||
interact: false,
|
||||
hideScrollbar: true,
|
||||
dragToSeek: false,
|
||||
barWidth,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
cursorWidth: 0,
|
||||
});
|
||||
|
||||
wave.load(toAudioDataUrl(audioData, audioMime));
|
||||
|
||||
return () => {
|
||||
wave.destroy();
|
||||
};
|
||||
}, [audioData, audioMime, barWidth, color, height]);
|
||||
|
||||
return <div ref={containerRef} className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function TrackWaveform({ audioData, audioMime, color }: { audioData: string; audioMime: string; color: string }) {
|
||||
return (
|
||||
<WaveformPreview
|
||||
audioData={audioData}
|
||||
audioMime={audioMime}
|
||||
color={color}
|
||||
className="cmp-waveform cmp-waveform-preview"
|
||||
height={30}
|
||||
barWidth={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineWaveform({ audioData, audioMime, color }: { audioData: string; audioMime: string; color: string }) {
|
||||
return (
|
||||
<WaveformPreview
|
||||
audioData={audioData}
|
||||
audioMime={audioMime}
|
||||
color={color}
|
||||
className="cmp-tl-waveform"
|
||||
height={18}
|
||||
barWidth={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComposePage() {
|
||||
@@ -457,7 +506,7 @@ export default function ComposePage() {
|
||||
{track.generating ? `${track.genElapsed || 0}s` : "\u25B6"}
|
||||
</button>
|
||||
{track.audioData && track.audioMime && (
|
||||
<WaveformCanvas audioData={track.audioData} audioMime={track.audioMime} color={track.color} />
|
||||
<TrackWaveform audioData={track.audioData} audioMime={track.audioMime} color={track.color} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -518,6 +567,9 @@ export default function ComposePage() {
|
||||
onPointerDown={e => startDrag(e, i)}
|
||||
onContextMenu={e => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, trackIdx: i }); }}
|
||||
>
|
||||
{track.audioData && track.audioMime && (
|
||||
<TimelineWaveform audioData={track.audioData} audioMime={track.audioMime} color={track.color} />
|
||||
)}
|
||||
<span className="cmp-tl-text">{track.prompt?.slice(0, 20) || track.label}</span>
|
||||
<span className="cmp-tl-dur">{track.duration}s</span>
|
||||
{/* Resize handle */}
|
||||
|
||||
+29
-3
@@ -3258,7 +3258,19 @@ code {
|
||||
}
|
||||
.cmp-gen-btn:hover { filter: brightness(1.2); }
|
||||
.cmp-gen-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.cmp-waveform { width: 120px; height: 30px; border-radius: 3px; background: rgba(0,0,0,0.3); }
|
||||
.cmp-waveform {
|
||||
width: 120px;
|
||||
height: 30px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.34), rgba(255,255,255,0.03));
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cmp-waveform-preview { flex-shrink: 0; }
|
||||
.cmp-waveform ::part(wrapper),
|
||||
.cmp-tl-waveform ::part(wrapper) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cmp-track-timeline {
|
||||
padding: 4px 8px 6px;
|
||||
@@ -3357,15 +3369,29 @@ code {
|
||||
transition: box-shadow 0.15s;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cmp-tl-block:hover { box-shadow: 0 1px 4px rgba(0,0,0,0.4); border-color: rgba(255,255,255,0.2); }
|
||||
.cmp-tl-dragging { cursor: grabbing; opacity: 0.7 !important; box-shadow: 0 2px 8px rgba(0,0,0,0.6); z-index: 5; }
|
||||
.cmp-tl-text { font-size: 8px; color: rgba(255,255,255,0.8); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.cmp-tl-dur { font-size: 7px; color: rgba(255,255,255,0.5); flex-shrink: 0; margin-left: 4px; }
|
||||
.cmp-tl-waveform {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cmp-tl-text,
|
||||
.cmp-tl-dur {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.7);
|
||||
}
|
||||
.cmp-tl-text { font-size: 8px; color: rgba(255,255,255,0.88); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.cmp-tl-dur { font-size: 7px; color: rgba(255,255,255,0.7); flex-shrink: 0; margin-left: 4px; }
|
||||
.cmp-tl-resize {
|
||||
position: absolute;
|
||||
right: 0; top: 0; width: 5px; height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
.cmp-tl-resize:hover { background: rgba(255,255,255,0.15); }
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+41
-3
@@ -256,12 +256,50 @@ stateDiagram-v2
|
||||
|
||||
### Coordinateur
|
||||
- [x] Audit SOTA 2026 intelligence + affectation agents par module
|
||||
- [x] lot-552: cloture documentaire lot-192 + sync QA/veille OSS
|
||||
|
||||
### Backend API
|
||||
- [x] lot-192: ws-commands modular extraction phases 1-2 (`/comp`, `/layer`, `/mix`, `/voice`, gestion composition)
|
||||
- [ ] lot-192: phase 3 extraction edition avancee (`/concat`, `/silence`, `/template`, `/marker`, `/metronome`, `/delete`, `/suggest`, `/snapshot`, `/randomize`)
|
||||
- [x] lot-192: ws-commands modular extraction phases 1-3 (`/comp`, `/layer`, `/mix`, `/voice`, gestion composition, edition avancee)
|
||||
- [ ] lot-549: renfort tests composition et export
|
||||
## Delta Session 2026-03-24 — phase 3
|
||||
|
||||
### Backend API
|
||||
- [x] lot-192: extraction complete du bloc compose avance (`/concat`, `/silence`, `/template`, `/marker`, `/metronome`, `/delete`, `/suggest`, `/snapshot`, `/randomize`)
|
||||
- [ ] lot-193: renfort tests composition et export
|
||||
- [x] lot-552: cloture documentaire et durcissement final lot-192
|
||||
- [ ] lot-549: renfort tests composition et export
|
||||
- [ ] lot-548: waveform timeline UI v1
|
||||
## Affectations Session 2026-03-24 — suite immediate
|
||||
|
||||
| Lot | Agent pilote | Sous-agents / competences | Portee immediate |
|
||||
|---|---|---|---|
|
||||
| lot-549 | Backend API | Polyglot Test Agent, QA, context-map | Etendre `apps/api/src/ws-commands.test.ts` et `apps/api/src/composition-store.test.ts`, verifier non-regression composition |
|
||||
| lot-548 | Frontend | Expert React Frontend Engineer, gem-browser-tester, context-map | Remplacer la waveform canvas par une timeline waveform plus lisible sans regression mobile |
|
||||
| lot-552 | Coordinateur | Planner/Docs, gem-researcher, refactor-plan | Maintenir PLAN/TODO/AGENTS/QA/OSS alignes avec l'etat reel et tracer les prochains deltas |
|
||||
|
||||
## Skills privilegies
|
||||
|
||||
- context-map: cartographie des fichiers et dependances avant modification.
|
||||
- refactor-plan: sequencing prudent des changements multi-fichiers.
|
||||
- polyglot-test-agent: lot-549 pour la recherche, le plan et l'implementation de tests additionnels.
|
||||
## Delta Session 2026-03-24 — validation compose + waveform
|
||||
|
||||
### Backend API
|
||||
- [x] lot-549: renfort tests composition et export
|
||||
- Validation ciblee: `tsx --test src/composition-store.test.ts src/ws-commands.test.ts` => 44/44 OK
|
||||
|
||||
### Frontend
|
||||
- [x] lot-548: waveform timeline UI v1
|
||||
- Implementation: `wavesurfer.js` branche dans `apps/web/src/components/ComposePage.tsx` pour apercu piste + bloc timeline
|
||||
- Validation: `npm run check` dans `apps/web` OK
|
||||
## Delta Session 2026-03-24 — cleanup compose
|
||||
|
||||
### Frontend
|
||||
- [x] lot-553: suppression du doublon `apps/web/src/components/ComposePage 2.tsx`
|
||||
- Validation: `npm run check` dans `apps/web` OK
|
||||
- Impact: reduction dette technique UI compose et elimination d'un faux point d'entree potentiel
|
||||
## Delta Session 2026-03-24 — QA visuelle compose
|
||||
|
||||
### Frontend
|
||||
- [x] lot-554: validation Playwright ciblee compose timeline
|
||||
- Commande: `npx playwright test e2e/visual-qa.spec.ts --project=chromium --grep "ComposePage|composition timeline"`
|
||||
- Resultat: 2/2 OK
|
||||
@@ -109,3 +109,47 @@
|
||||
| P1 | Prototyper CSS Minitel (Terminal CSS + VIDEOTEX colors + CRT effects) | Lot 16 |
|
||||
| P2 | Tester Ollama web search natif vs SearXNG pipeline | RAG |
|
||||
| P3 | Etudier AstrBot persona system pour inspiration | Personas |
|
||||
|
||||
## Addendum 2026-03-24 -- waveform, tests composition, orchestration agents
|
||||
|
||||
### Waveform / timeline UI
|
||||
|
||||
- waveform-playlist
|
||||
- Usage cible: lot-548.
|
||||
- Interet: vraie timeline multi-pistes, edition clips, waveform et transport plus proches d'un mini-DAW que notre canvas actuel.
|
||||
- Licence: MIT.
|
||||
- Recommandation: candidat principal si on accepte un changement UI plus structurant.
|
||||
|
||||
- wavesurfer.js
|
||||
- Usage cible: lot-548.
|
||||
- Interet: integration plus chirurgicale si on veut conserver la structure actuelle de ComposePage et remplacer seulement le rendu waveform/ruler/regions.
|
||||
- Licence: BSD-3-Clause.
|
||||
- Recommandation: meilleur plan B a faible delta.
|
||||
|
||||
### Tests composition
|
||||
|
||||
- fast-check
|
||||
- Usage cible: lot-549.
|
||||
- Interet: tests de proprietes sur les invariants du timeline store (offsets, durees, trims, serialisation).
|
||||
- Licence: MIT.
|
||||
- Recommandation: excellent levier pour durcir composition-store au-dela des seuls cas scenario.
|
||||
|
||||
- MSW
|
||||
- Usage cible: lot-549.
|
||||
- Interet: unifier les mocks HTTP front pour compose/export/suggest sans stub fragile de fetch.
|
||||
- Licence: MIT.
|
||||
- Recommandation: a introduire si les tests frontend composition deviennent prioritaires au lot suivant.
|
||||
|
||||
### Orchestration agents / continuite operationnelle
|
||||
|
||||
- XState
|
||||
- Usage cible: orchestration Compose + suivi des lots.
|
||||
- Interet: formaliser les workflows multi-etats (generation, mix, export, erreur, reprise) et documenter les transitions.
|
||||
- Licence: MIT.
|
||||
- Recommandation: meilleur candidat court terme pour rendre les flux agents/tests/docs plus explicites sans surdimensionner l'architecture.
|
||||
|
||||
- LangGraph.js
|
||||
- Usage cible: orchestration agents stateful plus tardive.
|
||||
- Interet: pertinent si le projet fait evoluer la coordination documentaire vers un vrai runtime de graphe agentique persistant.
|
||||
- Licence: MIT.
|
||||
- Recommandation: garder en reserve, trop lourd pour le prochain lot immediat.
|
||||
@@ -0,0 +1,86 @@
|
||||
# QA Webdesign 2026-03-24
|
||||
|
||||
## Portee
|
||||
|
||||
- Audit visuel et graphique du frontend public sur la cible de production.
|
||||
- Validation du rendu desktop, mobile, theming, lisibilite, composants medias et timeline composition.
|
||||
- Verification des regressions apres corrections CSS et commande /theme.
|
||||
|
||||
## Methode
|
||||
|
||||
- Suite Playwright visuelle dediee: 36 tests automatises.
|
||||
- Captures d'ecran systematiques sur ecrans clefs et breakpoints mobiles.
|
||||
- Relecture manuelle des captures pour validation du rendu, du contraste et de la densite UI.
|
||||
|
||||
## Couverture
|
||||
|
||||
- Connexion et ecran d'accueil.
|
||||
- Chat principal et messages medias.
|
||||
- Themes minitel, noir, matrix, amber, ocean, dark, light.
|
||||
- Navigation Compose, DAW, Voice, pages principales.
|
||||
- Responsive jusqu'a 320 px.
|
||||
- Accessibilite graphique basique: contraste, debordements, zones tactiles.
|
||||
|
||||
## Resultats
|
||||
|
||||
- 36 tests passes.
|
||||
- 0 echec.
|
||||
- 30 captures d'ecran generees et verifiees.
|
||||
- Rendu general valide sur desktop et mobile.
|
||||
|
||||
## Defauts identifies puis corriges
|
||||
|
||||
1. Commande /theme incomplete
|
||||
|
||||
Les themes dark et light existaient en CSS mais n'etaient pas exposes par le handler de commande cote API.
|
||||
|
||||
Impact:
|
||||
Le theme demande par l'utilisateur etait refuse silencieusement, ce qui biaisait la validation visuelle.
|
||||
|
||||
Statut:
|
||||
Corrige.
|
||||
|
||||
2. Barre fkeys trop compressee a 320 px
|
||||
|
||||
Ajout d'un breakpoint specifique <= 340 px avec defilement horizontal et protection contre le shrink excessif.
|
||||
|
||||
Impact:
|
||||
Lisibilite faible sur tres petit viewport.
|
||||
|
||||
Statut:
|
||||
Corrige.
|
||||
|
||||
## Observations de rendu
|
||||
|
||||
- L'identite CRT/Minitel reste forte et coherente.
|
||||
- Le chat principal reste lisible a 375 px.
|
||||
- La timeline composition est exploitable mais la waveform reste encore simplifiee par canvas.
|
||||
- La page DAW presente deja une bonne hierarchie visuelle pour preparer lot-548.
|
||||
|
||||
## Validation technique associee
|
||||
|
||||
- Verification TypeScript API sans erreur apres correctifs.
|
||||
- Verification des captures sur themes clairs et sombres.
|
||||
- Confirmation visuelle que le fond n'est plus bloque sur un theme unique.
|
||||
|
||||
## Conclusion
|
||||
|
||||
- La QA webdesign est validee pour la production courante.
|
||||
- Les correctifs de theming et de responsive ultra-compact sont integres.
|
||||
- Le prochain chantier prioritaire cote rendu reste lot-548: waveform timeline UI v1.
|
||||
## Addendum 2026-03-24 — Compose timeline ciblé
|
||||
|
||||
### Scope
|
||||
- Re-validation visuelle post-lot-548/lot-553 sur la page Compose et la timeline multi-pistes.
|
||||
|
||||
### Commande
|
||||
- `npx playwright test e2e/visual-qa.spec.ts --project=chromium --grep "ComposePage|composition timeline"`
|
||||
|
||||
### Resultat
|
||||
- 2 tests executes, 2 passes.
|
||||
- Captures regenerees:
|
||||
- `10-compose-page.png`
|
||||
- `18-composition-timeline.png`
|
||||
|
||||
### Conclusion
|
||||
- Aucun regressif visuel detecte sur le rendu Compose/timeline apres integration wavesurfer et suppression du doublon ComposePage.
|
||||
Reference in New Issue
Block a user