# PROD-ONPOSITION 2026-08-18 # Liquidsoap 2.4.x: callbacks de fin migres de source.on_end vers .on_position. # synchronous=false : callback hors boucle audio, conforme a l'API 2.4 et plus sur pour la production. # remaining=true + position=0.0 vise la fin du fichier; allow_partial=true couvre les fins courtes. # Aucun changement du rotate, des sources, des CUE_OUT musique ni du reset TOP. # # STABLE-SYNC TOP-GENERATION-FIX - 2026-08-18 # Correction ciblee : # - le TOP remet program_position a 0 SANS incrementer program_generation ; # - evite le double P00/JINGLEINFO generation 0 puis generation 1 ; # - aucune autre logique du rotate, des fins reelles ou du CUE_OUT n'est modifiee. # # VERSION STABLE-SYNC 2026-08-18 # Objectif : supprimer les blancs lies a la file asynchrone des one-shot. # Principe : # - non-musicaux : un seul MP3 par generation, resolution synchronous=true, # avance UNIQUEMENT sur source.on_end a la fin physique reelle ; # - aucun prechargement de la generation suivante ; # - musiques : logique CUE_OUT existante conservee ; # - rotate strict P00..P42 (43 positions). # # BIEN VIVRE A MAGNY # LIQUIDSOAP 2.4.5 - ROTATION NETTOYEE / ANTI-DOUBLE STRICT # BASE STABLE SANS cross() GLOBAL # # REGLES : # - elements non musicaux jusqu'a leur vraie fin physique (source.on_end) # - JESSY et JINGLESCHANTES restent dans la rotation # - JESSY et JINGLESCHANTES : une seule requete par generation de slot # - anti-repetition artiste : 60 minutes glissantes sur les 7 familles musicales # - TOUS les slots non musicaux sont one-shot : un seul fichier, fin physique obligatoire # - top horaire : controle de coupure musicale base directement sur PROGRAM # - NOUVEAUTES uniquement dans ses slots normaux # - mix CUE_OUT 0..3 s uniquement pour les 7 familles musicales # - le suivant peut etre Jessy, jingle, info, meteo, top ou musique # - aucun cross() / cross.buffer global # - JESSYete : diffusion uniquement du 1er juillet au 31 aout inclus # - dossier reserve aux liners sur intro : /home/bienvivre/Linercap # - LINERCAP : maximum 1 diffusion par demi-heure civile (HH:00-29 / HH:30-59) # - LINERCAP uniquement sur ANNEES70/80/90/2000 et NOUVEAUTES # - LINERCAP se termine 0.3 s avant le CUE_IN de la chanson # - LINERCAP ducking : musique env. -6 dB, liner niveau normal, rampes env. 0.25 s # - LINERCAP anti-clic : fade-in 0.12 s + fade-out 0.08 s sur chaque liner # - LINERS ARTISTES : 1 max/demi-heure, meme ARTIST que chanson, meme calage/ducking # settings.log.file.path := "/etc/liquidsoap/logs/bienvivreamagny.log" settings.log.stdout := true settings.log.level := 3 settings.init.daemon := false settings.server.telnet := true settings.server.telnet.bind_addr := "127.0.0.1" settings.server.telnet.port := 1240 settings.charset.encodings := ["UTF-8", "ISO-8859-1", "ISO-8859-15"] settings.request.metadata_decoders.recode := true # Le graphe contient notamment 24 tops horaires statiques et leurs delais. # Le seuil reste au-dessus du graphe normal, tout en gardant une alerte utile. settings.clock.leak_warning := 250 # ------------------------------------------------------------ # GESTION CUE_OUT AVANT DECODAGE # ------------------------------------------------------------ def cue_resolver(~rlog, ~maxtime, filename) = ignore(rlog) ignore(maxtime) tags = file.metadata(filename) cue_upper = tags["CUE_OUT"] cue_lower = tags["cue_out"] cue = if cue_upper != "" then cue_upper else cue_lower end if cue != "" then cue_value = float_of_string(default=0.0, cue) # Duree reelle du fichier pour savoir combien de son reste apres CUE_OUT. duration_opt = request.duration(resolve_metadata=false, filename) real_duration = null.get(default=0.0, duration_opt) if cue_value > 0.0 and real_duration > cue_value then remaining = real_duration - cue_value # Le mix commence exactement au CUE_OUT et dure au maximum 3 secondes. mix_duration = if remaining < 3.0 then remaining else 3.0 end # La fin effective de la requete est CUE_OUT + duree du mix. # PROGRAM conserve ensuite cette queue pour le chevauchement selectif. effective_end = cue_value + mix_duration "annotate:liq_cue_out=\"" ^ string(effective_end) ^ "\",mix_at_cue=\"true\",vrs_mix_duration=\"" ^ string(mix_duration) ^ "\":" ^ filename else # CUE_OUT invalide ou place apres la fin : lecture normale jusqu'a la fin. "annotate:mix_at_cue=\"false\",vrs_mix_duration=\"0.0\":" ^ filename end else # Sans CUE_OUT : lecture jusqu'a la fin reelle, sans chevauchement anticipe. "annotate:mix_at_cue=\"false\",vrs_mix_duration=\"0.0\":" ^ filename end end protocol.add( "cueexact", static=(fun (_) -> true), syntax="cueexact:/chemin/fichier.mp3", doc="CUE_OUT = debut du mix, maximum 3 secondes.", cue_resolver ) # Seules les sources musicales utilisent le traitement CUE_OUT pour le mix. # Les sources non musicales utilisent fullend: lecture jusqu'a la fin physique, # sans chevauchement. La coupure horaire forcee reste reservee a la musique. def mark_music(meta) = ignore(meta) [("vrs_mix_allowed", "true")] end # Source musicale : meme CUE_OUT global, avec autorisation de coupure H+1m30. def playlist_music(directory) = source_with_cue = playlist( mode="randomize", prefix="cueexact:", directory ) metadata.map(strip=false, insert_missing=true, mark_music, source_with_cue) end # Lecture integrale : ignore totalement CUE_OUT et interdit tout chevauchement. # Le titre suivant ne peut commencer qu'apres la vraie fin physique du fichier. def full_end_resolver(~rlog, ~maxtime, filename) = ignore(rlog) ignore(maxtime) "annotate:mix_at_cue=\"false\",vrs_mix_duration=\"0.0\":" ^ filename end protocol.add( "fullend", static=(fun (_) -> true), syntax="fullend:/chemin/fichier.mp3", doc="Lecture jusqu'a la vraie fin du fichier, CUE_OUT ignore.", full_end_resolver ) def playlist_full_end(directory) = playlist( mode="randomize", prefix="fullend:", directory ) end # Une seule couche metadata.map par source non musicale. # Elle indique uniquement que la source n'est pas une source musicale. def mark_non_music_source(audio_source) = def add_non_music_flags(meta) = ignore(meta) [("vrs_mix_allowed", "false")] end metadata.map( strip=false, insert_missing=true, add_non_music_flags, audio_source ) end # SOURCES MUSICALES ANNEES70 = playlist_music("/home/musique/Annees70") ANNEES80 = playlist_music("/home/musique/Annees80") ANNEES90 = playlist_music("/home/musique/Annees90") ANNEES2000 = playlist_music("/home/musique/Annees2000") NOUVEAUTES = playlist_music("/home/musique/Nouveautes") TITRESJESSY = playlist_music("/home/bienvivre/Titresjessy") GRENIERJESSY = playlist_music("/home/bienvivre/Grenierjessy") # SOURCES NON MUSICALES # Construites plus bas en one-shot request.dynamic, apres initialisation du moteur de slots. # TOPS HORAIRES # Chaque top porte le numero de l'heure qu'il annonce. # Cette information sert a eviter toute coupure apres que le top a deja joue. def hourly_top(filename, top_hour) = def tag_hourly_top(_) = [ ("hourly_top_hour", string(top_hour)), ("vrs_mix_allowed", "false") ] end # Le top horaire doit toujours aller jusqu'a sa fin physique, sans CUE_OUT ni crossfade. top_source = single("fullend:" ^ filename) metadata.map(strip=false, insert_missing=true, tag_hourly_top, top_source) end TOP00 = hourly_top("/home/bienvivre/Tophoraire/0h.mp3", 0) TOP01 = hourly_top("/home/bienvivre/Tophoraire/1h.mp3", 1) TOP02 = hourly_top("/home/bienvivre/Tophoraire/2h.mp3", 2) TOP03 = hourly_top("/home/bienvivre/Tophoraire/3h.mp3", 3) TOP04 = hourly_top("/home/bienvivre/Tophoraire/4h.mp3", 4) TOP05 = hourly_top("/home/bienvivre/Tophoraire/5h.mp3", 5) TOP06 = hourly_top("/home/bienvivre/Tophoraire/6h.mp3", 6) TOP07 = hourly_top("/home/bienvivre/Tophoraire/7h.mp3", 7) TOP08 = hourly_top("/home/bienvivre/Tophoraire/8h.mp3", 8) TOP09 = hourly_top("/home/bienvivre/Tophoraire/9h.mp3", 9) TOP10 = hourly_top("/home/bienvivre/Tophoraire/10h.mp3", 10) TOP11 = hourly_top("/home/bienvivre/Tophoraire/11h.mp3", 11) TOP12 = hourly_top("/home/bienvivre/Tophoraire/12h.mp3", 12) TOP13 = hourly_top("/home/bienvivre/Tophoraire/13h.mp3", 13) TOP14 = hourly_top("/home/bienvivre/Tophoraire/14h.mp3", 14) TOP15 = hourly_top("/home/bienvivre/Tophoraire/15h.mp3", 15) TOP16 = hourly_top("/home/bienvivre/Tophoraire/16h.mp3", 16) TOP17 = hourly_top("/home/bienvivre/Tophoraire/17h.mp3", 17) TOP18 = hourly_top("/home/bienvivre/Tophoraire/18h.mp3", 18) TOP19 = hourly_top("/home/bienvivre/Tophoraire/19h.mp3", 19) TOP20 = hourly_top("/home/bienvivre/Tophoraire/20h.mp3", 20) TOP21 = hourly_top("/home/bienvivre/Tophoraire/21h.mp3", 21) TOP22 = hourly_top("/home/bienvivre/Tophoraire/22h.mp3", 22) TOP23 = hourly_top("/home/bienvivre/Tophoraire/23h.mp3", 23) # CORRECTION ANTI-SILENCE 2026-09-30 # Les callbacks de fin exacte (musique et one-shot non musical) sont synchrones. # Le pointeur passe donc au slot suivant AVANT qu'une source request.dynamic # one-shot puisse etre redemandee dans sa generation deja consommee et renvoyer null. # Cela evite que mksafe() masque cette indisponibilite sous forme de silence. # Le rotate, l'ordre des 43 slots, les CUE_OUT et la logique TOP restent inchanges. # # ROTATION STRICTE REINITIALISABLE APRES CHAQUE TOP HORAIRE # ------------------------------------------------------------ # La version precedente partageait une seule instance de rotate entre toutes # les heures. Cela faisait reprendre la rotation la ou elle se trouvait apres # le top. Ici on utilise un pointeur explicite de 0 a 42. # Chaque top horaire remet ce pointeur a 0 : la reprise est donc TOUJOURS : # JINGLEINFO, INFOSMAGNY, MERCI, JINGLESMIX, NOUVEAUTES, etc. program_position = ref(0) # V20 : compteur d'activation de slot. # Il change CHAQUE fois que la rotation avance. Les sources one-shot # JESSY/JINGLESCHANTES ne peuvent fournir qu'une requete par generation. program_generation = ref(0) # ------------------------------------------------------------ # SOURCES NON MUSICALES ONE-SHOT # ------------------------------------------------------------ # Une playlist classique reste disponible apres la fin d'un fichier et peut # repartir sur un autre fichier (ou le meme si le dossier n'en contient qu'un). # Ici chaque slot non musical fournit UNE SEULE requete pour UNE generation. # Apres cette requete, le callback retourne null : la source devient indisponible # jusqu'a la prochaine activation legitime du slot. def random_mp3(directory) = cmd = "find " ^ process.quote(directory) ^ " -maxdepth 1 -type f -iname '*.mp3' -print | shuf -n 1" list.hd(default="", process.read.lines(cmd)) end # Resolution SYNCHRONE : la requete du slot est creee uniquement lorsque le switch # en a besoin. Aucun prechargement de generation future, donc aucune course entre # generation preparee et generation diffusee. served_generation interdit tout doublon. def one_shot_directory_source(source_id, directory, expected_slot) = served_generation = ref(-1) def next_request() = slot = program_position() generation = program_generation() if slot == expected_slot and served_generation() != generation then filename = random_mp3(directory) if filename != "" then served_generation := generation log( label="one_shot_source", level=3, "ONE-SHOT " ^ source_id ^ " : slot " ^ string(expected_slot) ^ " generation " ^ string(generation) ^ " ; fichier unique : " ^ filename ) request.create("fullend:" ^ filename) else null end else null end end mark_non_music_source( request.dynamic( id=source_id, synchronous=true, next_request ) ) end JINGLEINFO = one_shot_directory_source("Jingleinfo", "/home/bienvivre/Jingleinfo", 0) INFOSMAGNY = one_shot_directory_source("Infosmagny", "/home/bienvivre/Infosmagny", 1) MERCI = one_shot_directory_source("Merciflash", "/home/bienvivre/Merciflash", 2) JINGLESMIX = one_shot_directory_source("Jinglesmix", "/home/bienvivre/Jinglesmix", 3) LCTMETEO = one_shot_directory_source("Lancementmeteo", "/home/bienvivre/Lancementmeteo", 8) METEO = audio_to_stereo(one_shot_directory_source("Meteo", "/home/bienvivre/Meteo", 9)) JINGLEHISTOIRE = one_shot_directory_source("Jinglehistoire", "/home/bienvivre/Jinglehistoire", 13) HISTOIRE = one_shot_directory_source("Histoiremagny", "/home/bienvivre/Histoiremagny", 14) JINGLESGRENIER = one_shot_directory_source("Jinglesgrenier", "/home/bienvivre/Jinglesgrenier", 27) JINGLEINSOLITES = one_shot_directory_source("Jingleinsolite", "/home/bienvivre/Jingleinsolite", 30) INSOLITES = one_shot_directory_source("Insolites", "/home/musique/Insolites", 31) def set_program_position(pos) = program_generation := program_generation() + 1 program_position := pos end # Verrou utilise pendant la remise a zero horaire. Il empeche les metadonnees # retardees de la branche active avant le top de remettre un ancien slot. program_reset_lock = ref(false) # ------------------------------------------------------------ # V20 - SOURCES ONE-SHOT NATIVES JESSY / JINGLESCHANTES # ------------------------------------------------------------ # request.dynamic est utilise en resolution SYNCHRONE : aucune file asynchrone # et aucun prechargement d'une generation future. # La generation interdit une deuxieme requete logique pour le meme slot. # L'avancement du slot reste exclusivement gere par advance_program_when_due(). def is_jessy_slot(slot) = slot == 5 or slot == 11 or slot == 16 or slot == 21 or slot == 25 or slot == 34 or slot == 41 end def is_jingleschantes_slot(slot) = slot == 10 or slot == 15 or slot == 19 or slot == 24 or slot == 32 or slot == 37 or slot == 39 end last_jessy_generation = ref(-1) last_jingleschantes_generation = ref(-1) def jessy_hour_directory() = now = time.local() if now.hour >= 6 and now.hour < 9 then "/home/bienvivre/JESSY06h09h" elsif now.hour >= 9 and now.hour < 12 then "/home/bienvivre/JESSY09h12h" elsif now.hour >= 12 and now.hour < 13 then "/home/bienvivre/JESSY12h13h" elsif now.hour >= 13 and now.hour < 17 then "/home/bienvivre/JESSY13h17h" elsif now.hour >= 17 and now.hour < 20 then "/home/bienvivre/JESSY17h20h" elsif now.hour >= 20 and now.hour < 22 then "/home/bienvivre/JESSY20h22h" else "/home/bienvivre/JESSY22h06h" end end def next_jessy_request() = slot = program_position() generation = program_generation() if is_jessy_slot(slot) and last_jessy_generation() != generation then # JESSYete est strictement saisonnier : uniquement du 1er juillet # au 31 aout inclus, chaque annee. Hors de cette periode, ce dossier # n'entre jamais dans le tirage. now = time.local() jessy_directories = if now.month == 7 or now.month == 8 then [ "/home/bienvivre/Jessy", jessy_hour_directory(), "/home/bienvivre/JESSYete" ] else [ "/home/bienvivre/Jessy", jessy_hour_directory() ] end directory = list.hd( default="/home/bienvivre/Jessy", list.shuffle(jessy_directories) ) selected_filename = random_mp3(directory) # Si le dossier choisi est vide, repli sur Jessy toute heure. filename = if selected_filename == "" then random_mp3("/home/bienvivre/Jessy") else selected_filename end if filename != "" then last_jessy_generation := generation log( label="one_shot_jessy", level=3, "ONE-SHOT JESSY : generation " ^ string(generation) ^ " ; un seul fichier fourni : " ^ filename ) request.create("fullend:" ^ filename) else null end else null end end def next_jingleschantes_request() = slot = program_position() generation = program_generation() if is_jingleschantes_slot(slot) and last_jingleschantes_generation() != generation then filename = random_mp3("/home/bienvivre/Jingleschantes") if filename != "" then last_jingleschantes_generation := generation log( label="one_shot_jingleschantes", level=3, "ONE-SHOT JINGLESCHANTES : generation " ^ string(generation) ^ " ; un seul fichier fourni : " ^ filename ) request.create("fullend:" ^ filename) else null end else null end end JESSY = mark_non_music_source( request.dynamic( id="JESSY", synchronous=true, next_jessy_request ) ) JINGLESCHANTES = mark_non_music_source( request.dynamic( id="JINGLESCHANTES", synchronous=true, next_jingleschantes_request ) ) # ------------------------------------------------------------ # SLOTS PROGRAMME - SANS 47 metadata.map SUPPLEMENTAIRES # ------------------------------------------------------------ # IMPORTANT 2026-08-18 : # L'ancienne version enveloppait chacun des 47 slots dans tag_program_slot(), # ce qui creait 47 sources metadata.map permanentes supplementaires. # Les memes sources (JESSY, JINGLESCHANTES, playlists musicales, etc.) etaient # ainsi re-enveloppees plusieurs fois uniquement pour transporter le numero # du slot. Cela faisait depasser le seuil de diagnostic Liquidsoap (>300) et # declenchait le message "possible source leak" des le demarrage. # # Le numero du slot est deja connu de facon fiable via program_position(). # On reutilise donc directement les sources existantes, sans wrapper. P00 = JINGLEINFO P01 = INFOSMAGNY P02 = MERCI P03 = JINGLESMIX P04 = NOUVEAUTES P05 = JESSY P06 = ANNEES2000 P07 = TITRESJESSY P08 = LCTMETEO P09 = METEO P10 = JINGLESCHANTES P11 = JESSY P12 = ANNEES80 P13 = JINGLEHISTOIRE P14 = HISTOIRE P15 = JINGLESCHANTES P16 = JESSY P17 = NOUVEAUTES P18 = TITRESJESSY P19 = JINGLESCHANTES P20 = ANNEES2000 P21 = JESSY P22 = NOUVEAUTES P23 = ANNEES80 P24 = JINGLESCHANTES P25 = JESSY P26 = ANNEES2000 P27 = JINGLESGRENIER P28 = GRENIERJESSY P29 = NOUVEAUTES P30 = JINGLEINSOLITES P31 = INSOLITES P32 = JINGLESCHANTES P33 = ANNEES2000 P34 = JESSY P35 = NOUVEAUTES P36 = TITRESJESSY P37 = JINGLESCHANTES P38 = ANNEES80 P39 = JINGLESCHANTES P40 = ANNEES90 P41 = JESSY P42 = NOUVEAUTES # ------------------------------------------------------------ # PROGRAM : MIX SELECTIF SANS cross() # ------------------------------------------------------------ transition_mix_requested = ref(false) transition_mix_duration = ref(0.0) transition_tail_duration = ref(0.0) current_program_slot = ref(-1) current_program_is_music = ref(false) current_program_mix_at_cue = ref(false) current_program_mix_duration = ref(0.0) # IMPORTANT 2026-08-18 : une seule autorite fait avancer la rotation. # program_position est avance uniquement par advance_program_when_due(), # sauf remise a zero volontaire au top horaire. Aucun callback on_track ne # modifie le pointeur : cela evite le saut d'un element sur deux. # La meteo est diffusee uniquement entre 00:00 et 14:59:59 (heure locale). # A partir de 15:00, P07 rejoint P10 ; si P08 finit apres 15:00, # P09 est saute lui aussi. Le top horaire conserve son reset sur P00. def next_program_slot(slot) = now = time.local() if now.hour >= 15 and slot == 7 then 10 elsif now.hour >= 15 and slot == 8 then 10 elsif slot + 1 >= 43 then 0 else slot + 1 end end def program_transition(old_source, new_source) = # Forcer explicitement le type attendu par switch.transitions. old_source = (old_source : source) new_source = (new_source : source) if transition_mix_requested() and transition_mix_duration() > 0.0 then d = transition_mix_duration() old_tail = max_duration(d, old_source) log( label="mix_cueout", level=3, "MIX CUE_OUT SANS CROSS : " ^ string(d) ^ " s ; nouveau slot demarre au CUE_OUT." ) add( normalize=false, [ new_source, fade.out(duration=d, old_tail) ] ) else # Transition normale : le pointeur change AVANT la fin pour laisser # au slot suivant le temps de devenir disponible, mais l'ancien fichier # reste entendu jusqu'a sa vraie fin grace a la duree restante memorisee. tail = transition_tail_duration() if tail > 0.0 then old_tail = max_duration(tail, old_source) (sequence([old_tail, new_source]) : source) else new_source end end end PROGRAM = switch( track_sensitive=false, transitions=[ program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition, program_transition ], [ ({program_position() == 0}, P00), ({program_position() == 1}, P01), ({program_position() == 2}, P02), ({program_position() == 3}, P03), ({program_position() == 4}, P04), ({program_position() == 5}, P05), ({program_position() == 6}, P06), ({program_position() == 7}, P07), ({program_position() == 8}, P08), ({program_position() == 9}, P09), ({program_position() == 10}, P10), ({program_position() == 11}, P11), ({program_position() == 12}, P12), ({program_position() == 13}, P13), ({program_position() == 14}, P14), ({program_position() == 15}, P15), ({program_position() == 16}, P16), ({program_position() == 17}, P17), ({program_position() == 18}, P18), ({program_position() == 19}, P19), ({program_position() == 20}, P20), ({program_position() == 21}, P21), ({program_position() == 22}, P22), ({program_position() == 23}, P23), ({program_position() == 24}, P24), ({program_position() == 25}, P25), ({program_position() == 26}, P26), ({program_position() == 27}, P27), ({program_position() == 28}, P28), ({program_position() == 29}, P29), ({program_position() == 30}, P30), ({program_position() == 31}, P31), ({program_position() == 32}, P32), ({program_position() == 33}, P33), ({program_position() == 34}, P34), ({program_position() == 35}, P35), ({program_position() == 36}, P36), ({program_position() == 37}, P37), ({program_position() == 38}, P38), ({program_position() == 39}, P39), ({program_position() == 40}, P40), ({program_position() == 41}, P41), ({program_position() == 42}, P42) ] ) def remember_program_track(meta) = slot = program_position() if slot >= 0 then # Observation uniquement : ce callback ne fait JAMAIS avancer la rotation. # Ainsi le demarrage reel de P01 apres P00 ne peut plus etre interprete # comme un doublon et provoquer un second saut vers P02. current_program_slot := slot current_program_is_music := meta["vrs_mix_allowed"] == "true" current_program_mix_at_cue := meta["mix_at_cue"] == "true" current_program_mix_duration := float_of_string(default=0.0, meta["vrs_mix_duration"]) if program_reset_lock() and slot == 0 then program_reset_lock := false end log( label="slot_on_air", level=3, "ON AIR SLOT " ^ string(slot) ^ " generation " ^ string(program_generation()) ^ " : " ^ meta["artist"] ^ (if meta["artist"] != "" then " - " else "" end) ^ meta["title"] ) end end # IMPORTANT : on_track sert uniquement au journal et aux proprietes de la # piste courante. Il ne modifie jamais program_position. PROGRAM.on_track( synchronous=true, remember_program_track ) # ------------------------------------------------------------ # ANTI-REPETITION ARTISTE DESACTIVEE - ROTATE STRICT 2026-09-29 # ------------------------------------------------------------ # Aucune regle de retry/skip ne doit intervenir dans le moteur du rotate. # Chaque slot musical diffuse le titre choisi par sa famille puis passe # strictement au slot suivant. Cela supprime PROGRAM.skip() du chemin critique. # ------------------------------------------------------------ # LINERCAP SUR INTRO - MAXIMUM 1 PAR DEMI-HEURE # ------------------------------------------------------------ # Regles : # - uniquement sur les chansons des dossiers Annees70, Annees80, Annees90, # Annees2000 et Nouveautes (identifiees ici par leurs slots du rotate) ; # - le fichier Linercap est choisi uniquement s'il tient entierement dans l'intro ; # - sa fin physique doit arriver 0.3 seconde AVANT le CUE_IN du disque ; # - maximum une diffusion par demi-heure civile : HH:00-29 puis HH:30-59 ; # - si aucun liner ne tient sur une chanson, on attend une chanson admissible suivante ; # - aucun changement du pointeur de rotation, du CUE_OUT ou des tops horaires. LINERCAP_DIR = "/home/bienvivre/Linercap" LINERCAP_MARGIN = 0.3 LINERCAP_DUCK_RAMP = 0.25 LINERCAP_DUCK_GAIN = 0.501187 # environ -6 dB LINERCAP_FADE_IN = 0.12 LINERCAP_FADE_OUT = 0.08 LINERCAP_QUEUE = request.queue(id="linercap_overlay", interactive=false) # Anti-clic : micro-fondus appliques a chaque Linercap sans changer sa duree. # track_sensitive=true garantit que chaque nouveau liner de la file recoit son fondu. LINERCAP_AUDIO = fade.in( duration=LINERCAP_FADE_IN, type="sin", track_sensitive=true, LINERCAP_QUEUE ) LINERCAP_AUDIO = fade.out( duration=LINERCAP_FADE_OUT, type="sin", track_sensitive=true, LINERCAP_AUDIO ) linercap_pending = ref(false) linercap_pending_file = ref("") linercap_pending_start = ref(0.0) linercap_pending_duck_start = ref(0.0) linercap_pending_track = ref(0) linercap_ducking = ref(false) linercap_duck_gain = ref(1.0) linercap_track_serial = ref(0) linercap_last_bucket = ref("") # Slots correspondant EXCLUSIVEMENT aux 5 dossiers autorises. def linercap_slot_allowed(slot) = slot == 4 or slot == 6 or slot == 12 or slot == 17 or slot == 20 or slot == 22 or slot == 23 or slot == 26 or slot == 29 or slot == 33 or slot == 35 or slot == 38 or slot == 40 or slot == 42 end # Cle unique d'une demi-heure civile locale. def linercap_bucket() = now = time.local() half = if now.min < 30 then 0 else 1 end string(now.year) ^ "-" ^ string(now.year_day) ^ "-" ^ string(now.hour) ^ "-" ^ string(half) end # Lit CUE_IN sans imposer de casse au tag. def get_cue_in(meta) = cue_upper = meta["CUE_IN"] cue_lower = meta["cue_in"] cue = if cue_upper != "" then cue_upper else cue_lower end float_of_string(default=0.0, cue) end # Teste si un fichier Linercap tient entierement avant le CUE_IN disponible. def linercap_file_fits(allowed_duration, filename) = d = null.get( default=0.0, request.duration(resolve_metadata=false, filename) ) d > 0.0 and d <= allowed_duration end # Prepare eventuellement un liner au demarrage d'une chanson admissible. def prepare_linercap(meta) = linercap_track_serial := linercap_track_serial() + 1 serial = linercap_track_serial() # Toute nouvelle piste annule une programmation devenue obsolete. linercap_pending := false linercap_pending_file := "" linercap_pending_start := 0.0 linercap_pending_duck_start := 0.0 linercap_pending_track := serial slot = program_position() bucket = linercap_bucket() cue_in = get_cue_in(meta) if linercap_slot_allowed(slot) and meta["vrs_mix_allowed"] == "true" and bucket != linercap_last_bucket() and cue_in > LINERCAP_MARGIN then # Le liner doit tenir avant CUE_IN en conservant aussi la rampe de ducking. max_liner_duration = cue_in - LINERCAP_MARGIN - LINERCAP_DUCK_RAMP files = file.ls(absolute=true, pattern="*.mp3", LINERCAP_DIR) # Conserver uniquement les liners dont la duree physique tient avant CUE_IN. fitting = list.filter( (fun (filename) -> linercap_file_fits(max_liner_duration, filename)), files ) if list.length(fitting) > 0 then filename = list.pick(default="", fitting) liner_duration = null.get( default=0.0, request.duration(resolve_metadata=false, filename) ) start_at = cue_in - liner_duration - LINERCAP_MARGIN # La liste fitting garantit deja les 0.25 s necessaires au ducking. if filename != "" and liner_duration > 0.0 and start_at >= LINERCAP_DUCK_RAMP then linercap_pending := true linercap_pending_file := filename linercap_pending_start := start_at linercap_pending_duck_start := start_at - LINERCAP_DUCK_RAMP linercap_pending_track := serial log( label="linercap", level=3, "LINERCAP PREPARE slot " ^ string(slot) ^ " : " ^ filename ^ " ; ducking a " ^ string(start_at - LINERCAP_DUCK_RAMP) ^ " s ; depart liner a " ^ string(start_at) ^ " s ; duree " ^ string(liner_duration) ^ " s ; CUE_IN " ^ string(cue_in) ^ " s." ) end else log( label="linercap", level=4, "LINERCAP : aucun liner assez court pour CUE_IN=" ^ string(cue_in) ^ " s sur ce titre ; attente du prochain titre admissible." ) end end end PROGRAM.on_track( synchronous=true, prepare_linercap ) # Le push est fait depuis la boucle audio quand l'elapsed du disque atteint # exactement le point calcule. On ne marque la demi-heure comme utilisee # qu'au moment ou le liner est effectivement injecte. def fire_linercap_when_due() = if linercap_pending() and linercap_pending_track() == linercap_track_serial() then elapsed = PROGRAM.elapsed() # 0.25 s avant le liner, commencer la descente progressive de la musique. if not linercap_ducking() and elapsed >= linercap_pending_duck_start() then linercap_ducking := true log( label="linercap", level=3, "LINERCAP DUCK START : descente musique vers -6 dB." ) end # Le liner demarre au point calcule pour finir 0.3 s avant CUE_IN. if elapsed >= linercap_pending_start() then bucket = linercap_bucket() if bucket != linercap_last_bucket() then filename = linercap_pending_file() if filename != "" then LINERCAP_QUEUE.push.uri("fullend:" ^ filename) linercap_last_bucket := bucket log( label="linercap", level=3, "LINERCAP ON AIR : " ^ filename ^ " ; musique a environ -6 dB ; fin prevue 0.3 s avant CUE_IN." ) end else # Rien n'est joue : annuler aussi le ducking eventuellement arme. linercap_ducking := false end linercap_pending := false linercap_pending_file := "" end end end PROGRAM.on_frame( synchronous=true, fire_linercap_when_due ) # A la vraie fin physique du Linercap, lancer la remontee progressive de la musique. def release_linercap_duck(remaining, meta) = ignore(remaining) ignore(meta) linercap_ducking := false log( label="linercap", level=3, "LINERCAP DUCK END : remontee musique vers niveau normal." ) end LINERCAP_QUEUE.on_position( synchronous=false, remaining=true, allow_partial=true, position=0.0, release_linercap_duck ) # ------------------------------------------------------------ # LINERS ARTISTES SUR INTRO - MAXIMUM 1 PAR DEMI-HEURE # ------------------------------------------------------------ # Regles : # - dossier : /home/musique/Linersartistes ; # - uniquement sur les memes 5 familles musicales que Linercap ; # - le tag ARTIST du liner doit correspondre au tag ARTIST de la chanson ; # - comparaison sans tenir compte des majuscules/minuscules ni des espaces # en debut/fin ; # - le liner doit tenir entierement dans l'intro ; # - sa fin physique doit arriver 0.3 seconde AVANT le CUE_IN ; # - maximum 1 liner artiste par demi-heure civile ; # - si aucun liner correspondant/assez court n'existe, la demi-heure n'est # pas consommee et on retente sur une chanson admissible suivante ; # - si un Linercap est deja programme sur la meme chanson, le liner artiste # attend une autre chanson afin d'eviter deux overlays simultanes. LINERART_DIR = "/home/musique/Linersartistes" LINERART_MARGIN = 0.3 LINERART_DUCK_RAMP = 0.25 LINERART_FADE_IN = 0.12 LINERART_FADE_OUT = 0.08 LINERART_QUEUE = request.queue(id="linerartist_overlay", interactive=false) LINERART_AUDIO = fade.in( duration=LINERART_FADE_IN, type="sin", track_sensitive=true, LINERART_QUEUE ) LINERART_AUDIO = fade.out( duration=LINERART_FADE_OUT, type="sin", track_sensitive=true, LINERART_AUDIO ) linerart_pending = ref(false) linerart_pending_file = ref("") linerart_pending_start = ref(0.0) linerart_pending_duck_start = ref(0.0) linerart_pending_track = ref(0) linerart_ducking = ref(false) linerart_track_serial = ref(0) linerart_last_bucket = ref("") # Meme liste de slots musicaux autorises que Linercap. def linerart_slot_allowed(slot) = linercap_slot_allowed(slot) end # Demi-heure civile independante de celle de Linercap. def linerart_bucket() = now = time.local() half = if now.min < 30 then 0 else 1 end string(now.year) ^ "-" ^ string(now.year_day) ^ "-" ^ string(now.hour) ^ "-" ^ string(half) end # Normalisation volontairement prudente : casse + espaces exterieurs seulement. def normalize_artist_name(name) = string.lowercase(string.trim(name)) end # Verifie a la fois l'artiste et la duree physique du liner artiste. def linerart_file_fits(song_artist_norm, allowed_duration, filename) = liner_meta = file.metadata(filename) liner_artist = metadata.artist(liner_meta) liner_artist_norm = normalize_artist_name(liner_artist) liner_duration = null.get( default=0.0, request.duration(resolve_metadata=false, filename) ) song_artist_norm != "" and liner_artist_norm != "" and liner_artist_norm == song_artist_norm and liner_duration > 0.0 and liner_duration <= allowed_duration end # Prepare eventuellement un liner artiste au demarrage d'une chanson admissible. def prepare_linerartist(meta) = linerart_track_serial := linerart_track_serial() + 1 serial = linerart_track_serial() linerart_pending := false linerart_pending_file := "" linerart_pending_start := 0.0 linerart_pending_duck_start := 0.0 linerart_pending_track := serial slot = program_position() bucket = linerart_bucket() cue_in = get_cue_in(meta) song_artist = metadata.artist(meta) song_artist_norm = normalize_artist_name(song_artist) # IMPORTANT : si Linercap est deja programme sur cette chanson, on n'arme # pas le liner artiste. Il pourra etre tente sur une chanson suivante. linercap_reserved_this_track = linercap_pending() and linercap_pending_track() == linercap_track_serial() if linerart_slot_allowed(slot) and meta["vrs_mix_allowed"] == "true" and bucket != linerart_last_bucket() and cue_in > LINERART_MARGIN and song_artist_norm != "" and not linercap_reserved_this_track then max_liner_duration = cue_in - LINERART_MARGIN - LINERART_DUCK_RAMP files = file.ls(absolute=true, pattern="*.mp3", LINERART_DIR) fitting = list.filter( (fun (filename) -> linerart_file_fits(song_artist_norm, max_liner_duration, filename)), files ) if list.length(fitting) > 0 then filename = list.pick(default="", fitting) liner_duration = null.get( default=0.0, request.duration(resolve_metadata=false, filename) ) start_at = cue_in - liner_duration - LINERART_MARGIN if filename != "" and liner_duration > 0.0 and start_at >= LINERART_DUCK_RAMP then linerart_pending := true linerart_pending_file := filename linerart_pending_start := start_at linerart_pending_duck_start := start_at - LINERART_DUCK_RAMP linerart_pending_track := serial log( label="linerartist", level=3, "LINER ARTISTE PREPARE slot " ^ string(slot) ^ " : artiste chanson='" ^ song_artist ^ "' ; fichier=" ^ filename ^ " ; ducking a " ^ string(start_at - LINERART_DUCK_RAMP) ^ " s ; depart liner a " ^ string(start_at) ^ " s ; duree " ^ string(liner_duration) ^ " s ; CUE_IN " ^ string(cue_in) ^ " s." ) end else log( label="linerartist", level=4, "LINER ARTISTE : aucun liner ARTIST='" ^ song_artist ^ "' assez court pour CUE_IN=" ^ string(cue_in) ^ " s ; attente du prochain titre admissible." ) end elsif linercap_reserved_this_track then log( label="linerartist", level=4, "LINER ARTISTE : Linercap deja programme sur cette chanson ; " ^ "report au prochain titre admissible." ) end end # Enregistre APRES prepare_linercap : ainsi on sait deja si cette chanson est # reservee par Linercap et on evite toute superposition de deux liners. PROGRAM.on_track( synchronous=true, prepare_linerartist ) def fire_linerartist_when_due() = if linerart_pending() and linerart_pending_track() == linerart_track_serial() then elapsed = PROGRAM.elapsed() if not linerart_ducking() and elapsed >= linerart_pending_duck_start() then linerart_ducking := true log( label="linerartist", level=3, "LINER ARTISTE DUCK START : descente musique vers -6 dB." ) end if elapsed >= linerart_pending_start() then bucket = linerart_bucket() if bucket != linerart_last_bucket() then filename = linerart_pending_file() if filename != "" then LINERART_QUEUE.push.uri("fullend:" ^ filename) linerart_last_bucket := bucket log( label="linerartist", level=3, "LINER ARTISTE ON AIR : " ^ filename ^ " ; musique a environ -6 dB ; fin prevue 0.3 s avant CUE_IN." ) end else linerart_ducking := false end linerart_pending := false linerart_pending_file := "" end end end PROGRAM.on_frame( synchronous=true, fire_linerartist_when_due ) def release_linerartist_duck(remaining, meta) = ignore(remaining) ignore(meta) linerart_ducking := false log( label="linerartist", level=3, "LINER ARTISTE DUCK END : remontee musique vers niveau normal." ) end LINERART_QUEUE.on_position( synchronous=false, remaining=true, allow_partial=true, position=0.0, release_linerartist_duck ) # La rotation musicale reste pilotee par PROGRAM.remaining() : # - avec CUE_OUT, le slot suivant demarre au CUE_OUT et le mix dure au plus 3 s ; # - sans CUE_OUT, la fin est conservee par la transition normale. # # IMPORTANT : AUCUN element non musical n'est avance ici. Les non-musicaux # avancent uniquement sur leur vraie fin via source.on_end(delay=0.0), plus bas. def advance_program_when_due() = slot = current_program_slot() if slot >= 0 and program_position() == slot and not program_reset_lock() and current_program_is_music() then remaining = PROGRAM.remaining() if current_program_mix_at_cue() and current_program_mix_duration() > 0.0 then d = current_program_mix_duration() if remaining >= 0.0 and remaining <= d then transition_tail_duration := 0.0 transition_mix_duration := d transition_mix_requested := true set_program_position(next_program_slot(slot)) end else # Musique sans CUE_OUT : conservation de la fin physique dans la transition. # Ce seuil ne concerne JAMAIS JESSY, jingles, infos, meteo, histoires, etc. music_end_arm = 0.5 if remaining >= 0.0 and remaining <= music_end_arm then transition_mix_requested := false transition_mix_duration := 0.0 transition_tail_duration := remaining log( label="rotation_music", level=3, "FIN MUSIQUE SLOT " ^ string(slot) ^ " : prochain slot " ^ string(next_program_slot(slot)) ^ " arme ; fin conservee (" ^ string(remaining) ^ " s)." ) set_program_position(next_program_slot(slot)) end end end end # AUTORITE UNIQUE D'AVANCEMENT DE LA ROTATION : # ce callback est le seul endroit qui passe de Pxx a Pxx+1 pendant la rotation. # Aucun on_track/on_metadata ne doit appeler set_program_position(). PROGRAM.on_frame( synchronous=true, advance_program_when_due ) # ------------------------------------------------------------ # GARDE-FOU ABSOLU : UN SEUL TITRE PAR SLOT MUSICAL # ------------------------------------------------------------ # Les playlists musicales sont continues. Si la petite fenetre remaining() # de fin de titre est ratee (notamment sans CUE_OUT), la playlist pourrait # demarrer un 2e titre du meme dossier. Ce callback de fin physique constitue # une seconde autorite uniquement si le pointeur n'a PAS deja avance. # Il ne peut donc pas doubler l'avancement normal au CUE_OUT. def music_source_matches_slot(source_name, slot) = if source_name == "ANNEES70" then slot == 20 or slot == 33 elsif source_name == "ANNEES80" then slot == 12 or slot == 23 or slot == 38 elsif source_name == "ANNEES90" then slot == 17 or slot == 29 or slot == 40 elsif source_name == "ANNEES2000" then slot == 6 or slot == 26 elsif source_name == "NOUVEAUTES" then slot == 4 or slot == 22 or slot == 35 or slot == 42 elsif source_name == "TITRESJESSY" then slot == 7 or slot == 18 or slot == 36 elsif source_name == "GRENIERJESSY" then slot == 28 else false end end def advance_music_exact_end(source_name, ended_remaining, ended_meta) = ignore(ended_remaining) ignore(ended_meta) slot = program_position() # Le callback n'agit que si le slot ACTUEL appartient encore a la source # qui vient de finir. Si le pointeur a deja avance au CUE_OUT, il ne fait rien. if slot >= 0 and music_source_matches_slot(source_name, slot) and current_program_slot() == slot and current_program_is_music() and not program_reset_lock() then transition_mix_requested := false transition_mix_duration := 0.0 transition_tail_duration := 0.0 log( label="rotation_music_failsafe", level=2, "GARDE-FOU FIN MUSIQUE " ^ source_name ^ " SLOT " ^ string(slot) ^ " : passage force au slot " ^ string(next_program_slot(slot)) ) set_program_position(next_program_slot(slot)) end end def end_annees70(r,m) = advance_music_exact_end("ANNEES70",r,m) end def end_annees80(r,m) = advance_music_exact_end("ANNEES80",r,m) end def end_annees90(r,m) = advance_music_exact_end("ANNEES90",r,m) end def end_annees2000(r,m) = advance_music_exact_end("ANNEES2000",r,m) end def end_nouveautes(r,m) = advance_music_exact_end("NOUVEAUTES",r,m) end def end_titresjessy(r,m) = advance_music_exact_end("TITRESJESSY",r,m) end def end_grenierjessy(r,m) = advance_music_exact_end("GRENIERJESSY",r,m) end ANNEES70.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_annees70) ANNEES80.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_annees80) ANNEES90.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_annees90) ANNEES2000.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_annees2000) NOUVEAUTES.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_nouveautes) TITRESJESSY.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_titresjessy) GRENIERJESSY.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_grenierjessy) # ------------------------------------------------------------ # FIN REELLE DES ELEMENTS NON MUSICAUX # ------------------------------------------------------------ # Ces sources sont request.dynamic one-shot : apres leur unique MP3, elles # deviennent indisponibles. source.on_end est donc attache a la SOURCE REELLE # et fait avancer le rotate seulement APRES la fin physique du fichier. # Aucun seuil remaining(), aucune anticipation, aucun chevauchement. def advance_non_music_exact(expected_slot, source_name, ended_remaining, ended_meta) = ignore(ended_remaining) ignore(ended_meta) if program_position() == expected_slot and not program_reset_lock() then transition_mix_requested := false transition_mix_duration := 0.0 transition_tail_duration := 0.0 log( label="non_music_end", level=3, "FIN REELLE SLOT " ^ string(expected_slot) ^ " " ^ source_name ^ " ; passage au slot " ^ string(next_program_slot(expected_slot)) ) set_program_position(next_program_slot(expected_slot)) end end def end_p00_jingleinfo(r, m) = advance_non_music_exact(0, "JINGLEINFO", r, m) end def end_p01_infosmagny(r, m) = advance_non_music_exact(1, "INFOSMAGNY", r, m) end def end_p02_merci(r, m) = advance_non_music_exact(2, "MERCI", r, m) end def end_p03_jinglesmix(r, m) = advance_non_music_exact(3, "JINGLESMIX", r, m) end def end_p08_lctmeteo(r, m) = advance_non_music_exact(8, "LCTMETEO", r, m) end def end_p09_meteo(r, m) = advance_non_music_exact(9, "METEO", r, m) end def end_p13_jinglehistoire(r, m) = advance_non_music_exact(13, "JINGLEHISTOIRE", r, m) end def end_p14_histoire(r, m) = advance_non_music_exact(14, "HISTOIRE", r, m) end def end_p27_jinglesgrenier(r, m) = advance_non_music_exact(27, "JINGLESGRENIER", r, m) end def end_p30_jingleinsolites(r, m) = advance_non_music_exact(30, "JINGLEINSOLITES", r, m) end def end_p31_insolites(r, m) = advance_non_music_exact(31, "INSOLITES", r, m) end JINGLEINFO.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p00_jingleinfo) INFOSMAGNY.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p01_infosmagny) MERCI.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p02_merci) JINGLESMIX.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p03_jinglesmix) LCTMETEO.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p08_lctmeteo) METEO.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p09_meteo) JINGLEHISTOIRE.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p13_jinglehistoire) HISTOIRE.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p14_histoire) JINGLESGRENIER.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p27_jinglesgrenier) JINGLEINSOLITES.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p30_jingleinsolites) INSOLITES.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_p31_insolites) # JESSY est partage par plusieurs slots. Le callback regarde le slot reel actif # au moment exact ou le fichier JESSY se termine. def end_jessy_exact(ended_remaining, ended_meta) = ignore(ended_remaining) ignore(ended_meta) slot = program_position() if is_jessy_slot(slot) and not program_reset_lock() then transition_mix_requested := false transition_mix_duration := 0.0 transition_tail_duration := 0.0 log(label="non_music_end", level=3, "FIN REELLE SLOT " ^ string(slot) ^ " JESSY ; passage au slot " ^ string(next_program_slot(slot))) set_program_position(next_program_slot(slot)) end end JESSY.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_jessy_exact) # Meme principe pour JINGLESCHANTES. def end_jingleschantes_exact(ended_remaining, ended_meta) = ignore(ended_remaining) ignore(ended_meta) slot = program_position() if is_jingleschantes_slot(slot) and not program_reset_lock() then transition_mix_requested := false transition_mix_duration := 0.0 transition_tail_duration := 0.0 log(label="non_music_end", level=3, "FIN REELLE SLOT " ^ string(slot) ^ " JINGLESCHANTES ; passage au slot " ^ string(next_program_slot(slot))) set_program_position(next_program_slot(slot)) end end JINGLESCHANTES.on_position(synchronous=true, remaining=true, allow_partial=true, position=0.0, end_jingleschantes_exact) # ------------------------------------------------------------ # NOTE 2026-08-18 : ancien callback on_position anti-blanc retire. # Il pouvait recevoir plusieurs fins de pistes internes dans la meme frame # et faire avancer plusieurs slots d'un coup, coupant notamment JESSY. # ------------------------------------------------------------ # ------------------------------------------------------------ # TOP HORAIRE QUOTIDIEN + REPRISE AU DEBUT DE LA ROTATION # ------------------------------------------------------------ # Chaque top est rendu indisponible pendant 23 heures apres sa diffusion. # Ainsi il ne peut jouer qu'une fois dans sa tranche horaire, mais il redevient # disponible le lendemain. Apres le top, le switch retombe naturellement sur # PROGRAM, dont le pointeur a ete remis a zero par update_hourly_metadata(). TOP00_DAILY = delay(82800.0, TOP00) TOP01_DAILY = delay(82800.0, TOP01) TOP02_DAILY = delay(82800.0, TOP02) TOP03_DAILY = delay(82800.0, TOP03) TOP04_DAILY = delay(82800.0, TOP04) TOP05_DAILY = delay(82800.0, TOP05) TOP06_DAILY = delay(82800.0, TOP06) TOP07_DAILY = delay(82800.0, TOP07) TOP08_DAILY = delay(82800.0, TOP08) TOP09_DAILY = delay(82800.0, TOP09) TOP10_DAILY = delay(82800.0, TOP10) TOP11_DAILY = delay(82800.0, TOP11) TOP12_DAILY = delay(82800.0, TOP12) TOP13_DAILY = delay(82800.0, TOP13) TOP14_DAILY = delay(82800.0, TOP14) TOP15_DAILY = delay(82800.0, TOP15) TOP16_DAILY = delay(82800.0, TOP16) TOP17_DAILY = delay(82800.0, TOP17) TOP18_DAILY = delay(82800.0, TOP18) TOP19_DAILY = delay(82800.0, TOP19) TOP20_DAILY = delay(82800.0, TOP20) TOP21_DAILY = delay(82800.0, TOP21) TOP22_DAILY = delay(82800.0, TOP22) TOP23_DAILY = delay(82800.0, TOP23) # La fenetre reste large comme dans la version precedente : le top devient # eligible a H-1 minute et reste eligible durant l'heure. S'il n'a pas encore # joue a H+1m30, la coupure forcee ne reste permise que sur la musique a >=65%. hourly_radio = switch( track_sensitive=true, [ ({23h59m-0h59m}, TOP00_DAILY), ({0h59m-1h59m}, TOP01_DAILY), ({1h59m-2h59m}, TOP02_DAILY), ({2h59m-3h59m}, TOP03_DAILY), ({3h59m-4h59m}, TOP04_DAILY), ({4h59m-5h59m}, TOP05_DAILY), ({5h59m-6h59m}, TOP06_DAILY), ({6h59m-7h59m}, TOP07_DAILY), ({7h59m-8h59m}, TOP08_DAILY), ({8h59m-9h59m}, TOP09_DAILY), ({9h59m-10h59m}, TOP10_DAILY), ({10h59m-11h59m}, TOP11_DAILY), ({11h59m-12h59m}, TOP12_DAILY), ({12h59m-13h59m}, TOP13_DAILY), ({13h59m-14h59m}, TOP14_DAILY), ({14h59m-15h59m}, TOP15_DAILY), ({15h59m-16h59m}, TOP16_DAILY), ({16h59m-17h59m}, TOP17_DAILY), ({17h59m-18h59m}, TOP18_DAILY), ({18h59m-19h59m}, TOP19_DAILY), ({19h59m-20h59m}, TOP20_DAILY), ({20h59m-21h59m}, TOP21_DAILY), ({21h59m-22h59m}, TOP22_DAILY), ({22h59m-23h59m}, TOP23_DAILY), ({true}, PROGRAM) ] ) top_done_hour = ref(-1) last_seen_hour = ref(-1) force_requested_hour = ref(-1) def update_hourly_metadata(meta) = announced_hour = meta["hourly_top_hour"] if announced_hour != "" then h = int_of_string(default=-1, announced_hour) top_done_hour := h force_requested_hour := -1 # RESET SIMPLE ET UNIQUE AU DEBUT DU TOP : # on rearme P00 sans appeler PROGRAM.skip(). Le TOP reste prioritaire dans # hourly_radio, donc PROGRAM ne consomme pas P00 pendant que le TOP joue. transition_mix_requested := false transition_mix_duration := 0.0 transition_tail_duration := 0.0 program_reset_lock := true # CORRECTION ROTATE STRICT 2026-09-29 : # le TOP doit ouvrir une NOUVELLE generation de P00. Sans cela, si P00 a # deja ete servi dans la generation courante juste avant le TOP, le one-shot # JINGLEINFO refuse de resservir et PROGRAM peut rester indisponible. # set_program_position(0) rearme donc P00 de facon atomique. set_program_position(0) log( label="top_horaire", level=3, "Top horaire " ^ string(h) ^ "h demarre : NOUVELLE generation et pointeur remis sur P00/JINGLEINFO." ) end end hourly_radio.on_metadata( synchronous=true, update_hourly_metadata ) # Aucun callback de fin de TOP n'est necessaire. # Le reset est deja effectue au debut du TOP, sans PROGRAM.skip(). # A la fin du TOP, hourly_radio retombe naturellement sur PROGRAM/P00. def check_hourly_deadline() = now = time.local() # Au changement d'heure, on conserve l'etat si le top a deja commence # pendant la minute precedente. Sinon, l'heure reste en attente de son top. if now.hour != last_seen_hour() then last_seen_hour := now.hour if top_done_hour() != now.hour then top_done_hour := -1 end force_requested_hour := -1 end deadline_reached = now.min > 1 or (now.min == 1 and now.sec >= 30) top_still_waiting = top_done_hour() != now.hour not_already_requested = force_requested_hour() != now.hour # V16e : # le controle de la musique en cours se fait directement sur PROGRAM. # Cela evite de dependre d'une metadata de hourly_radio qui peut rester # celle d'une transition precedente. # # - element non musical : jamais coupe ; le one-shot lui interdit seulement # d'enchainer un deuxieme fichier et hourly_radio peut alors prendre le TOP. # - musique : a partir de H+1m30, si >=65% ont ete joues, on force le passage # au TOP avec hourly_radio.skip(). if deadline_reached and top_still_waiting and not_already_requested and current_program_is_music() then track_duration = PROGRAM.duration() track_elapsed = PROGRAM.elapsed() if track_duration > 0.0 then played_ratio = track_elapsed / track_duration if played_ratio >= 0.65 then force_requested_hour := now.hour transition_mix_requested := false transition_mix_duration := 0.0 transition_tail_duration := 0.0 log( label="top_horaire", level=3, "TOP PRIORITAIRE : musique a " ^ string(played_ratio * 100.0) ^ "% ; lancement force du top de " ^ string(now.hour) ^ "h." ) hourly_radio.skip() end end end end hourly_radio.on_frame( synchronous=true, check_hourly_deadline ) # ------------------------------------------------------------ # ANTI-DOUBLE JESSY / JINGLESCHANTES : # gere a la source par program_generation + last_*_generation. # Aucun callback on_track ne fait avancer la rotation ; cela evite les doubles # increments responsables des slots sautes. # ------------------------------------------------------------ radio = hourly_radio # DUCKING COMMUN LINERCAP + LINER ARTISTE : seule la musique baisse. # Chaque moteur garde son propre bool d'activite. Si l'un des deux overlays est # actif, la musique vise environ -6 dB. Les deux overlays ne sont jamais programmes # simultanement sur la meme chanson. # La musique reste en premiere position dans add(), donc ses metadonnees restent # prioritaires vers Icecast/VRS. def intro_overlay_music_gain() = current = linercap_duck_gain() duck_active = linercap_ducking() or linerart_ducking() target = if duck_active then LINERCAP_DUCK_GAIN else 1.0 end step = (1.0 - LINERCAP_DUCK_GAIN) / 6.0 next = if current < target then min(1.0, current + step) elsif current > target then max(LINERCAP_DUCK_GAIN, current - step) else current end linercap_duck_gain := next next end radio_ducked = amplify( override=null, intro_overlay_music_gain, radio ) radio = add( normalize=false, weights=[1.0, 1.0, 1.0], [radio_ducked, LINERCAP_AUDIO, LINERART_AUDIO] ) # IMPORTANT 2026-08-18 - SUPPRESSION DU PIEGE A BLANC PERMANENT # Ne jamais entourer hourly_radio d'un fallback(track_sensitive=true, [radio, blank()]). # Si PROGRAM devient indisponible quelques millisecondes a une fin de piste, # ce fallback peut selectionner blank(). Comme blank() est continu et que le # fallback est track_sensitive, il peut rester sur le silence jusqu'au restart. # La securisation infaillible est deja faite plus bas avec mksafe(radio), qui # est l'operateur adapte pour garantir une sortie sans emprisonner la radio # dans une branche blank() permanente. # ------------------------------------------------------------ # STABILISATION AUDIO - AUCUN cross() # ------------------------------------------------------------ # Le chevauchement selectif est gere uniquement par la transition de PROGRAM. # TRAITEMENT AUDIO - VERSION PLUS TRANSPARENTE POUR MP3 128 KB/S # Objectif : homogénéiser les fichiers de niveaux différents sans écraser # les transitoires ni pousser inutilement l'encodeur MP3 dans le limiteur. radio = audio_to_stereo(radio) # Très léger gain d'entrée. L'ancienne valeur 1.32 poussait déjà le signal # d'environ +2,4 dB avant toute compression. radio = amplify(1.08, radio) # Etage 1 : nivellement lent et doux. # Il remonte légèrement les morceaux faibles et réduit les écarts de niveau # sans créer de pompage audible. radio = compress( attack=120.0, release=1800.0, threshold=-27.0, ratio=1.45, gain=2.4, radio ) # Etage 2 : contrôle des crêtes, plus rapide mais volontairement modéré. # Il évite que certains masters agressifs déclenchent constamment le limiteur. radio = compress( attack=20.0, release=320.0, threshold=-14.0, ratio=1.45, gain=0.6, radio ) # Le limiteur exige une source infaillible. # On securise le flux avant le limiteur. radio = mksafe(radio) # Marge de sécurité légèrement supérieure avant encodage MP3 128 kb/s. # Cela limite les surcharges et les artefacts sur les fichiers déjà très forts. radio = limit( attack=5.0, release=180.0, threshold=-1.2, radio ) # ------------------------------------------------------------ # METADONNEES POUR ICECAST ET L INTERFACE WEB # ------------------------------------------------------------ # Ne pas fabriquer de titre de secours ici : cela risquerait d'ecraser # les vrais tags artist/title au passage des jingles et des transitions. # Les metadata.map des sources conservent deja les tags ID3 avec strip=false. # Journalise chaque titre reellement remis a la sortie Icecast. def log_stream_metadata(meta) = log( label="metadata_icecast", level=3, "Titre envoye : " ^ meta["artist"] ^ (if meta["artist"] != "" then " - " else "" end) ^ meta["title"] ) end radio.on_metadata( synchronous=true, log_stream_metadata ) # SORTIE ICECAST output.icecast( %mp3(bitrate=128, samplerate=44100, stereo=true), encoding="UTF-8", host="stream.votreradiosurlenet.eu", port=8181, user="source", password="jerome", mount="/bienvivreamagny.mp3", send_icy_metadata=true, genre="Bien Vivre A Magny", name="Bien Vivre A Magny", description="Bien Vivre A Magny - toute l'actualite independante de Magny le Hongre Val d'Europe en Seine-et-Marne", url="https://bienvivreamagny.fr", radio )