Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion buildspec.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 9 additions & 1 deletion config.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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 (Cool 5 … Very Fast 8).
},

// --- Quand PLUSIEURS parlent en même temps (tirage pondéré) ---
Expand Down
6 changes: 6 additions & 0 deletions data/locale/en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."
Expand All @@ -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"

Expand Down
6 changes: 6 additions & 0 deletions data/locale/fr-FR.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."
Expand All @@ -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"

Expand Down
10 changes: 9 additions & 1 deletion src/core/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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}}},
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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;
}
Expand Down
36 changes: 25 additions & 11 deletions src/core/config.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ 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 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
// temps mini (spec : 12 s). Les profils existants gardent leur valeur enregistree.
Expand All @@ -53,25 +53,39 @@ 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 (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;
};

// Contexte B : plusieurs parlent en meme temps (poids relatifs : rester sur le plan courant
// / 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 {
Expand Down
74 changes: 70 additions & 4 deletions src/core/director.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ void Director::setConfig(const Config& 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
// 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
Expand Down Expand Up @@ -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<SceneWeight> 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<std::string, double>& levelsDb) {
// 1) Alimenter les detecteurs et collecter les parlants (tries par dB desc).
std::vector<std::pair<std::string, double>> speaking;
Expand Down Expand Up @@ -283,7 +321,18 @@ Decision Director::update(double now, const std::map<std::string, double>& 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;
Expand Down Expand Up @@ -331,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;
}
}
Expand All @@ -345,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;
}
}
Expand Down Expand Up @@ -374,6 +429,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;
Expand Down Expand Up @@ -405,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;
}
Expand Down
Loading
Loading