From fbda80064764397bceddbd14df3abd372eb0fdef Mon Sep 17 00:00:00 2001 From: DavidClaudeAI Date: Fri, 12 Jun 2026 11:01:42 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat=20core=20:=205=20presets=20de=20r?= =?UTF-8?q?=C3=A9alisation=20+=20curseur=20r=C3=A9p=C3=A9tition-max=20+=20?= =?UTF-8?q?bump=200.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTEXTE : analyse du corpus Flowspire-Lab -> réalisation graduée en 5 styles sur l'axe rythme (Very Chill..Very Fast) + nouveau curseur "répétition max" (variété forcée, opt-in). Détail : Flowspire-Lab/docs/presets-analyse.md. FICHIERS CLÉS : core/rhythm_style.{hpp,cpp}, core/director.{hpp,cpp}, core/config.{hpp,cpp}, tests/test_core.cpp, config.example.jsonc, buildspec.json ZONES À RISQUE : cœur director + défauts d'usine ; atténué (opt-in off par défaut, rétrocompat testée, 96 tests verts /WX, 5 styles auto dans dock/assistant) COMPAT : none (plugin solo ; profils/presets perso préservés) REF : todo-flowspire "5 presets + curseur répétition max" --- buildspec.json | 2 +- config.example.jsonc | 10 +- src/core/config.cpp | 10 +- src/core/config.hpp | 15 ++- src/core/director.cpp | 61 ++++++++++- src/core/director.hpp | 28 +++++ src/core/rhythm_style.cpp | 27 +++-- src/core/rhythm_style.hpp | 18 ++-- tests/test_core.cpp | 211 ++++++++++++++++++++++++++++++++------ 9 files changed, 329 insertions(+), 53 deletions(-) diff --git a/buildspec.json b/buildspec.json index 4828236..9bb1624 100644 --- a/buildspec.json +++ b/buildspec.json @@ -38,7 +38,7 @@ }, "name": "flowspire", "displayName": "Flowspire", - "version": "0.6.1", + "version": "0.7.0", "author": "David", "website": "https://github.com/DavidClaudeAI/Flowspire", "email": "zouarid@gmail.com" diff --git a/config.example.jsonc b/config.example.jsonc index 162c605..68089fa 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -67,10 +67,18 @@ "timing": { "minShotSeconds": 5.0, // temps mini sur un plan (anti-nervosité) "maxShotSeconds": 12.0, // au-delà : on rafraîchit le plan (variété) - "pingPongWindowSeconds": 0.0 // "retour au plan large sur échange rapide" : 0 = désactivé + "pingPongWindowSeconds": 0.0, // "retour au plan large sur échange rapide" : 0 = désactivé // (opt-in). Sur une navette A↔B rapide (chacun seul à son // tour), on se recule sur le plan large le temps que ça // respire. N'agit QUE si un plan large existe. + "silenceReactionSeconds": 1.0, // grâce de silence : délai avant de réagir à un blanc (on garde + // le plan courant), évite de basculer sur une simple respiration. + // 0 = réaction immédiate. + "maxPlanRepeats": 0 // répétition max d'un MÊME plan avant respiration. 0 = désactivé + // (opt-in). Au-delà de N : re-tirage pondéré dans le pool de la + // personne PRIVÉ de ce plan (autre cam / réaction / plan large, + // selon les poids). Compte PAR SCÈNE, jamais par intervenant. Les + // styles livrés le règlent (Very Fast 1 … Very Chill 7). }, // --- Quand PLUSIEURS parlent en même temps (tirage pondéré) --- diff --git a/src/core/config.cpp b/src/core/config.cpp index 2295e8f..01ff81e 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -44,7 +44,8 @@ std::string toJson(const Config& cfg) { {{"minShotSeconds", cfg.timing.minShotSeconds}, {"maxShotSeconds", cfg.timing.maxShotSeconds}, {"pingPongWindowSeconds", cfg.timing.pingPongWindowSeconds}, - {"silenceReactionSeconds", cfg.timing.silenceReactionSeconds}}}, + {"silenceReactionSeconds", cfg.timing.silenceReactionSeconds}, + {"maxPlanRepeats", cfg.timing.maxPlanRepeats}}}, {"whenMultiple", {{"currentSpeaker", cfg.whenMultiple.currentSpeaker}, {"wideShot", cfg.whenMultiple.wideShot}}}, {"whenSilence", {{"lastSpeaker", cfg.whenSilence.lastSpeaker}, {"wideShot", cfg.whenSilence.wideShot}}}, @@ -103,6 +104,9 @@ Config fromJson(const std::string& text) { cfg.timing.maxShotSeconds = t.value("maxShotSeconds", cfg.timing.maxShotSeconds); cfg.timing.pingPongWindowSeconds = t.value("pingPongWindowSeconds", cfg.timing.pingPongWindowSeconds); cfg.timing.silenceReactionSeconds = t.value("silenceReactionSeconds", cfg.timing.silenceReactionSeconds); + // Cle absente (profil anterieur a la feature) -> defaut 0 = desactive (retrocompat : rendu + // inchange). Compte par scene, jamais par intervenant (cf. config.hpp). + cfg.timing.maxPlanRepeats = t.value("maxPlanRepeats", cfg.timing.maxPlanRepeats); } if (j.contains("whenMultiple")) { @@ -141,6 +145,10 @@ Config fromJson(const std::string& text) { if (!std::isfinite(cfg.timing.silenceReactionSeconds) || cfg.timing.silenceReactionSeconds < 0.0) { cfg.timing.silenceReactionSeconds = 0.0; } + // Repetition max : une valeur negative (JSON edite a la main) n'a pas de sens -> 0 (desactive). + if (cfg.timing.maxPlanRepeats < 0) { + cfg.timing.maxPlanRepeats = 0; + } return cfg; } diff --git a/src/core/config.hpp b/src/core/config.hpp index c3474c5..0833719 100644 --- a/src/core/config.hpp +++ b/src/core/config.hpp @@ -41,7 +41,7 @@ struct AudioSettings { // Reglages de rythme (secondes). struct TimingSettings { double minShotSeconds = 3.0; // verrou anti-nervosite - double maxShotSeconds = 8.0; // rafraichissement du plan (= style Cool ; affine en reel 2026-06-07) + double maxShotSeconds = 10.0; // rafraichissement du plan (= style Cool ; recale sur corpus 2026-06-12) // Anti ping-pong : DESACTIVE par defaut (0 = opt-in). Feature subtile, a valider // en live avant d'activer par defaut. Pour qu'elle agisse, la regler AU-DESSUS du // temps mini (spec : 12 s). Les profils existants gardent leur valeur enregistree. @@ -53,8 +53,17 @@ struct TimingSettings { // l'a jamais quitte (reprise instantanee). 0 = reaction immediate (comportement // historique). N'affecte QUE le silence : un nouveau locuteur bascule normalement. // DISTINCT du "delai de silence" (audio.releaseFrames = a partir de quand une personne - // est consideree silencieuse, detection par personne). Defaut tune en reel ("Cyp Live"). - double silenceReactionSeconds = 1.5; + // est consideree silencieuse, detection par personne). Defaut recale a 1.0 sur corpus (2026-06-12) : + // la grace de silence s'est revelee idiosyncratique (sans lien au rythme) -> une valeur commune. + double silenceReactionSeconds = 1.0; + // Repetition max d'un MEME plan (cadrage) avant respiration (re-tirage PONDERE dans le pool prive + // de ce plan : autre camera / plan de reaction / plan large s'il y figure). 0 = + // DESACTIVE (opt-in, comme l'anti ping-pong) : un profil neuf, un profil ou un preset perso + // anterieur a la feature garde un comportement INCHANGE (aucune respiration imposee). Compte + // PAR SCENE affichee, jamais par intervenant : deux cameras d'une meme personne ont chacune + // leur compteur, qui repart de zero des qu'on change de cadrage (modele A). Les styles livres + // portent leur valeur (Very Fast 1 .. Very Chill 7). Effet en secondes ~ maxShotSeconds * maxPlanRepeats. + int maxPlanRepeats = 0; }; // Contexte B : plusieurs parlent en meme temps (poids relatifs : rester sur le plan courant diff --git a/src/core/director.cpp b/src/core/director.cpp index 3320bbe..e6a2a7b 100644 --- a/src/core/director.cpp +++ b/src/core/director.cpp @@ -28,7 +28,8 @@ void Director::setConfig(const Config& cfg) { cfg_ = cfg; detectors_.clear(); ownerLeftAt_.clear(); // memoire anti ping-pong : repart de zero a chaque (re)config - silenceSince_ = -1.0; // grace de silence : on repart "pas en silence" a chaque (re)config + silenceSince_ = -1.0; // grace de silence : on repart "pas en silence" a chaque (re)config + planRepeatCount_ = 0; // repetition-max : aucun plan encore tenu // On repart de zero puis on SEME les overrides depuis la config : le seuil // par intervenant (Speaker.thresholdDb) est desormais persiste dans le profil, // donc la config/JSON reste la source de verite au chargement. Un intervenant @@ -96,6 +97,43 @@ bool Director::isPingPongBounce(double now, const std::string& owner) const { return (now - it->second) < cfg_.timing.pingPongWindowSeconds; } +bool Director::needsRepetitionBreather(const std::string& candidate) const { + return cfg_.timing.maxPlanRepeats > 0 // feature active (opt-in) + && candidate == currentScene_ // on rejouerait EXACTEMENT le meme plan + && currentScene_ != cfg_.wideShotScene // le plan large lui-meme n'est pas "martele" + && planRepeatCount_ >= cfg_.timing.maxPlanRepeats; // deja tenu N fenetres temps-max +} + +bool Director::resolveBreather(const std::string& owner, const std::string& avoid, std::string& outScene, + std::string& outOwner) { + // 1) Re-tirage PONDERE dans le pool de l'intervenant, PRIVE du plan sur-repete : les poids sont + // recalcules sur ce qui reste (autre camera, plan de reaction, plan large s'il y figure...). + if (const Speaker* sp = findSpeaker(owner)) { + std::vector pool; + pool.reserve(sp->scenes.size()); + for (const auto& sw : sp->scenes) { + if (sw.scene != avoid && !sw.scene.empty() && sw.weight > 0) { + pool.push_back(sw); + } + } + const std::string drawn = drawSceneFromPool(pool); + if (!drawn.empty()) { + outScene = drawn; + // Invariant owner vide <=> plan large : si le tirage tombe sur le plan large global (il + // peut figurer dans un pool), on garde owner vide, comme sceneInProgram. + outOwner = (drawn == cfg_.wideShotScene) ? std::string{} : owner; + return true; + } + } + // 2) Repli universel : le plan large GLOBAL, s'il existe et n'est pas le plan sur-repete. + if (!cfg_.wideShotScene.empty() && cfg_.wideShotScene != avoid) { + outScene = cfg_.wideShotScene; + outOwner.clear(); + return true; + } + return false; // rien d'autre a montrer -> l'appelant garde le plan courant (degradation gracieuse) +} + Decision Director::update(double now, const std::map& levelsDb) { // 1) Alimenter les detecteurs et collecter les parlants (tries par dB desc). std::vector> speaking; @@ -283,7 +321,18 @@ Decision Director::update(double now, const std::map& level if ((now - lastSwitch_) >= cfg_.timing.maxShotSeconds) { std::string scene, owner; if (resolvePlayable(desiredOwner, desiredWide, scene, owner)) { - commit(now, scene, owner, hold_, out); + // Repetition-max : si ce rafraichissement rejouerait une fois de trop le MEME plan d'un + // locuteur qui monologue, on RESPIRE -> re-tirage PONDERE dans son pool prive de ce plan + // (autre camera, plan de reaction, plan large s'il y figure ; repli plan large global + // sinon). Si une respiration est jouable on la prend, sinon on garde le plan (degradation + // gracieuse). Limite au contexte Single : multi/silence ont deja leur variete par re-tirage. + std::string bScene, bOwner; + if (ctx == Context::Single && needsRepetitionBreather(scene) && + resolveBreather(desiredOwner, currentScene_, bScene, bOwner)) { + commit(now, bScene, bOwner, /*hold=*/true, out); + } else { + commit(now, scene, owner, hold_, out); + } } } return out; @@ -374,6 +423,14 @@ void Director::commit(double now, const std::string& scene, const std::string& o if (recordLeave && !currentOwner_.empty() && !owner.empty() && currentOwner_ != owner) { ownerLeftAt_[currentOwner_] = now; } + // Repetition-max (variete forcee) : +1 si on RECOMMIT le meme plan, sinon on repart a 1. Compte + // par scene affichee (modele A) : changer de cadrage -- meme vers une autre camera de la meme + // personne -- remet le compteur a zero. Calcule AVANT d'ecraser currentScene_. + if (!currentScene_.empty() && scene == currentScene_) { + ++planRepeatCount_; + } else { + planRepeatCount_ = 1; + } out.switched = (scene != currentScene_); currentScene_ = scene; currentOwner_ = owner; diff --git a/src/core/director.hpp b/src/core/director.hpp index 49242c0..216f19c 100644 --- a/src/core/director.hpp +++ b/src/core/director.hpp @@ -25,6 +25,16 @@ // sur un plan qu'on vient de quitter (< fenetre pingPongWindowSeconds), on se RECULE // sur le plan large le temps que ca respire, puis on repart. N'agit QUE si un plan // large existe (sinon le temps-mini gere). Fenetre a 0 => off. +// +// Variete forcee (repetition-max) : pour eviter de marteler un meme cadrage quand une personne +// monologue, on borne le nombre de fenetres temps-max consecutives passees sur le MEME plan +// (cfg.timing.maxPlanRepeats). Au-dela, on RESPIRE : re-tirage PONDERE dans le pool de l'intervenant +// prive du plan sur-repete (autre camera, plan de reaction, plan large s'il y figure ; repli sur le +// plan large global sinon) -> ce n'est PAS une regle rigide "va au plan large", les poids decident. +// Compte PAR SCENE affichee (modele A) : deux cameras d'une meme personne ont chacune leur compteur, +// remis a zero des qu'on change de cadrage -> leur alternance suffit deja a aerer. Opt-in (0 = off, +// retrocompat) et limite au contexte Single (mono-locuteur) ; multi/silence ont deja leur variete par +// re-tirage. Sans aucune alternative jouable (pool d'une seule scene, pas de plan large) : on reste. #pragma once #include @@ -118,6 +128,17 @@ class Director { // moins de pingPongWindowSeconds. Toujours faux si la fenetre vaut 0 (anti // ping-pong desactive) ou si owner est vide. bool isPingPongBounce(double now, const std::string& owner) const; + // Repetition-max : a-t-on atteint le cap de repetition sur `candidate` (le plan qu'on rejouerait) ? + // Vrai si la feature est active (maxPlanRepeats > 0), que `candidate` est le MEME plan que l'actuel + // (et que ce n'est pas le plan large) et qu'on l'a deja tenu maxPlanRepeats fenetres temps-max. Pur ; + // ne dit PAS s'il existe une respiration jouable -> c'est resolveBreather qui tranche. + bool needsRepetitionBreather(const std::string& candidate) const; + // Repetition-max : choisit un plan de RESPIRATION pour `owner` en EXCLUANT le plan sur-repete + // `avoid`, par TIRAGE PONDERE dans le pool de l'intervenant prive de `avoid` (autre camera, plan de + // reaction, plan large s'il y figure...). Repli sur le plan large GLOBAL si le pool n'offre rien + // d'autre. Renvoie false si aucune respiration n'est jouable (l'appelant garde alors le plan). + bool resolveBreather(const std::string& owner, const std::string& avoid, std::string& outScene, + std::string& outOwner); Config cfg_; Rng rng_; @@ -147,6 +168,13 @@ class Director { // Anti ping-pong : instant ou l'on a QUITTE chaque proprietaire de plan. Sert a // detecter une bascule "retour" trop rapide (navette) -> on prefere alors rester. std::map ownerLeftAt_; + + // Repetition-max ("variete forcee") : nombre de fenetres temps-max consecutives durant + // lesquelles le MEME plan (currentScene_) est reste a l'antenne. Incremente quand un + // rafraichissement rejoue la meme scene, remis a 1 des qu'on change de cadrage (modele A : + // par scene, jamais par intervenant). Au-dela de cfg.timing.maxPlanRepeats, on respire sur le + // plan large (cf. shouldBreatheForRepetition). 0 tant qu'aucun plan n'a ete affiche. + int planRepeatCount_ = 0; }; } // namespace sd::core diff --git a/src/core/rhythm_style.cpp b/src/core/rhythm_style.cpp index e51d1e4..d11600f 100644 --- a/src/core/rhythm_style.cpp +++ b/src/core/rhythm_style.cpp @@ -10,14 +10,21 @@ namespace sd::core { using nlohmann::json; std::vector builtinRhythmStyles() { - // {nom, mini, maxi, grace silence, anti ping-pong, whenMultiple{rester,large}, whenSilence{dernier,large}} - // Valeurs affinees en reel (2026-06-07). La tendance plan large fait partie du temperament : - // Chill/Cool privilegient FORTEMENT le groupe (10/94) ; Speed reste plus serre sur l'orateur - // (40/60) pour suivre l'action. Cool == les defauts d'usine (cf. config.hpp, garde le couplage). + // {nom, mini, maxi, grace silence, anti ping-pong, repetition max, whenMultiple{rester,large}, + // whenSilence{dernier,large}}. Mini/maxi/repetition derives du corpus Flowspire-Lab (57 episodes, + // 2026-06-12) ; grace COMMUNE a 1 s (mesuree idiosyncratique, sans lien au rythme). Le "temps tenu + // sur une personne" vient du DECOUPLAGE maxi x repetition (plans courts + retours), pas d'un plan fige. + // Anti ping-pong : arme seulement sur les 2 rapides (valeur de depart, a figer au test live). + // ⚠️ POIDS PLAN LARGE = baseline heritee ("Cyp Live"), volontairement NON tunee pour les 5 crans : + // la data %large suggere l'INVERSE (poses peu de large, rapides beaucoup) -> a departager EN LIVE, + // separement de cette PR (on ne flippe pas la politique large en meme temps que le rythme). + // Cool == les defauts d'usine (cf. config.hpp) SAUF la repetition-max (opt-in : defaut d'usine 0). return { - {"Chill", 5.0, 12.0, 2.0, 0.0, {10, 94}, {10, 94}}, // pose : plans longs, plan large tres genereux - {"Cool", 3.0, 8.0, 1.5, 0.0, {10, 94}, {10, 94}}, // equilibre = defauts d'usine - {"Speed", 2.0, 4.0, 1.0, 3.0, {40, 60}, {25, 75}}, // vif : reste plus sur l'orateur ; anti ping-pong arme + {"Very Chill", 5.0, 15.0, 1.0, 0.0, 7, {10, 94}, {10, 94}}, // tres pose : plans longs, retours rares + {"Chill", 3.5, 13.0, 1.0, 0.0, 5, {10, 94}, {10, 94}}, // pose + {"Cool", 3.0, 10.0, 1.0, 0.0, 4, {10, 94}, {10, 94}}, // equilibre = defauts d'usine (hors repetition) + {"Fast", 2.0, 7.0, 1.0, 4.0, 2, {25, 75}, {20, 80}}, // vif : retours frequents ; anti ping-pong arme + {"Very Fast", 1.5, 4.0, 1.0, 3.0, 1, {40, 60}, {25, 75}}, // nerveux : 1 plan par prise ; reste sur l'orateur }; } @@ -26,6 +33,7 @@ void applyRhythmStyle(Config& cfg, const RhythmStyle& style) { cfg.timing.maxShotSeconds = style.maxShotSeconds; cfg.timing.silenceReactionSeconds = style.silenceReactionSeconds; cfg.timing.pingPongWindowSeconds = style.pingPongWindowSeconds; + cfg.timing.maxPlanRepeats = style.maxPlanRepeats; cfg.whenMultiple = style.whenMultiple; cfg.whenSilence = style.whenSilence; cfg.styleName = style.name; @@ -38,6 +46,7 @@ RhythmStyle styleFromConfig(const Config& cfg, const std::string& name) { s.maxShotSeconds = cfg.timing.maxShotSeconds; s.silenceReactionSeconds = cfg.timing.silenceReactionSeconds; s.pingPongWindowSeconds = cfg.timing.pingPongWindowSeconds; + s.maxPlanRepeats = cfg.timing.maxPlanRepeats; s.whenMultiple = cfg.whenMultiple; s.whenSilence = cfg.whenSilence; return s; @@ -52,6 +61,7 @@ std::string rhythmStyleLibraryToJson(const std::vector& styles) { {"maxShotSeconds", s.maxShotSeconds}, {"silenceReactionSeconds", s.silenceReactionSeconds}, {"pingPongWindowSeconds", s.pingPongWindowSeconds}, + {"maxPlanRepeats", s.maxPlanRepeats}, {"whenMultiple", {{"currentSpeaker", s.whenMultiple.currentSpeaker}, {"wideShot", s.whenMultiple.wideShot}}}, {"whenSilence", {{"lastSpeaker", s.whenSilence.lastSpeaker}, {"wideShot", s.whenSilence.wideShot}}}, @@ -75,6 +85,9 @@ std::vector rhythmStyleLibraryFromJson(const std::string& text) { s.maxShotSeconds = js.value("maxShotSeconds", s.maxShotSeconds); s.silenceReactionSeconds = js.value("silenceReactionSeconds", s.silenceReactionSeconds); s.pingPongWindowSeconds = js.value("pingPongWindowSeconds", s.pingPongWindowSeconds); + // Cle absente (preset perso d'avant la feature) -> defaut 0 = desactive : le preset + // garde EXACTEMENT son comportement, aucune respiration imposee dans le dos. + s.maxPlanRepeats = js.value("maxPlanRepeats", s.maxPlanRepeats); if (js.contains("whenMultiple")) { const auto& m = js.at("whenMultiple"); s.whenMultiple.currentSpeaker = m.value("currentSpeaker", s.whenMultiple.currentSpeaker); diff --git a/src/core/rhythm_style.hpp b/src/core/rhythm_style.hpp index 7673585..38e873b 100644 --- a/src/core/rhythm_style.hpp +++ b/src/core/rhythm_style.hpp @@ -22,22 +22,26 @@ namespace sd::core { -// Un style = un nom + les 4 parametres de rythme. Les defauts repliquent ceux de -// TimingSettings (un RhythmStyle vide == config livree par defaut). +// Un style = un nom + les 5 parametres de rythme. Les defauts repliquent EXACTEMENT ceux de +// TimingSettings (un RhythmStyle vide == config livree par defaut) -> filet coherent quand une cle +// manque dans un preset perso (cf. rhythmStyleLibraryFromJson). struct RhythmStyle { std::string name; // identifiant ET libelle affiche (nom propre, non traduit) double minShotSeconds = 3.0; // verrou anti-nervosite - double maxShotSeconds = 6.0; // rafraichissement du plan - double silenceReactionSeconds = 1.5; // grace de silence + double maxShotSeconds = 10.0; // rafraichissement du plan (= defaut TimingSettings) + double silenceReactionSeconds = 1.0; // grace de silence (= defaut TimingSettings) double pingPongWindowSeconds = 0.0; // 0 = anti ping-pong (retour au plan large) desactive + int maxPlanRepeats = 0; // 0 = desactive (opt-in) ; sinon N repetitions max d'un meme plan MultiWeights whenMultiple; // contexte B (2+ parlent) : { rester / plan large } SilenceWeights whenSilence; // contexte C (silence) : { dernier locuteur / plan large } }; -// Les 3 styles LIVRES, en lecture seule, du plus pose au plus vif : -// Chill (plans longs), Cool (= defauts "Cyp Live"), Speed (coupe nette + anti ping-pong). +// Les 5 styles LIVRES, en lecture seule, du plus pose au plus vif : Very Chill, Chill, Cool, +// Fast, Very Fast. Gradues sur l'axe RYTHME a partir du corpus Flowspire-Lab (2026-06-12) ; +// chacun porte aussi sa repetition-max (Very Chill 7 .. Very Fast 1). Cool == les defauts d'usine +// (cf. config.hpp) SAUF la repetition-max (opt-in : Cool porte 4, le defaut d'usine reste 0). // L'utilisateur ne les modifie pas : il part de l'un d'eux, ajuste, puis enregistre sa -// variante dans la bibliotheque globale (etape ulterieure). +// variante dans la bibliotheque globale. std::vector builtinRhythmStyles(); // Applique un style a une config : copie le TEMPO (cfg.timing : 4 params) ET la tendance diff --git a/tests/test_core.cpp b/tests/test_core.cpp index df80512..3e81f29 100644 --- a/tests/test_core.cpp +++ b/tests/test_core.cpp @@ -125,6 +125,7 @@ TEST_CASE("config : round-trip JSON complet (tous les champs)") { in.version = 7; in.audio = {-28.0, -55.0, 3, 11}; // voiceThr, volFloor, attack, release in.timing = {2.5, 9.0, 7.0, 4.0}; // min, max, pingPong, silenceReaction + in.timing.maxPlanRepeats = 6; // 5e champ timing (detecter une cle de serialisation oubliee) in.whenMultiple = {40, 35}; // {rester, plan large} in.whenSilence = {70, 30}; const Config out = fromJson(toJson(in)); @@ -145,6 +146,7 @@ TEST_CASE("config : round-trip JSON complet (tous les champs)") { CHECK(out.timing.maxShotSeconds == doctest::Approx(9.0)); CHECK(out.timing.pingPongWindowSeconds == doctest::Approx(7.0)); CHECK(out.timing.silenceReactionSeconds == doctest::Approx(4.0)); + CHECK(out.timing.maxPlanRepeats == 6); CHECK(out.whenMultiple.currentSpeaker == 40); CHECK(out.whenMultiple.wideShot == 35); @@ -157,6 +159,7 @@ TEST_CASE("config : JSON tolerant aux cles absentes") { CHECK(c.speakers.empty()); CHECK(c.audio.voiceThresholdDb == doctest::Approx(-35.0)); // defaut CHECK(c.whenMultiple.wideShot == 94); // defaut (= Cool, affine 2026-06-07) + CHECK(c.timing.maxPlanRepeats == 0); // repetition-max opt-in : desactivee par defaut } TEST_CASE("config : retrocompat - ancienne cle whenMultiple.loudestSpeaker ignoree sans erreur") { @@ -343,11 +346,12 @@ TEST_CASE("director : anti ping-pong NE se declenche PAS sur la pause d'un orate TEST_CASE("config : un style applique (tempo + poids plan large) survit a un aller-retour JSON") { Config c = twoSpeakerConfig(); - applyRhythmStyle(c, builtinRhythmStyles()[2]); // Speed {40,60}/{25,75}, pingPong 3, mini 2 + applyRhythmStyle(c, builtinRhythmStyles()[4]); // Very Fast {40,60}/{25,75}, pingPong 3, mini 1.5, repet 1 const Config back = fromJson(toJson(c)); - CHECK(back.styleName == "Speed"); - CHECK(back.timing.minShotSeconds == doctest::Approx(2.0)); + CHECK(back.styleName == "Very Fast"); + CHECK(back.timing.minShotSeconds == doctest::Approx(1.5)); CHECK(back.timing.pingPongWindowSeconds == doctest::Approx(3.0)); + CHECK(back.timing.maxPlanRepeats == 1); CHECK(back.whenMultiple.currentSpeaker == 40); CHECK(back.whenMultiple.wideShot == 60); CHECK(back.whenSilence.lastSpeaker == 25); @@ -356,7 +360,7 @@ TEST_CASE("config : un style applique (tempo + poids plan large) survit a un all TEST_CASE("rhythm style : bibliotheque globale - round-trip JSON + tolerance") { Config speedCfg; - applyRhythmStyle(speedCfg, builtinRhythmStyles()[2]); // Speed + applyRhythmStyle(speedCfg, builtinRhythmStyles()[4]); // Very Fast std::vector lib; lib.push_back(styleFromConfig(speedCfg, "Mon debat")); // capture du reglage courant RhythmStyle posed; @@ -370,8 +374,9 @@ TEST_CASE("rhythm style : bibliotheque globale - round-trip JSON + tolerance") { const auto round = rhythmStyleLibraryFromJson(rhythmStyleLibraryToJson(lib)); REQUIRE(round.size() == 2); CHECK(round[0].name == "Mon debat"); - CHECK(round[0].whenMultiple.currentSpeaker == 40); // hérité de Speed + CHECK(round[0].whenMultiple.currentSpeaker == 40); // herite de Very Fast CHECK(round[0].pingPongWindowSeconds == doctest::Approx(3.0)); + CHECK(round[0].maxPlanRepeats == 1); // repetition-max capturee dans le preset perso CHECK(round[1].name == "Talk pose"); CHECK(round[1].maxShotSeconds == doctest::Approx(14.0)); CHECK(round[1].whenSilence.wideShot == 100); @@ -1149,6 +1154,132 @@ TEST_CASE("director : anti ping-pong relache APRES la fenetre -> on repart sur l CHECK(dir.update(11.1, {{"A", hi}, {"B", kDbFloor}}).owner == "A"); } +// --- Repetition-max ("variete forcee" : cap de repetition d'un meme plan) ------- +namespace { +// Config mono-locuteur dediee aux tests de repetition-max. `scenes` = pool de l'unique +// intervenant "A" ; `repeats` = cfg.timing.maxPlanRepeats ; plan large "Plateau" sauf si vide. +Config repeatConfig(std::vector scenes, int repeats, const std::string& wide = "Plateau") { + Config c; + c.wideShotScene = wide; + c.timing.minShotSeconds = 1.0; + c.timing.maxShotSeconds = 5.0; + c.timing.silenceReactionSeconds = 0.0; + c.timing.maxPlanRepeats = repeats; + c.audio.attackFrames = 1; // A "parle" des le 1er frame -> pas d'ecran large de demarrage + c.audio.releaseFrames = 8; + Speaker a; + a.id = "A"; + a.name = "A"; + a.audioSource = "sA"; + a.scenes = std::move(scenes); + c.speakers = {a}; + return c; +} +} // namespace + +TEST_CASE("director : repetition-max — un monologue mono-camera respire sur le plan large apres N plans") { + // maxPlanRepeats=2, temps-max 5 s : le meme cadrage tient 2 fenetres (10 s) puis on impose une + // courte respiration sur le plan large, avant de revenir sur la personne. + Director dir(repeatConfig({{"A_close", 100}}, 2), seq({0.0})); + const double hi = mulToDb(0.9); + double breatheAt = -1.0, t = 0.0; + for (int i = 0; i < 400 && breatheAt < 0.0; ++i) { + Decision d = dir.update(t, {{"A", hi}}); // A monologue en continu + if (d.scene == "Plateau") { + breatheAt = t; + } + t += 0.1; + } + REQUIRE(breatheAt > 0.0); // la respiration a bien eu lieu + CHECK(breatheAt > 9.0); // pas avant ~2 fenetres de 5 s + CHECK(breatheAt < 13.0); + // Respiration COURTE (de l'ordre du temps-mini) puis retour sur le locuteur. + double backAt = -1.0; + for (int i = 0; i < 80 && backAt < 0.0; ++i) { + Decision d = dir.update(t, {{"A", hi}}); + if (d.scene == "A_close") { + backAt = t; + } + t += 0.1; + } + REQUIRE(backAt > 0.0); + CHECK(backAt - breatheAt < 3.0); +} + +TEST_CASE("director : repetition-max a 0 (opt-in off) ne respire JAMAIS — retrocompat") { + // Defaut/retrocompat : 0 = desactive. Un monologue, meme tres long, ne bascule jamais au plan + // large de force (comportement identique a avant la feature). + Director dir(repeatConfig({{"A_close", 100}}, 0), seq({0.0})); + const double hi = mulToDb(0.9); + bool sawWide = false; + double t = 0.0; + for (int i = 0; i < 400; ++i) { + if (dir.update(t, {{"A", hi}}).scene == "Plateau") { + sawWide = true; + } + t += 0.1; + } + CHECK_FALSE(sawWide); + CHECK(dir.currentScene() == "A_close"); +} + +TEST_CASE("director : repetition-max — modele A : 2 cameras qui alternent ne forcent PAS de respiration") { + // Clarification David : le compteur est PAR SCENE, jamais par intervenant. Deux cameras d'une + // meme personne qui alternent remettent le compteur a zero a chaque changement de cadrage -> + // leur alternance EST deja la variete, aucune respiration imposee (la ou un mono-camera respirerait). + // RNG alterne le tirage du pool (cam1 / cam2) a chaque rafraichissement. + Director dir(repeatConfig({{"A_cam1", 50}, {"A_cam2", 50}}, 2), seq({0.0, 0.99})); + const double hi = mulToDb(0.9); + bool sawWide = false, sawCam1 = false, sawCam2 = false; + double t = 0.0; + for (int i = 0; i < 400; ++i) { + const std::string s = dir.update(t, {{"A", hi}}).scene; + sawWide = sawWide || (s == "Plateau"); + sawCam1 = sawCam1 || (s == "A_cam1"); + sawCam2 = sawCam2 || (s == "A_cam2"); + t += 0.1; + } + CHECK_FALSE(sawWide); // l'alternance des cameras suffit : pas de respiration forcee + CHECK(sawCam1); // les deux cameras tournent bien + CHECK(sawCam2); +} + +TEST_CASE("director : repetition-max sans plan large ne peut pas respirer (degradation gracieuse)") { + // Pas de plan large -> nulle part ou se reculer : on reste sur la personne (meme principe que + // l'anti ping-pong sans plan large). La contrainte ne peut tout simplement pas s'appliquer. + Director dir(repeatConfig({{"A_close", 100}}, 2, /*wide=*/""), seq({0.0})); + const double hi = mulToDb(0.9); + double t = 0.0; + for (int i = 0; i < 400; ++i) { + dir.update(t, {{"A", hi}}); + t += 0.1; + } + CHECK(dir.currentScene() == "A_close"); +} + +TEST_CASE("director : repetition-max — la respiration re-tire dans le pool (reaction/large pondere), pas " + "forcement le plan large") { + // David : la respiration recalcule les POIDS et renvoie vers ce qui doit etre montre. Si le pool de + // l'intervenant contient une scene de respiration (reaction d'un autre, autre camera...), la + // respiration y va selon les poids -- elle ne saute PAS d'office au plan large global. + // Pool A = A_close(90) + B_react(10) ; plan large "Plateau" dispo mais HORS pool. maxPlanRepeats=1. + Director dir(repeatConfig({{"A_close", 90}, {"B_react", 10}}, 1), seq({0.0})); + const double hi = mulToDb(0.9); + std::string firstBreather; + bool sawForcedWide = false; + double t = 0.0; + for (int i = 0; i < 200; ++i) { + const std::string s = dir.update(t, {{"A", hi}}).scene; // A monologue + if (firstBreather.empty() && !s.empty() && s != "A_close") { + firstBreather = s; + } + sawForcedWide = sawForcedWide || (s == "Plateau"); + t += 0.1; + } + CHECK(firstBreather == "B_react"); // la respiration va vers la scene du pool, pas le plan large + CHECK_FALSE(sawForcedWide); // le plan large global n'est PAS force tant qu'une alternative existe +} + // --- Versions semantiques (systeme de mise a jour) ------------------------------ TEST_CASE("version : parseSemVer accepte X.Y.Z (prefixe 'v' et espaces toleres)") { @@ -1196,58 +1327,75 @@ TEST_CASE("version : isNewerVersion ne notifie que pour une version stable super } // --- Styles de realisation (presets de rythme) ----------------------------- -TEST_CASE("rhythm style : les 3 built-ins, ordre et valeurs") { +TEST_CASE("rhythm style : les 5 built-ins, ordre et valeurs") { const auto styles = builtinRhythmStyles(); - REQUIRE(styles.size() == 3); - CHECK(styles[0].name == "Chill"); - CHECK(styles[1].name == "Cool"); - CHECK(styles[2].name == "Speed"); - - // "Cool" reprend EXACTEMENT les defauts livres -> un Config par defaut == style Cool. + REQUIRE(styles.size() == 5); + CHECK(styles[0].name == "Very Chill"); + CHECK(styles[1].name == "Chill"); + CHECK(styles[2].name == "Cool"); + CHECK(styles[3].name == "Fast"); + CHECK(styles[4].name == "Very Fast"); + + // "Cool" reprend les defauts livres POUR LE TEMPO -> un Config par defaut == tempo de Cool. const Config def; - const RhythmStyle& cool = styles[1]; + const RhythmStyle& cool = styles[2]; CHECK(cool.minShotSeconds == doctest::Approx(def.timing.minShotSeconds)); CHECK(cool.maxShotSeconds == doctest::Approx(def.timing.maxShotSeconds)); CHECK(cool.silenceReactionSeconds == doctest::Approx(def.timing.silenceReactionSeconds)); CHECK(cool.pingPongWindowSeconds == doctest::Approx(def.timing.pingPongWindowSeconds)); + // EXCEPTION opt-in : la repetition-max n'est PAS couplee aux defauts. Le defaut d'usine reste 0 + // (desactive, retrocompat) alors que Cool porte 4 -> on l'active en choisissant un style livre. + CHECK(def.timing.maxPlanRepeats == 0); + CHECK(cool.maxPlanRepeats == 4); - // Speed est le seul a armer l'anti ping-pong ; Chill/Cool le laissent a 0. + // Seuls les 2 rapides arment l'anti ping-pong ; les 3 poses le laissent a 0. CHECK(styles[0].pingPongWindowSeconds == doctest::Approx(0.0)); - CHECK(styles[2].pingPongWindowSeconds > 0.0); - // Du plus pose au plus vif : temps maxi strictement decroissant. - CHECK(styles[0].maxShotSeconds > styles[1].maxShotSeconds); - CHECK(styles[1].maxShotSeconds > styles[2].maxShotSeconds); + CHECK(styles[1].pingPongWindowSeconds == doctest::Approx(0.0)); + CHECK(styles[2].pingPongWindowSeconds == doctest::Approx(0.0)); + CHECK(styles[3].pingPongWindowSeconds > 0.0); + CHECK(styles[4].pingPongWindowSeconds > 0.0); + + // Du plus pose au plus vif : temps maxi ET repetition-max strictement DECROISSANTS (un style + // pose tolere de longs retours sur le meme plan ; un nerveux force la variete). + for (size_t i = 1; i < styles.size(); ++i) { + CHECK(styles[i].maxShotSeconds < styles[i - 1].maxShotSeconds); + CHECK(styles[i].maxPlanRepeats < styles[i - 1].maxPlanRepeats); + } + + // Grace de silence COMMUNE a 1 s sur tous (mesuree idiosyncratique -> non graduee). + for (const auto& s : styles) { + CHECK(s.silenceReactionSeconds == doctest::Approx(1.0)); + } // Invariants de TOUT built-in : maxi >= mini (sinon fromJson remonterait le maxi et le - // plan affiche divergerait du preset) et tous les delais >= 0. Garde-fou si un futur - // edit des valeurs casse un de ces invariants. + // plan affiche divergerait du preset) et tous les delais/poids/repetition >= 0. for (const auto& s : styles) { CHECK(s.maxShotSeconds >= s.minShotSeconds); CHECK(s.minShotSeconds >= 0.0); CHECK(s.silenceReactionSeconds >= 0.0); CHECK(s.pingPongWindowSeconds >= 0.0); + CHECK(s.maxPlanRepeats >= 0); CHECK(s.whenMultiple.currentSpeaker >= 0); CHECK(s.whenMultiple.wideShot >= 0); CHECK(s.whenSilence.lastSpeaker >= 0); CHECK(s.whenSilence.wideShot >= 0); } - // La politique plan large fait partie du temperament : Speed reste bien plus "serre" - // quand 2+ parlent (moins de plan large, plus "rester") que Cool. - CHECK(styles[2].whenMultiple.wideShot < styles[1].whenMultiple.wideShot); // Speed < Cool en large - CHECK(styles[2].whenMultiple.currentSpeaker > - styles[1].whenMultiple.currentSpeaker); // Speed reste + sur le locuteur + // Politique plan large = BASELINE heritee (a departager au test live ; la data suggere l'inverse). + // On verifie seulement sa coherence : Very Fast reste plus "serre" que Cool quand 2+ parlent. + CHECK(styles[4].whenMultiple.wideShot < styles[2].whenMultiple.wideShot); + CHECK(styles[4].whenMultiple.currentSpeaker > styles[2].whenMultiple.currentSpeaker); } TEST_CASE("rhythm style : appliquer un style a 0 DESARME l'anti ping-pong (pas seulement laisse)") { Config c; c.timing.pingPongWindowSeconds = 12.0; // anti ping-pong arme au prealable - const RhythmStyle chill = builtinRhythmStyles()[0]; - REQUIRE(chill.pingPongWindowSeconds == doctest::Approx(0.0)); - applyRhythmStyle(c, chill); + const RhythmStyle posed = builtinRhythmStyles()[0]; // Very Chill (pose, anti ping-pong a 0) + REQUIRE(posed.pingPongWindowSeconds == doctest::Approx(0.0)); + applyRhythmStyle(c, posed); // La valeur DOIT etre ecrasee a 0 (et pas conservee) : un style "calme" coupe bien // l'anti ping-pong herite d'un style precedent. CHECK(c.timing.pingPongWindowSeconds == doctest::Approx(0.0)); - CHECK(c.styleName == "Chill"); + CHECK(c.styleName == "Very Chill"); } TEST_CASE("rhythm style : applyRhythmStyle copie le tempo ET la politique plan large, marque le style") { @@ -1260,15 +1408,16 @@ TEST_CASE("rhythm style : applyRhythmStyle copie le tempo ET la politique plan l c.whenMultiple = {20, 70}; c.whenSilence = {30, 70}; - const RhythmStyle speed = builtinRhythmStyles()[2]; + const RhythmStyle speed = builtinRhythmStyles()[4]; // Very Fast (ex-"Speed") applyRhythmStyle(c, speed); - CHECK(c.styleName == "Speed"); + CHECK(c.styleName == "Very Fast"); // Tempo copie. CHECK(c.timing.minShotSeconds == doctest::Approx(speed.minShotSeconds)); CHECK(c.timing.maxShotSeconds == doctest::Approx(speed.maxShotSeconds)); CHECK(c.timing.silenceReactionSeconds == doctest::Approx(speed.silenceReactionSeconds)); CHECK(c.timing.pingPongWindowSeconds == doctest::Approx(speed.pingPongWindowSeconds)); + CHECK(c.timing.maxPlanRepeats == speed.maxPlanRepeats); // repetition-max copiee aussi // Politique plan large copiee (fait partie du temperament depuis le recentrage). CHECK(c.whenMultiple.currentSpeaker == speed.whenMultiple.currentSpeaker); CHECK(c.whenMultiple.wideShot == speed.whenMultiple.wideShot); From 4992f098bab4f45f82a7c553798f6c8217fb8f5e Mon Sep 17 00:00:00 2001 From: DavidClaudeAI Date: Fri, 12 Jun 2026 11:40:58 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat=20ui=20:=20curseur=20r=C3=A9p=C3=A9tit?= =?UTF-8?q?ion-max=20+=20r=C3=A9cap=20assistant=20+=20fix=20d=C3=A9bordeme?= =?UTF-8?q?nt=20pastilles=20(FlowLayout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTEXTE : exposer le curseur répétition-max dans le panneau de rythme partagé (réglages avancés + assistant) + corriger le débordement des 5 pastilles de style (retour live David). FICHIERS CLÉS : ui/sd_config_panels.cpp, ui/sd_assistant.cpp, ui/sd_widgets.hpp (FlowLayout), ui/sd_dock.cpp, data/locale/{en-US,fr-FR}.ini ZONES À RISQUE : faible - UI seule, aucune logique réalisation touchée ; FlowLayout = pattern standard Qt ; build /WX 0 warning ; cœur inchangé (96 tests verts) COMPAT : none (plugin solo) REF : todo-flowspire "5 presets + curseur répétition max" (partie UI) --- data/locale/en-US.ini | 6 +++ data/locale/fr-FR.ini | 6 +++ src/ui/sd_assistant.cpp | 4 ++ src/ui/sd_config_panels.cpp | 66 +++++++++++++++++++++++++++----- src/ui/sd_dock.cpp | 4 +- src/ui/sd_widgets.hpp | 76 +++++++++++++++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 11 deletions(-) diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index a03c91d..45ff432 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -131,6 +131,10 @@ Rhythm.PingPong="Anti ping-pong (memory)" Rhythm.PingPongOff="Disabled" Rhythm.SilenceReaction="Silence reaction delay" Rhythm.SilenceReactionImmediate="Immediate" +Rhythm.MaxRepeats="Max repeats of a shot" +Rhythm.MaxRepeatsOff="Disabled" +Rhythm.MaxRepeatsValue="×%1" +Rhythm.MaxRepeatsHint="≈ %1 s on one person before a breather" Rhythm.AudioSection="AUDIO SENSITIVITY (ADVANCED)" Rhythm.Threshold="Voice threshold" Rhythm.Attack="Attack delay" @@ -152,6 +156,7 @@ Tip.Wide.WideShot="Chance to switch to the wide shot." Tip.Wide.LastSpeaker="Chance to stay on the last person who spoke." Tip.Rhythm.MinShot="Each shot stays on screen at least this long before a change is allowed (avoids jumpy cuts)." Tip.Rhythm.MaxShot="After this long on the same shot, Flowspire refreshes the view to add variety." +Tip.Rhythm.MaxRepeats="Beyond this many passes on the SAME shot, Flowspire breathes: it redraws another of the person's shots (other camera, reaction, wide) by weight. Counts per shot, not per person. 0 = disabled." Tip.Rhythm.PingPong="Avoids cutting back too quickly to someone who just spoke. 0 = disabled; only takes effect above the minimum shot time." Tip.Rhythm.Threshold="Sound level above which a person counts as speaking. Lower = more sensitive." Tip.Rhythm.Attack="How long of continuous voice before a person counts as speaking (ignores brief noises: a click, a knock). Shorter = more reactive." @@ -174,6 +179,7 @@ Summary.WideScene="Scene" Summary.WideSceneNone="(no wide shot)" Summary.Style="Style" Summary.Tempo="Shot length: %1–%2 s" +Summary.Repeats="Max repeats: %1" Summary.Error.NoSpeaker="Add at least one speaker with an audio source." Summary.Error.Save="Save failed: %1" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 618add4..1bb945d 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -130,6 +130,10 @@ Rhythm.PingPong="Anti ping-pong (mémoire)" Rhythm.PingPongOff="Désactivé" Rhythm.SilenceReaction="Délai avant réaction au silence" Rhythm.SilenceReactionImmediate="Immédiat" +Rhythm.MaxRepeats="Répétition max d'un plan" +Rhythm.MaxRepeatsOff="Désactivée" +Rhythm.MaxRepeatsValue="×%1" +Rhythm.MaxRepeatsHint="≈ %1 s sur une personne avant respiration" Rhythm.AudioSection="SENSIBILITÉ AUDIO (AVANCÉ)" Rhythm.Threshold="Seuil de voix" Rhythm.Attack="Délai d'attaque" @@ -151,6 +155,7 @@ Tip.Wide.WideShot="Chance de basculer sur le plan large." Tip.Wide.LastSpeaker="Chance de rester sur la dernière personne ayant parlé." Tip.Rhythm.MinShot="Chaque plan reste affiché au moins ce temps avant qu'un changement soit permis (évite les coupes nerveuses)." Tip.Rhythm.MaxShot="Au-delà de ce temps sur un même plan, Flowspire rafraîchit la vue pour varier les plans." +Tip.Rhythm.MaxRepeats="Au-delà de ce nombre de passages sur un MÊME plan, Flowspire respire : il re-tire un autre plan de la personne (autre caméra, réaction, plan large) selon les poids. Compte par plan, pas par personne. 0 = désactivé." Tip.Rhythm.PingPong="Évite de revenir trop vite sur une personne qui vient juste de parler. 0 = désactivé ; n'agit qu'au-dessus du temps mini de plan." Tip.Rhythm.Threshold="Niveau sonore à partir duquel une personne est considérée comme parlant. Plus bas = plus sensible." Tip.Rhythm.Attack="Durée de voix continue avant de considérer qu'une personne parle (ignore les bruits brefs : clic, choc). Plus court = plus réactif." @@ -173,6 +178,7 @@ Summary.WideScene="Scène" Summary.WideSceneNone="(aucun plan large)" Summary.Style="Style" Summary.Tempo="Durée d'un plan : %1–%2 s" +Summary.Repeats="Répétition max : %1" Summary.Error.NoSpeaker="Ajoutez au moins un intervenant avec une source audio." Summary.Error.Save="Échec de l'enregistrement : %1" diff --git a/src/ui/sd_assistant.cpp b/src/ui/sd_assistant.cpp index 1dafe60..b7578f0 100644 --- a/src/ui/sd_assistant.cpp +++ b/src/ui/sd_assistant.cpp @@ -452,6 +452,10 @@ void SdAssistant::Impl::populateSummary() { realiLines << i18n("Summary.Tempo") .arg(static_cast(std::lround(working.timing.minShotSeconds))) .arg(static_cast(std::lround(working.timing.maxShotSeconds))); + realiLines << i18n("Summary.Repeats") + .arg(working.timing.maxPlanRepeats > 0 + ? i18n("Rhythm.MaxRepeatsValue").arg(working.timing.maxPlanRepeats) + : i18n("Rhythm.MaxRepeatsOff")); contentLay[5]->addWidget(recapCard(Icon::Clock, i18n("Summary.Realisation"), 3, realiLines)); summaryError = new QLabel(); diff --git a/src/ui/sd_config_panels.cpp b/src/ui/sd_config_panels.cpp index 22280cb..c6a0baa 100644 --- a/src/ui/sd_config_panels.cpp +++ b/src/ui/sd_config_panels.cpp @@ -385,6 +385,8 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { auto maxRowPtr = std::make_shared(nullptr); auto ppRowPtr = std::make_shared(nullptr); auto silRowPtr = std::make_shared(nullptr); + auto repRowPtr = std::make_shared(nullptr); // curseur repetition-max + auto repHintPtr = std::make_shared(nullptr); // sous-texte "≈ N s sur une personne" // Holders des 4 curseurs de "politique plan large" (eux aussi pilotes par le style, avec // badges %). Construits dans les DEUX dispositions (la politique plan large est partout). auto mCurRowPtr = std::make_shared(nullptr); @@ -411,8 +413,23 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { (*sWideRowPtr)->setBadge(pctOf((*sWideRowPtr)->value(), sum)); } }; + // Sous-texte du curseur repetition-max : "≈ (temps maxi x repetition) s sur une personne avant + // respiration". Recalcule a chaque changement du temps-maxi OU de la repetition (les deux jouent + // sur le produit). Cache quand la repetition vaut 0 (desactivee) -> pas de duree trompeuse. + // Garde par holders -> no-op tant que les widgets n'existent pas. + auto updateRepHint = [maxRowPtr, repRowPtr, repHintPtr]() { + if (!*repHintPtr) { + return; + } + const int reps = *repRowPtr ? (*repRowPtr)->value() : 0; + const int maxS = *maxRowPtr ? (*maxRowPtr)->value() : 0; + (*repHintPtr)->setVisible(reps > 0); + if (reps > 0) { + (*repHintPtr)->setText(i18n("Rhythm.MaxRepeatsHint").arg(reps * maxS)); + } + }; - // === Selecteur de style de realisation (Chill / Cool / Speed / Perso) ============ + // === Selecteur de style de realisation (styles livres + Perso) ============ // Un style = un bundle nomme de parametres de RYTHME (cf. core/rhythm_style). Le // choisir fait glisser les 4 curseurs ci-dessous ; toucher un curseur repasse en // "Perso". `applying` neutralise ce basculement pendant l'application programmee. @@ -438,7 +455,7 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { } }; // Repeint l'etat du selecteur selon le style actif (cfg_.styleName) : - // - pastille livree (Chill/Cool/Speed) allumee si elle correspond ; + // - pastille d'un style livre allumee si elle correspond ; // - "Perso" allumee si AUCUN style (livre OU perso) n'est actif ; // - le menu "Mes styles" pointe sur le style perso actif (sinon "—") ; // - la ligne renommer/supprimer n'apparait que sur un style perso. @@ -465,8 +482,9 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { }; // Applique un style : copie ses valeurs (coeur) PUIS fait glisser les curseurs. Le // garde `applying` empeche ces setValue de repasser le style en "Perso". - auto onPick = [this, applying, refresh, minRowPtr, maxRowPtr, ppRowPtr, silRowPtr, mCurRowPtr, mWideRowPtr, - sLastRowPtr, sWideRowPtr, recomputeMulti, recomputeSil](const sd::core::RhythmStyle& st) { + auto onPick = [this, applying, refresh, minRowPtr, maxRowPtr, ppRowPtr, silRowPtr, repRowPtr, mCurRowPtr, + mWideRowPtr, sLastRowPtr, sWideRowPtr, recomputeMulti, recomputeSil, + updateRepHint](const sd::core::RhythmStyle& st) { *applying = true; sd::core::applyRhythmStyle(cfg_, st); if (*minRowPtr) { @@ -481,6 +499,9 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { if (*silRowPtr) { (*silRowPtr)->setValue(static_cast(std::lround(st.silenceReactionSeconds * 2.0))); // demi-secondes } + if (*repRowPtr) { + (*repRowPtr)->setValue(st.maxPlanRepeats); + } // Politique plan large (presente dans les deux dispositions ; holders toujours peuples). if (*mCurRowPtr) { (*mCurRowPtr)->setValue(st.whenMultiple.currentSpeaker); @@ -497,6 +518,7 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { *applying = false; recomputeMulti(); recomputeSil(); + updateRepHint(); refresh(); }; @@ -506,9 +528,13 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { slay->setSpacing(10); slay->addWidget(makeGroupHeader(i18n("Rhythm.StyleSection"))); auto* chipRow = new QWidget(); - auto* chipLay = new QHBoxLayout(chipRow); - chipLay->setContentsMargins(0, 0, 0, 0); - chipLay->setSpacing(8); + // FlowLayout : les pastilles s'enroulent sur plusieurs lignes si la fenetre est etroite (5 + // styles + "Perso" ne tiennent plus sur une seule ligne) -> plus de debordement horizontal. + // Size policy heightForWidth : le QVBoxLayout parent reserve la hauteur des lignes enroulees. + QSizePolicy chipPol = chipRow->sizePolicy(); + chipPol.setHeightForWidth(true); + chipRow->setSizePolicy(chipPol); + auto* chipLay = new FlowLayout(chipRow, 0, 8, 8); // Styles livres : noms propres affiches tels quels (non traduits, comme "Flowspire"). for (const auto& st : sd::core::builtinRhythmStyles()) { auto* chip = new ClickButton(); @@ -522,7 +548,6 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { persoChip->setCursor(Qt::ArrowCursor); chips->push_back({std::string{}, persoChip}); chipLay->addWidget(persoChip); - chipLay->addStretch(); slay->addWidget(chipRow); // === "Mes styles" : selection d'un style perso enregistre (menu deroulant) === @@ -690,11 +715,12 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { }); auto* maxR = new SliderRow(i18n("Rhythm.MaxShot"), 0, 60, static_cast(std::lround(cfg_.timing.maxShotSeconds)), fmtSeconds, false); - maxR->setOnChange([this, minRowPtr, applying, refresh](int v) { + maxR->setOnChange([this, minRowPtr, applying, refresh, updateRepHint](int v) { cfg_.timing.maxShotSeconds = v; if (*minRowPtr && (*minRowPtr)->value() > v) { (*minRowPtr)->setValue(v); // redescend le min au niveau du max } + updateRepHint(); // le produit temps-maxi x repetition change -> sous-texte recalcule if (!*applying) { cfg_.styleName.clear(); refresh(); @@ -738,13 +764,35 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { }); silReactR->setInfo(i18n("Tip.Rhythm.SilenceReaction")); + // Repetition-max : nombre de fois max sur un MEME plan avant respiration (variete forcee). ×N + // (0 = desactive). Entier direct (pas de demi-pas). Le sous-texte donne le temps approx tenu sur + // une personne (= temps maxi x repetition) -> recalcule aussi quand on bouge le temps-maxi. + auto* repR = new SliderRow( + i18n("Rhythm.MaxRepeats"), 0, 10, std::max(0, std::min(10, cfg_.timing.maxPlanRepeats)), + [](int v) { return v == 0 ? i18n("Rhythm.MaxRepeatsOff") : i18n("Rhythm.MaxRepeatsValue").arg(v); }, false); + repR->setOnChange([this, applying, refresh, updateRepHint](int v) { + cfg_.timing.maxPlanRepeats = v; + updateRepHint(); + if (!*applying) { + cfg_.styleName.clear(); + refresh(); + } + }); + repR->setInfo(i18n("Tip.Rhythm.MaxRepeats")); + auto* repHint = makeHint(QString()); // texte pose par updateRepHint (cache si repetition = 0) + *minRowPtr = minR; *maxRowPtr = maxR; *ppRowPtr = ppR; *silRowPtr = silReactR; + *repRowPtr = repR; + *repHintPtr = repHint; + updateRepHint(); // etat initial du sous-texte tlay->addWidget(minR); tlay->addWidget(maxR); + tlay->addWidget(repR); + tlay->addWidget(repHint); tlay->addWidget(ppR); tlay->addWidget(silReactR); advTarget->addWidget(timing); diff --git a/src/ui/sd_dock.cpp b/src/ui/sd_dock.cpp index 4b712ec..5db8c4e 100644 --- a/src/ui/sd_dock.cpp +++ b/src/ui/sd_dock.cpp @@ -318,7 +318,7 @@ SdDock::SdDock(QWidget* parent) : QWidget(parent) { // --- Selecteur de STYLE de realisation (Etape 4) : "Realisation : [ style v ]" --- // Change le style EN DIRECT (applique + sauve le profil actif + recharge le moteur). - // Menu groupe : styles FOURNIS (Chill/Cool/Speed) + separateur "Mes styles" + persos. + // Menu groupe : styles FOURNIS (livres) + separateur "Mes styles" + persos. // Masque en etat d'accueil (pas de style a regler sans config). Calque le selecteur de profil. styleBar_ = new QWidget(); auto* styleBarLay = new QHBoxLayout(styleBar_); @@ -1331,7 +1331,7 @@ void SdDock::showStyleMenu() { .arg(rgba(th::kAccent, 0.25))); menu.setMinimumWidth(styleButton_->width()); const std::string active = activeConfig_.styleName; - // Styles FOURNIS (Chill/Cool/Speed) en haut. + // Styles FOURNIS (livres) en haut. for (const auto& b : sd::core::builtinRhythmStyles()) { QAction* a = menu.addAction(QString::fromStdString(b.name)); a->setCheckable(true); diff --git a/src/ui/sd_widgets.hpp b/src/ui/sd_widgets.hpp index 0338871..20c29b1 100644 --- a/src/ui/sd_widgets.hpp +++ b/src/ui/sd_widgets.hpp @@ -17,7 +17,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -201,6 +203,80 @@ inline QWidget* withInfo(QWidget* field, const QString& tip) { return row; } +// =========================================================================== +// FlowLayout — dispose les enfants horizontalement et passe a la LIGNE quand la largeur +// disponible est atteinte (style "chips / tags"). Sert a ce qu'une rangee dont le nombre +// d'elements peut grandir (ex. les pastilles de style : 5 styles + "Perso") n'impose pas une +// largeur mini trop grande et ne deborde pas une fenetre etroite : elle s'enroule sur plusieurs +// lignes. Adaptation du FlowLayout canonique de Qt (hauteur calculee en fonction de la largeur). +// Pas de Q_OBJECT (aucun signal custom) -> header-only sans moc, comme les autres widgets ici. +// =========================================================================== +class FlowLayout : public QLayout { +public: + explicit FlowLayout(QWidget* parent = nullptr, int margin = 0, int hSpacing = 8, int vSpacing = 8) + : QLayout(parent), hSpace_(hSpacing), vSpace_(vSpacing) { + setContentsMargins(margin, margin, margin, margin); + } + ~FlowLayout() override { + QLayoutItem* item = nullptr; + while ((item = takeAt(0)) != nullptr) { + delete item; + } + } + + void addItem(QLayoutItem* item) override { items_.append(item); } + int count() const override { return static_cast(items_.size()); } + QLayoutItem* itemAt(int index) const override { return items_.value(index); } + QLayoutItem* takeAt(int index) override { + return (index >= 0 && index < items_.size()) ? items_.takeAt(index) : nullptr; + } + Qt::Orientations expandingDirections() const override { return {}; } + bool hasHeightForWidth() const override { return true; } + int heightForWidth(int width) const override { return doLayout(QRect(0, 0, width, 0), true); } + void setGeometry(const QRect& rect) override { + QLayout::setGeometry(rect); + doLayout(rect, false); + } + QSize sizeHint() const override { return minimumSize(); } + QSize minimumSize() const override { + QSize s; + for (QLayoutItem* item : items_) { + s = s.expandedTo(item->minimumSize()); + } + const QMargins m = contentsMargins(); + return s + QSize(m.left() + m.right(), m.top() + m.bottom()); + } + +private: + int doLayout(const QRect& rect, bool testOnly) const { + const QMargins m = contentsMargins(); + const QRect eff = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom()); + int x = eff.x(); + int y = eff.y(); + int lineHeight = 0; + for (QLayoutItem* item : items_) { + const QSize hint = item->sizeHint(); + int nextX = x + hint.width() + hSpace_; + if (nextX - hSpace_ > eff.right() + 1 && lineHeight > 0) { + x = eff.x(); + y += lineHeight + vSpace_; + nextX = x + hint.width() + hSpace_; + lineHeight = 0; + } + if (!testOnly) { + item->setGeometry(QRect(QPoint(x, y), hint)); + } + x = nextX; + lineHeight = qMax(lineHeight, hint.height()); + } + return y + lineHeight - rect.y() + m.bottom(); + } + + QList items_; + int hSpace_; + int vSpace_; +}; + // =========================================================================== // ClickButton — bouton cliquable custom : icone (optionnelle) + texte CENTRES via // un layout interne. On n'utilise PAS QPushButton::setIcon : sur un bouton LARGE, From b90cc97ceb82410a1da9b394e656f77f5af214e7 Mon Sep 17 00:00:00 2001 From: DavidClaudeAI Date: Fri, 12 Jun 2026 11:54:27 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix=20ci=20:=20clang-format=20conforme=20+?= =?UTF-8?q?=20r=C3=A9f=20commentaire=20obsol=C3=A8te=20(post=20code-review?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTEXTE : le check CI clang-format échouait (nouveau code non formaté). Les 3 builds OS passaient. Formatage 19.1.5 appliqué + réf morte "shouldBreatheForRepetition" (renommée en needsRepetitionBreather/resolveBreather) corrigée, trouvée au /code-review. FICHIERS CLÉS : core/{config.hpp,director.{hpp,cpp},rhythm_style.cpp}, ui/sd_widgets.hpp, tests/test_core.cpp ZONES À RISQUE : nulle - reformatage + 1 commentaire ; 96 tests verts, dry-run clang-format OK COMPAT : none REF : PR #38 --- src/core/config.hpp | 2 +- src/core/director.cpp | 6 +++--- src/core/director.hpp | 2 +- src/core/rhythm_style.cpp | 2 +- src/ui/sd_widgets.hpp | 4 +++- tests/test_core.cpp | 4 ++-- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/core/config.hpp b/src/core/config.hpp index 0833719..7b2e464 100644 --- a/src/core/config.hpp +++ b/src/core/config.hpp @@ -40,7 +40,7 @@ struct AudioSettings { // Reglages de rythme (secondes). struct TimingSettings { - double minShotSeconds = 3.0; // verrou anti-nervosite + double minShotSeconds = 3.0; // verrou anti-nervosite double maxShotSeconds = 10.0; // rafraichissement du plan (= style Cool ; recale sur corpus 2026-06-12) // Anti ping-pong : DESACTIVE par defaut (0 = opt-in). Feature subtile, a valider // en live avant d'activer par defaut. Pour qu'elle agisse, la regler AU-DESSUS du diff --git a/src/core/director.cpp b/src/core/director.cpp index e6a2a7b..8fa0152 100644 --- a/src/core/director.cpp +++ b/src/core/director.cpp @@ -28,8 +28,8 @@ void Director::setConfig(const Config& cfg) { cfg_ = cfg; detectors_.clear(); ownerLeftAt_.clear(); // memoire anti ping-pong : repart de zero a chaque (re)config - silenceSince_ = -1.0; // grace de silence : on repart "pas en silence" a chaque (re)config - planRepeatCount_ = 0; // repetition-max : aucun plan encore tenu + silenceSince_ = -1.0; // grace de silence : on repart "pas en silence" a chaque (re)config + planRepeatCount_ = 0; // repetition-max : aucun plan encore tenu // On repart de zero puis on SEME les overrides depuis la config : le seuil // par intervenant (Speaker.thresholdDb) est desormais persiste dans le profil, // donc la config/JSON reste la source de verite au chargement. Un intervenant @@ -98,7 +98,7 @@ bool Director::isPingPongBounce(double now, const std::string& owner) const { } bool Director::needsRepetitionBreather(const std::string& candidate) const { - return cfg_.timing.maxPlanRepeats > 0 // feature active (opt-in) + return cfg_.timing.maxPlanRepeats > 0 // feature active (opt-in) && candidate == currentScene_ // on rejouerait EXACTEMENT le meme plan && currentScene_ != cfg_.wideShotScene // le plan large lui-meme n'est pas "martele" && planRepeatCount_ >= cfg_.timing.maxPlanRepeats; // deja tenu N fenetres temps-max diff --git a/src/core/director.hpp b/src/core/director.hpp index 216f19c..336dbf0 100644 --- a/src/core/director.hpp +++ b/src/core/director.hpp @@ -173,7 +173,7 @@ class Director { // lesquelles le MEME plan (currentScene_) est reste a l'antenne. Incremente quand un // rafraichissement rejoue la meme scene, remis a 1 des qu'on change de cadrage (modele A : // par scene, jamais par intervenant). Au-dela de cfg.timing.maxPlanRepeats, on respire sur le - // plan large (cf. shouldBreatheForRepetition). 0 tant qu'aucun plan n'a ete affiche. + // plan large (cf. needsRepetitionBreather + resolveBreather). 0 tant qu'aucun plan n'a ete affiche. int planRepeatCount_ = 0; }; diff --git a/src/core/rhythm_style.cpp b/src/core/rhythm_style.cpp index d11600f..cb4902c 100644 --- a/src/core/rhythm_style.cpp +++ b/src/core/rhythm_style.cpp @@ -24,7 +24,7 @@ std::vector builtinRhythmStyles() { {"Chill", 3.5, 13.0, 1.0, 0.0, 5, {10, 94}, {10, 94}}, // pose {"Cool", 3.0, 10.0, 1.0, 0.0, 4, {10, 94}, {10, 94}}, // equilibre = defauts d'usine (hors repetition) {"Fast", 2.0, 7.0, 1.0, 4.0, 2, {25, 75}, {20, 80}}, // vif : retours frequents ; anti ping-pong arme - {"Very Fast", 1.5, 4.0, 1.0, 3.0, 1, {40, 60}, {25, 75}}, // nerveux : 1 plan par prise ; reste sur l'orateur + {"Very Fast", 1.5, 4.0, 1.0, 3.0, 1, {40, 60}, {25, 75}}, // nerveux : 1 plan par prise ; reste sur l'orateur }; } diff --git a/src/ui/sd_widgets.hpp b/src/ui/sd_widgets.hpp index 20c29b1..fc60134 100644 --- a/src/ui/sd_widgets.hpp +++ b/src/ui/sd_widgets.hpp @@ -214,7 +214,9 @@ inline QWidget* withInfo(QWidget* field, const QString& tip) { class FlowLayout : public QLayout { public: explicit FlowLayout(QWidget* parent = nullptr, int margin = 0, int hSpacing = 8, int vSpacing = 8) - : QLayout(parent), hSpace_(hSpacing), vSpace_(vSpacing) { + : QLayout(parent), + hSpace_(hSpacing), + vSpace_(vSpacing) { setContentsMargins(margin, margin, margin, margin); } ~FlowLayout() override { diff --git a/tests/test_core.cpp b/tests/test_core.cpp index 3e81f29..739ac4c 100644 --- a/tests/test_core.cpp +++ b/tests/test_core.cpp @@ -376,7 +376,7 @@ TEST_CASE("rhythm style : bibliotheque globale - round-trip JSON + tolerance") { CHECK(round[0].name == "Mon debat"); CHECK(round[0].whenMultiple.currentSpeaker == 40); // herite de Very Fast CHECK(round[0].pingPongWindowSeconds == doctest::Approx(3.0)); - CHECK(round[0].maxPlanRepeats == 1); // repetition-max capturee dans le preset perso + CHECK(round[0].maxPlanRepeats == 1); // repetition-max capturee dans le preset perso CHECK(round[1].name == "Talk pose"); CHECK(round[1].maxShotSeconds == doctest::Approx(14.0)); CHECK(round[1].whenSilence.wideShot == 100); @@ -1388,7 +1388,7 @@ TEST_CASE("rhythm style : les 5 built-ins, ordre et valeurs") { TEST_CASE("rhythm style : appliquer un style a 0 DESARME l'anti ping-pong (pas seulement laisse)") { Config c; - c.timing.pingPongWindowSeconds = 12.0; // anti ping-pong arme au prealable + c.timing.pingPongWindowSeconds = 12.0; // anti ping-pong arme au prealable const RhythmStyle posed = builtinRhythmStyles()[0]; // Very Chill (pose, anti ping-pong a 0) REQUIRE(posed.pingPongWindowSeconds == doctest::Approx(0.0)); applyRhythmStyle(c, posed); From 1031561ac1582f8fcc22c8fff577cfb17e6fb248 Mon Sep 17 00:00:00 2001 From: DavidClaudeAI Date: Fri, 12 Jun 2026 17:14:59 +0200 Subject: [PATCH 4/5] fix core : invariant owner vide <=> plan large dans resolvePlayable et forceSpeaker CONTEXTE : bug revele par le banc de simulation Flowspire-Bench + code review. Quand le tirage pondere du pool d'un intervenant tombait sur le plan large global (cas reel : large dans le pool), resolvePlayable (branches owner et fallback) et forceSpeaker renvoyaient (plan large, owner=intervenant), violant l'invariant respecte par resolveBreather et sceneInProgram. Consequences : faux armement anti ping-pong (recul au large injustifie), memoisation multi faussee. Correction : normalisation au point de production du couple scene/owner. Effet assume : un large tire d'un pool tient ~temps-mini (respiration) au lieu de temps-max, conforme a la semantique documentee. FICHIERS CLES : src/core/director.cpp ; tests/test_core.cpp (+3 tests, 99 cases / 531 assertions verts, contre-preuve faite sur l'ancien code) ZONES A RISQUE : chemin pool-contient-large uniquement ; configs sans large dans les pools inchangees. REF : todo-flowspire (tuning presets / banc de simulation) --- src/core/director.cpp | 15 +++++++-- tests/test_core.cpp | 76 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/core/director.cpp b/src/core/director.cpp index 8fa0152..459105c 100644 --- a/src/core/director.cpp +++ b/src/core/director.cpp @@ -380,7 +380,11 @@ bool Director::resolvePlayable(const std::string& owner, bool wide, std::string& const std::string scene = drawSceneFromPool(sp->scenes); if (!scene.empty()) { outScene = scene; - outOwner = owner; + // Invariant owner vide <=> plan large : le plan large global peut figurer dans le + // pool d'un intervenant ; s'il est tire, il reste le plan large (owner vide), + // comme resolveBreather et sceneInProgram. Sinon l'anti ping-pong et la + // memoisation raisonneraient sur un "faux proprietaire" du plan large. + outOwner = (scene == cfg_.wideShotScene) ? std::string{} : owner; return true; } } @@ -394,7 +398,9 @@ bool Director::resolvePlayable(const std::string& owner, bool wide, std::string& const std::string scene = drawSceneFromPool(sp->scenes); if (!scene.empty()) { outScene = scene; - outOwner = loud; + // Meme invariant que ci-dessus : un plan large tire dans le pool du plus + // fort n'a pas de proprietaire. + outOwner = (scene == cfg_.wideShotScene) ? std::string{} : loud; return true; } } @@ -462,7 +468,10 @@ Decision Director::forceSpeaker(double now, const std::string& speakerId) { if (scene.empty()) { return out; // pas de scene jouable pour cet intervenant. } - commit(now, scene, speakerId, /*hold=*/true, out, /*recordLeave=*/false); + // Invariant owner vide <=> plan large : si le tirage du pool tombe sur le plan large + // global, on le commite SANS proprietaire (comme resolvePlayable et resolveBreather). + const std::string owner = (scene == cfg_.wideShotScene) ? std::string{} : speakerId; + commit(now, scene, owner, /*hold=*/true, out, /*recordLeave=*/false); decisionKey_.clear(); // un forçage reinitialise la situation memorisee return out; } diff --git a/tests/test_core.cpp b/tests/test_core.cpp index 739ac4c..9d6d706 100644 --- a/tests/test_core.cpp +++ b/tests/test_core.cpp @@ -1280,6 +1280,82 @@ TEST_CASE("director : repetition-max — la respiration re-tire dans le pool (re CHECK_FALSE(sawForcedWide); // le plan large global n'est PAS force tant qu'une alternative existe } +// --- Invariant "owner vide <=> plan large" (plan large present dans un pool) ----- + +TEST_CASE("director : invariant owner vide — le plan large tire dans le pool d'un intervenant n'a pas de " + "proprietaire") { + // Cas reel : l'utilisateur met le plan large global DANS le pool d'un intervenant. Quand le + // tirage pondere tombe dessus, la Decision doit porter (plan large, owner vide), comme + // resolveBreather et sceneInProgram — jamais (plan large, owner=A). + // Pool A = A_cam(50) + Plateau(15) ; r=0.99 -> x=0.99*65 > 50 -> Plateau. + Director dir(repeatConfig({{"A_cam", 50}, {"Plateau", 15}}, 0), seq({0.99})); + const Decision d = dir.update(0.0, {{"A", mulToDb(0.9)}}); // A parle seul (Single) + CHECK(d.scene == "Plateau"); + CHECK(d.owner.empty()); + CHECK(d.switched); +} + +TEST_CASE("director : invariant owner vide — forceSpeaker dont le tirage tombe sur le plan large rend un " + "owner vide") { + // Meme invariant cote forcage manuel : forcer A alors que le tirage de SON pool tombe sur le + // plan large global -> la Decision est (plan large, owner vide). + Director wideDraw(repeatConfig({{"A_cam", 50}, {"Plateau", 15}}, 0), seq({0.99})); + const Decision d = wideDraw.forceSpeaker(0.0, "A"); + CHECK(d.scene == "Plateau"); + CHECK(d.owner.empty()); + CHECK(d.switched); + // Contraste : un tirage qui tombe sur une camera de A garde bien A comme proprietaire. + Director camDraw(repeatConfig({{"A_cam", 50}, {"Plateau", 15}}, 0), seq({0.0})); + const Decision d2 = camDraw.forceSpeaker(0.0, "A"); + CHECK(d2.scene == "A_cam"); + CHECK(d2.owner == "A"); +} + +TEST_CASE("director : invariant owner vide — quitter un plan large issu d'un pool n'arme PAS l'anti " + "ping-pong") { + // Non-regression : avant la correction, le plan large tire dans le pool de A portait owner=A. + // Basculer ensuite vers B armait ownerLeftAt_[A] a tort, et le retour rapide de A se faisait + // reculer sur le plan large (faux declenchement de navette). Quitter (plan large, owner vide) + // n'est PAS une navette (cf. commit) : le retour de A doit couper directement sur sa camera. + Config c; + c.wideShotScene = "Plateau"; + c.timing.minShotSeconds = 1.0; + c.timing.maxShotSeconds = 30.0; // grand expres : aucun rafraichissement ne vient consommer le RNG + c.timing.silenceReactionSeconds = 0.0; + c.timing.pingPongWindowSeconds = 12.0; // fenetre LARGE : un faux armement se verrait forcement + c.audio.attackFrames = 1; // parole detectee des le 1er frame + c.audio.releaseFrames = 2; // relachement court -> transitions Single nettes + c.whenMultiple = {100, 0}; // multi : toujours "rester" -> pas de plan large impose pendant les + c.whenSilence = {100, 0}; // transitions (les frames multi/silence ne perturbent pas le scenario) + Speaker a; + a.id = "A"; + a.name = "A"; + a.audioSource = "sA"; + a.scenes = {{"A_cam", 50}, {"Plateau", 50}}; // le plan large global figure dans le pool de A + Speaker b; + b.id = "B"; + b.name = "B"; + b.audioSource = "sB"; + b.scenes = {{"B_close", 100}}; + c.speakers = {a, b}; + // RNG dans l'ordre des tirages : pool A (0.99 -> Plateau), multi, pool B, multi, pool A (0.0 -> A_cam). + Director dir(c, seq({0.99, 0.0, 0.0, 0.0, 0.0})); + const double hi = mulToDb(0.9); + // 1) A parle seul, le tirage de son pool tombe sur le plan large -> (Plateau, owner vide). + const Decision wide = dir.update(0.0, {{"A", hi}, {"B", kDbFloor}}); + REQUIRE(wide.scene == "Plateau"); + REQUIRE(wide.owner.empty()); + // 2) B prend la parole -> on quitte le plan large pour B (ne doit PAS armer l'anti ping-pong). + dir.update(0.2, {{"A", kDbFloor}, {"B", hi}}); + const Decision toB = dir.update(0.3, {{"A", kDbFloor}, {"B", hi}}); + REQUIRE(toB.scene == "B_close"); + // 3) A revient vite (dans la fenetre anti ping-pong) : pas de recul au plan large, on coupe sur lui. + dir.update(1.5, {{"A", hi}, {"B", kDbFloor}}); + const Decision back = dir.update(1.6, {{"A", hi}, {"B", kDbFloor}}); + CHECK(back.scene == "A_cam"); + CHECK(back.owner == "A"); +} + // --- Versions semantiques (systeme de mise a jour) ------------------------------ TEST_CASE("version : parseSemVer accepte X.Y.Z (prefixe 'v' et espaces toleres)") { From 6dc20b96145514534a3db27c326f5bf0b9d97d51 Mon Sep 17 00:00:00 2001 From: DavidClaudeAI Date: Fri, 12 Jun 2026 17:40:58 +0200 Subject: [PATCH 5/5] feat core : grille de presets corpus gravee + defauts d'usine alignes + plafond repetition x15 CONTEXTE : tuning des 5 presets valide au banc de simulation Flowspire-Bench (16 800 runs sur le vrai moteur, conversations calees sur le corpus 57 episodes). Grille corpus : tempos/repetitions de la grille recalee + poids multi/silence alignes sur la pratique reelle (poses restent sur les personnes, %large cible 4/4/2 atteint en plateau large-global ; rapides tranchent vers le large, poids tranches anti-pompage : Very Fast 16,7 -> 2,3 pompages/10 min). Defauts d'usine MultiWeights/SilenceWeights {10,94} -> {90,10} (invariant Cool = defauts ; profils existants proteges, fromJson tolerant). Plafond curseur repetition x10 -> x15 (constat code review differe). Variante corpus-v2 testee et ecartee (passe sous les cibles sans gain). FICHIERS CLES : src/core/rhythm_style.cpp ; src/core/config.hpp ; src/ui/sd_config_panels.cpp ; tests/test_core.cpp (99 cases / 536 assertions verts) ; config.example.jsonc ZONES A RISQUE : defauts d'usine -> profils NEUFS uniquement ; politique large inversee vs baseline Cyp Live (decision fondee corpus, validee banc). REF : ~/Max/research/flowspire-presets-tuning-live.md --- config.example.jsonc | 2 +- src/core/config.hpp | 21 ++++++++++------- src/core/rhythm_style.cpp | 27 ++++++++++++---------- src/core/rhythm_style.hpp | 7 +++--- src/ui/sd_config_panels.cpp | 2 +- tests/test_core.cpp | 46 ++++++++++++++++++++++--------------- 6 files changed, 61 insertions(+), 44 deletions(-) diff --git a/config.example.jsonc b/config.example.jsonc index 68089fa..f870fee 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -78,7 +78,7 @@ // (opt-in). Au-delà de N : re-tirage pondéré dans le pool de la // personne PRIVÉ de ce plan (autre cam / réaction / plan large, // selon les poids). Compte PAR SCÈNE, jamais par intervenant. Les - // styles livrés le règlent (Very Fast 1 … Very Chill 7). + // styles livrés le règlent (Cool 5 … Very Fast 8). }, // --- Quand PLUSIEURS parlent en même temps (tirage pondéré) --- diff --git a/src/core/config.hpp b/src/core/config.hpp index 7b2e464..649ecd1 100644 --- a/src/core/config.hpp +++ b/src/core/config.hpp @@ -62,7 +62,8 @@ struct TimingSettings { // anterieur a la feature garde un comportement INCHANGE (aucune respiration imposee). Compte // PAR SCENE affichee, jamais par intervenant : deux cameras d'une meme personne ont chacune // leur compteur, qui repart de zero des qu'on change de cadrage (modele A). Les styles livres - // portent leur valeur (Very Fast 1 .. Very Chill 7). Effet en secondes ~ maxShotSeconds * maxPlanRepeats. + // portent leur valeur (Cool 5 .. Very Fast 8 : les rapides repetent plus de plans plus courts, + // cf. rhythm_style.cpp). Effet en secondes ~ maxShotSeconds * maxPlanRepeats. int maxPlanRepeats = 0; }; @@ -70,17 +71,21 @@ struct TimingSettings { // / plan large). Le VOLUME n'est PAS un critere de bascule -> pas d'option "le plus fort" // (le fait de parler fort ne doit pas decider qu'on vous montre). Mettre en avant une // personne se fait naturellement quand elle "gagne" la parole (contexte Single). Defauts -// tunes en reel (profil "Cyp Live") : forte preference pour le plan large des que 2+ parlent. +// alignes sur le style Cool (corpus 57 episodes, banc de simulation 2026-06-12) : on RESTE +// sur les personnes, le plan large est une respiration rare. Ne touche que les profils +// NEUFS : les profils existants gardent leurs valeurs enregistrees (fromJson est tolerant, +// les cles presentes priment). struct MultiWeights { - int currentSpeaker = 10; - int wideShot = 94; + int currentSpeaker = 90; + int wideShot = 10; }; -// Contexte C : personne ne parle (poids relatifs). Defauts tunes en reel ("Cyp Live") : -// on revient quasi systematiquement au plan large quand le silence s'installe. +// Contexte C : personne ne parle (poids relatifs). Defauts alignes sur le style Cool (corpus +// 57 episodes, banc de simulation 2026-06-12) : on reste sur le dernier locuteur, le plan +// large est une respiration rare. Les profils existants gardent leurs valeurs enregistrees. struct SilenceWeights { - int lastSpeaker = 10; - int wideShot = 94; + int lastSpeaker = 90; + int wideShot = 10; }; struct Config { diff --git a/src/core/rhythm_style.cpp b/src/core/rhythm_style.cpp index cb4902c..d852c9f 100644 --- a/src/core/rhythm_style.cpp +++ b/src/core/rhythm_style.cpp @@ -11,20 +11,23 @@ using nlohmann::json; std::vector builtinRhythmStyles() { // {nom, mini, maxi, grace silence, anti ping-pong, repetition max, whenMultiple{rester,large}, - // whenSilence{dernier,large}}. Mini/maxi/repetition derives du corpus Flowspire-Lab (57 episodes, - // 2026-06-12) ; grace COMMUNE a 1 s (mesuree idiosyncratique, sans lien au rythme). Le "temps tenu - // sur une personne" vient du DECOUPLAGE maxi x repetition (plans courts + retours), pas d'un plan fige. - // Anti ping-pong : arme seulement sur les 2 rapides (valeur de depart, a figer au test live). - // ⚠️ POIDS PLAN LARGE = baseline heritee ("Cyp Live"), volontairement NON tunee pour les 5 crans : - // la data %large suggere l'INVERSE (poses peu de large, rapides beaucoup) -> a departager EN LIVE, - // separement de cette PR (on ne flippe pas la politique large en meme temps que le rythme). + // whenSilence{dernier,large}}. Valeurs issues du banc Flowspire-Bench (simulation du vrai moteur + // sur des conversations calees corpus, 12 600+ runs, 2026-06-12 ; corpus = 57 episodes reels). + // Principe : les styles POSES restent sur les personnes (le %large corpus est faible : ~4/4/2), + // les RAPIDES tranchent vers le plan large. Anti-pompage : des poids TRANCHES (85/15, 30/70...) + // battent l'oscillation 50/50 qui fait pomper l'image. Repetitions HAUTES sur les rapides : le + // temps tenu sur une personne ~ maxi x repetition (~90 s Very Chill .. ~30 s Very Fast), + // decroissant du plus pose au plus vif. Grace COMMUNE a 1 s (mesuree idiosyncratique, sans lien + // au rythme). Anti ping-pong : arme seulement sur les 2 rapides. + // ⚠️ L'ancienne baseline "Cyp Live" {10, 94} (plan large dominant partout) est REMPLACEE par ces + // poids fondes corpus : la pratique reelle observee etait l'inverse de cette baseline. // Cool == les defauts d'usine (cf. config.hpp) SAUF la repetition-max (opt-in : defaut d'usine 0). return { - {"Very Chill", 5.0, 15.0, 1.0, 0.0, 7, {10, 94}, {10, 94}}, // tres pose : plans longs, retours rares - {"Chill", 3.5, 13.0, 1.0, 0.0, 5, {10, 94}, {10, 94}}, // pose - {"Cool", 3.0, 10.0, 1.0, 0.0, 4, {10, 94}, {10, 94}}, // equilibre = defauts d'usine (hors repetition) - {"Fast", 2.0, 7.0, 1.0, 4.0, 2, {25, 75}, {20, 80}}, // vif : retours frequents ; anti ping-pong arme - {"Very Fast", 1.5, 4.0, 1.0, 3.0, 1, {40, 60}, {25, 75}}, // nerveux : 1 plan par prise ; reste sur l'orateur + {"Very Chill", 5.0, 15.0, 1.0, 0.0, 6, {85, 15}, {90, 10}}, // tres pose : on reste sur les personnes + {"Chill", 3.5, 13.0, 1.0, 0.0, 5, {80, 20}, {85, 15}}, // pose + {"Cool", 3.0, 10.0, 1.0, 0.0, 5, {90, 10}, {90, 10}}, // equilibre = defauts d'usine (hors repetition) + {"Fast", 2.0, 7.0, 1.0, 4.0, 6, {55, 45}, {20, 80}}, // vif : tranche vers le large ; anti ping-pong arme + {"Very Fast", 1.5, 4.0, 1.0, 3.0, 8, {30, 70}, {0, 100}}, // nerveux : large franc ; silence -> toujours large }; } diff --git a/src/core/rhythm_style.hpp b/src/core/rhythm_style.hpp index 38e873b..538fe9b 100644 --- a/src/core/rhythm_style.hpp +++ b/src/core/rhythm_style.hpp @@ -37,9 +37,10 @@ struct RhythmStyle { }; // Les 5 styles LIVRES, en lecture seule, du plus pose au plus vif : Very Chill, Chill, Cool, -// Fast, Very Fast. Gradues sur l'axe RYTHME a partir du corpus Flowspire-Lab (2026-06-12) ; -// chacun porte aussi sa repetition-max (Very Chill 7 .. Very Fast 1). Cool == les defauts d'usine -// (cf. config.hpp) SAUF la repetition-max (opt-in : Cool porte 4, le defaut d'usine reste 0). +// Fast, Very Fast. Grille validee au banc Flowspire-Bench (simulation du vrai moteur, corpus +// 57 episodes, 2026-06-12) ; chacun porte aussi sa repetition-max (hautes sur les rapides : +// temps tenu sur une personne ~ maxi x repetition). Cool == les defauts d'usine +// (cf. config.hpp) SAUF la repetition-max (opt-in : Cool porte 5, le defaut d'usine reste 0). // L'utilisateur ne les modifie pas : il part de l'un d'eux, ajuste, puis enregistre sa // variante dans la bibliotheque globale. std::vector builtinRhythmStyles(); diff --git a/src/ui/sd_config_panels.cpp b/src/ui/sd_config_panels.cpp index c6a0baa..0ca99ab 100644 --- a/src/ui/sd_config_panels.cpp +++ b/src/ui/sd_config_panels.cpp @@ -768,7 +768,7 @@ void ConfigPanels::mountRhythm(QVBoxLayout* host, RhythmLayout layout) { // (0 = desactive). Entier direct (pas de demi-pas). Le sous-texte donne le temps approx tenu sur // une personne (= temps maxi x repetition) -> recalcule aussi quand on bouge le temps-maxi. auto* repR = new SliderRow( - i18n("Rhythm.MaxRepeats"), 0, 10, std::max(0, std::min(10, cfg_.timing.maxPlanRepeats)), + i18n("Rhythm.MaxRepeats"), 0, 15, std::max(0, std::min(15, cfg_.timing.maxPlanRepeats)), [](int v) { return v == 0 ? i18n("Rhythm.MaxRepeatsOff") : i18n("Rhythm.MaxRepeatsValue").arg(v); }, false); repR->setOnChange([this, applying, refresh, updateRepHint](int v) { cfg_.timing.maxPlanRepeats = v; diff --git a/tests/test_core.cpp b/tests/test_core.cpp index 9d6d706..3f76215 100644 --- a/tests/test_core.cpp +++ b/tests/test_core.cpp @@ -158,7 +158,7 @@ TEST_CASE("config : JSON tolerant aux cles absentes") { const Config c = fromJson(R"({"version":1,"speakers":[]})"); CHECK(c.speakers.empty()); CHECK(c.audio.voiceThresholdDb == doctest::Approx(-35.0)); // defaut - CHECK(c.whenMultiple.wideShot == 94); // defaut (= Cool, affine 2026-06-07) + CHECK(c.whenMultiple.wideShot == 10); // defaut (= Cool, recale corpus 2026-06-12) CHECK(c.timing.maxPlanRepeats == 0); // repetition-max opt-in : desactivee par defaut } @@ -346,16 +346,16 @@ TEST_CASE("director : anti ping-pong NE se declenche PAS sur la pause d'un orate TEST_CASE("config : un style applique (tempo + poids plan large) survit a un aller-retour JSON") { Config c = twoSpeakerConfig(); - applyRhythmStyle(c, builtinRhythmStyles()[4]); // Very Fast {40,60}/{25,75}, pingPong 3, mini 1.5, repet 1 + applyRhythmStyle(c, builtinRhythmStyles()[4]); // Very Fast {30,70}/{0,100}, pingPong 3, mini 1.5, repet 8 const Config back = fromJson(toJson(c)); CHECK(back.styleName == "Very Fast"); CHECK(back.timing.minShotSeconds == doctest::Approx(1.5)); CHECK(back.timing.pingPongWindowSeconds == doctest::Approx(3.0)); - CHECK(back.timing.maxPlanRepeats == 1); - CHECK(back.whenMultiple.currentSpeaker == 40); - CHECK(back.whenMultiple.wideShot == 60); - CHECK(back.whenSilence.lastSpeaker == 25); - CHECK(back.whenSilence.wideShot == 75); + CHECK(back.timing.maxPlanRepeats == 8); + CHECK(back.whenMultiple.currentSpeaker == 30); + CHECK(back.whenMultiple.wideShot == 70); + CHECK(back.whenSilence.lastSpeaker == 0); + CHECK(back.whenSilence.wideShot == 100); } TEST_CASE("rhythm style : bibliotheque globale - round-trip JSON + tolerance") { @@ -374,9 +374,9 @@ TEST_CASE("rhythm style : bibliotheque globale - round-trip JSON + tolerance") { const auto round = rhythmStyleLibraryFromJson(rhythmStyleLibraryToJson(lib)); REQUIRE(round.size() == 2); CHECK(round[0].name == "Mon debat"); - CHECK(round[0].whenMultiple.currentSpeaker == 40); // herite de Very Fast + CHECK(round[0].whenMultiple.currentSpeaker == 30); // herite de Very Fast CHECK(round[0].pingPongWindowSeconds == doctest::Approx(3.0)); - CHECK(round[0].maxPlanRepeats == 1); // repetition-max capturee dans le preset perso + CHECK(round[0].maxPlanRepeats == 8); // repetition-max capturee dans le preset perso CHECK(round[1].name == "Talk pose"); CHECK(round[1].maxShotSeconds == doctest::Approx(14.0)); CHECK(round[1].whenSilence.wideShot == 100); @@ -1412,17 +1412,22 @@ TEST_CASE("rhythm style : les 5 built-ins, ordre et valeurs") { CHECK(styles[3].name == "Fast"); CHECK(styles[4].name == "Very Fast"); - // "Cool" reprend les defauts livres POUR LE TEMPO -> un Config par defaut == tempo de Cool. + // "Cool" reprend les defauts livres (tempo ET poids plan large, alignes corpus 2026-06-12) + // -> un Config par defaut == Cool, sauf la repetition-max (cf. ci-dessous). const Config def; const RhythmStyle& cool = styles[2]; CHECK(cool.minShotSeconds == doctest::Approx(def.timing.minShotSeconds)); CHECK(cool.maxShotSeconds == doctest::Approx(def.timing.maxShotSeconds)); CHECK(cool.silenceReactionSeconds == doctest::Approx(def.timing.silenceReactionSeconds)); CHECK(cool.pingPongWindowSeconds == doctest::Approx(def.timing.pingPongWindowSeconds)); + CHECK(cool.whenMultiple.currentSpeaker == def.whenMultiple.currentSpeaker); + CHECK(cool.whenMultiple.wideShot == def.whenMultiple.wideShot); + CHECK(cool.whenSilence.lastSpeaker == def.whenSilence.lastSpeaker); + CHECK(cool.whenSilence.wideShot == def.whenSilence.wideShot); // EXCEPTION opt-in : la repetition-max n'est PAS couplee aux defauts. Le defaut d'usine reste 0 - // (desactive, retrocompat) alors que Cool porte 4 -> on l'active en choisissant un style livre. + // (desactive, retrocompat) alors que Cool porte 5 -> on l'active en choisissant un style livre. CHECK(def.timing.maxPlanRepeats == 0); - CHECK(cool.maxPlanRepeats == 4); + CHECK(cool.maxPlanRepeats == 5); // Seuls les 2 rapides arment l'anti ping-pong ; les 3 poses le laissent a 0. CHECK(styles[0].pingPongWindowSeconds == doctest::Approx(0.0)); @@ -1431,11 +1436,13 @@ TEST_CASE("rhythm style : les 5 built-ins, ordre et valeurs") { CHECK(styles[3].pingPongWindowSeconds > 0.0); CHECK(styles[4].pingPongWindowSeconds > 0.0); - // Du plus pose au plus vif : temps maxi ET repetition-max strictement DECROISSANTS (un style - // pose tolere de longs retours sur le meme plan ; un nerveux force la variete). + // Du plus pose au plus vif : temps maxi strictement DECROISSANT, et temps tenu sur une + // personne (~ maxi x repetition) strictement DECROISSANT aussi (banc 2026-06-12 : les rapides + // portent des repetitions HAUTES de plans courts, c'est le produit qui fait le temperament). for (size_t i = 1; i < styles.size(); ++i) { CHECK(styles[i].maxShotSeconds < styles[i - 1].maxShotSeconds); - CHECK(styles[i].maxPlanRepeats < styles[i - 1].maxPlanRepeats); + CHECK(styles[i].maxShotSeconds * styles[i].maxPlanRepeats < + styles[i - 1].maxShotSeconds * styles[i - 1].maxPlanRepeats); } // Grace de silence COMMUNE a 1 s sur tous (mesuree idiosyncratique -> non graduee). @@ -1456,10 +1463,11 @@ TEST_CASE("rhythm style : les 5 built-ins, ordre et valeurs") { CHECK(s.whenSilence.lastSpeaker >= 0); CHECK(s.whenSilence.wideShot >= 0); } - // Politique plan large = BASELINE heritee (a departager au test live ; la data suggere l'inverse). - // On verifie seulement sa coherence : Very Fast reste plus "serre" que Cool quand 2+ parlent. - CHECK(styles[4].whenMultiple.wideShot < styles[2].whenMultiple.wideShot); - CHECK(styles[4].whenMultiple.currentSpeaker > styles[2].whenMultiple.currentSpeaker); + // Politique plan large fondee corpus (banc 2026-06-12) : les poses RESTENT sur les personnes, + // les rapides TRANCHENT vers le plan large (en multi comme en silence). + CHECK(styles[4].whenMultiple.wideShot > styles[2].whenMultiple.wideShot); + CHECK(styles[4].whenMultiple.currentSpeaker < styles[2].whenMultiple.currentSpeaker); + CHECK(styles[4].whenSilence.wideShot > styles[2].whenSilence.wideShot); } TEST_CASE("rhythm style : appliquer un style a 0 DESARME l'anti ping-pong (pas seulement laisse)") {