feat: Zacus V3 complete
Phase 1: zacus_v3_complete.yaml, constants, phrases Phase 3: ESP-NOW framework, 4 puzzle firmwares, master Phase 4: NPC V3 profiling + game coordinator Phase 5: XTTS-v2 Docker + voice clone generator Phase 6: AudioCraft + BLE audio control Phase 9: One-pager, video script, pricing, guide
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# Guide de Déploiement — Le Mystère du Professeur Zacus
|
||||
|
||||
**Objectif :** Installation complète en 15 minutes sur n'importe quel lieu.
|
||||
**Prérequis :** Kit V3 complet (3 valises), batteries chargées à 100%.
|
||||
|
||||
---
|
||||
|
||||
## Avant de partir (30 min, à faire chez vous / au bureau)
|
||||
|
||||
### Vérification matérielle
|
||||
|
||||
- [ ] Charger toutes les batteries à 100 % (chargeur GaN 65W 4 ports, ~3h)
|
||||
- Anker #1 (hub) — 20 000 mAh
|
||||
- Anker #2 (spare) — 20 000 mAh
|
||||
- Pack puzzle A — 10 000 mAh
|
||||
- Pack puzzle B — 10 000 mAh
|
||||
- [ ] Allumer BOX-3 → vérifier firmware version (Settings → About → Version ≥ v3.1)
|
||||
- [ ] Allumer chaque puzzle ESP32 (LED verte = OK, LED rouge = recharger)
|
||||
- [ ] Compter les 12 tuiles NFC symboles alchimiques (P6)
|
||||
- [ ] Vérifier les 6 QR codes A5 plastifiés (aucun ne doit être plié ou illisible)
|
||||
|
||||
<!-- PHOTO: [batteries_charged.jpg] — 4 batteries posées côte à côte, toutes LED vertes -->
|
||||
|
||||
### Vérification logicielle
|
||||
|
||||
- [ ] BOX-3 → Settings → Scenario → `zacus_v3_complete` chargé
|
||||
- [ ] Pool TTS MP3 à jour (si nouvelles phrases ajoutées depuis la dernière session)
|
||||
```bash
|
||||
python3 tools/tts/generate_npc_pool.py --dry-run
|
||||
```
|
||||
- [ ] Test ESP-NOW mesh rapide (à la maison, avant transport) :
|
||||
```bash
|
||||
./tools/dev/zacus.sh espnow-test
|
||||
```
|
||||
Résultat attendu : `MESH: 6/6 OK`
|
||||
|
||||
---
|
||||
|
||||
## Sur place : Installation en 15 minutes
|
||||
|
||||
### Minutes 1–2 : Hub central (Valise 1)
|
||||
|
||||
<!-- PHOTO: [valise1_layout.jpg] — contenu de la valise 1 vue du dessus -->
|
||||
|
||||
- [ ] Poser RTC_PHONE sur la table d'accueil, brancher USB-C → Batterie #1
|
||||
- [ ] Poser BOX-3 sur mini-trépied, orienter caméra vers le centre de la pièce
|
||||
- [ ] Brancher BOX-3 USB-C → Batterie #1 (même batterie, hub USB-C 2 ports)
|
||||
- [ ] Brancher le routeur GL.iNet → Batterie #2 USB-C
|
||||
- [ ] Attendre les LED vertes sur les 3 appareils (~30 secondes)
|
||||
|
||||
**Contrôle visuel :** BOX-3 affiche « WAITING FOR MESH… »
|
||||
|
||||
<!-- PHOTO: [hub_setup.jpg] — RTC_PHONE + BOX-3 + routeur sur table, tous allumés -->
|
||||
|
||||
---
|
||||
|
||||
### Minutes 3–7 : Puzzles (Valise 2)
|
||||
|
||||
Placer chaque puzzle à sa position définie dans la salle (voir plan de salle ci-dessous).
|
||||
|
||||
<!-- PHOTO: [floor_plan.jpg] — schéma de placement des 7 puzzles dans une salle générique -->
|
||||
|
||||
- [ ] **P1 — Séquence Sonore** → table latérale, brancher USB-C → Pack A
|
||||
- [ ] **P2 — Breadboard Magnétique** → sol ou grande table, déplier, USB-C → Pack A
|
||||
- [ ] **P4 — Poste Radio** → rebord de fenêtre ou étagère (hauteur yeux)
|
||||
- [ ] **P5 — Télégraphe** → bureau avec la carte de référence morse posée à côté
|
||||
- [ ] **P6 — Tablette Symboles** → grande table, disposer les 12 tuiles NFC autour
|
||||
- [ ] **P7 — Coffre Final** → position centrale et bien visible, USB-C → Pack B, VERROU FERMÉ
|
||||
- [ ] Cacher les 6 QR codes A5 aux positions définies (voir carte de placement dans valise 1)
|
||||
|
||||
<!-- PHOTO: [p1_setup.jpg] — P1 boîte sonore sur table -->
|
||||
<!-- PHOTO: [p6_setup.jpg] — tablette symboles avec tuiles disposées autour -->
|
||||
<!-- PHOTO: [p7_setup.jpg] — coffre final position centrale, verrou visible -->
|
||||
|
||||
**Contrôle :** Chaque puzzle allumé = LED verte fixe. Si LED orange = calibration en cours (attendre 10s).
|
||||
|
||||
---
|
||||
|
||||
### Minutes 8–10 : Ambiance (Valise 3)
|
||||
|
||||
<!-- PHOTO: [valise3_layout.jpg] — enceinte BT + LED strip + signalétique -->
|
||||
|
||||
- [ ] Poser l'enceinte Bluetooth, maintenir le bouton pairing 3 secondes
|
||||
- [ ] Sur BOX-3 → Settings → Bluetooth → connecter l'enceinte (nom : `ZACUS-SPK`)
|
||||
- [ ] Dérouler le ruban LED USB (3m), brancher → Port USB de la batterie #2
|
||||
- Couleur : bleu/vert pour ambiance laboratoire
|
||||
- [ ] Installer les 4 panneaux de signalétique :
|
||||
- Entrée : « Laboratoire du Professeur Zacus — Accès Restreint »
|
||||
- Règles du jeu (format A3, plastifié)
|
||||
- Zones puzzle : numérotées P1 à P7
|
||||
- Sortie de secours : toujours visible, non obstrué
|
||||
|
||||
<!-- PHOTO: [ambiance_setup.jpg] — salle vue de l'entrée avec éclairage ambiance -->
|
||||
|
||||
---
|
||||
|
||||
### Minutes 11–13 : Vérification du mesh ESP-NOW
|
||||
|
||||
- [ ] BOX-3 écran principal → attendre « MESH: 6/6 CONNECTED »
|
||||
- Si un nœud manquant après 90 secondes : appuyer RESET sur ce puzzle ESP32
|
||||
- Si toujours absent : vérifier LED — éteint = pas alimenté, rouge = erreur firmware
|
||||
- [ ] Test fonctionnel rapide :
|
||||
- Appuyer sur un bouton de P1 → BOX-3 doit afficher l'événement reçu
|
||||
- Taper le clé P5 → BOX-3 doit afficher le code morse reçu
|
||||
|
||||
<!-- PHOTO: [box3_mesh_ok.jpg] — écran BOX-3 montrant "MESH: 6/6 CONNECTED" -->
|
||||
|
||||
---
|
||||
|
||||
### Minutes 14–15 : Configuration game master
|
||||
|
||||
- [ ] BOX-3 touchscreen → **Settings**
|
||||
- **Target Duration** → sélectionner [30 / 45 / 60 / 75 / 90 min] selon le groupe
|
||||
- **WiFi** → entrer le WiFi du lieu (pour XTTS live) **OU** → **Offline Mode** (MP3 SD)
|
||||
- **Volume NPC** → ajuster selon la taille de la salle (défaut : 70%)
|
||||
- [ ] Appuyer **READY** → autotest 30 secondes → tous les puzzles en vert
|
||||
- [ ] Briefing game master : mémoriser les 3 niveaux d'indices par puzzle
|
||||
|
||||
<!-- PHOTO: [box3_settings.jpg] — écran BOX-3 menu Settings ouvert -->
|
||||
<!-- PHOTO: [box3_ready.jpg] — écran BOX-3 "ALL SYSTEMS GO" -->
|
||||
|
||||
**La session peut démarrer. Appuyer sur START GAME.**
|
||||
|
||||
---
|
||||
|
||||
## Démarrage du jeu
|
||||
|
||||
1. Faire entrer les joueurs dans la pièce (lumières éteintes ou tamisées)
|
||||
2. La sonnerie du RTC_PHONE retentit automatiquement après 10 secondes
|
||||
3. Les joueurs décrochent → Professeur Zacus parle → jeu commence
|
||||
|
||||
---
|
||||
|
||||
## Pendant le jeu — Rôle du game master
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Joueurs bloqués > 5 min | BOX-3 → Hint → sélectionner puzzle → niveau 1 |
|
||||
| Puzzle ESP32 déconnecté | Appuyer RESET sur le puzzle, reconnecter sous 30s |
|
||||
| Enceinte BT déconnectée | BOX-3 → Bluetooth → Reconnect |
|
||||
| Panne réseau WiFi | BOX-3 bascule automatiquement en mode offline |
|
||||
| Urgence (évacuation) | BOX-3 → PAUSE GAME → sortie sécurisée |
|
||||
|
||||
---
|
||||
|
||||
## Rangement (10 minutes)
|
||||
|
||||
- [ ] BOX-3 → **End Game** → **Export Stats** (PDF ou QR code)
|
||||
- [ ] Partager le score/diplôme avec les joueurs (QR ou lien)
|
||||
- [ ] Éteindre tous les puzzles (débrancher USB-C)
|
||||
- [ ] **Compter 12 tuiles NFC** avant de les ranger (ne jamais en oublier)
|
||||
- [ ] Récupérer les 6 QR codes A5
|
||||
- [ ] Emballer dans l'ordre inverse (puzzles → hub → ambiance)
|
||||
- [ ] Recharger toutes les batteries dès retour (ne pas stocker à plat)
|
||||
|
||||
---
|
||||
|
||||
## Dépannage rapide
|
||||
|
||||
| Symptôme | Cause probable | Solution |
|
||||
|----------|---------------|----------|
|
||||
| Nœud ESP32 absent du mesh | Firmware crashé | RESET sur le puzzle |
|
||||
| Voix NPC muette | WiFi hors portée | Basculer Offline Mode |
|
||||
| Enceinte BT grésillements | Distance > 10m | Rapprocher l'enceinte |
|
||||
| BOX-3 ne démarre pas | Batterie < 5% | Brancher USB-C direct (pas batterie) |
|
||||
| QR code non scanné | Angle ou distance | Ajuster la caméra BOX-3, recoller le QR |
|
||||
| Score non calculé | P7 non résolu | Vérifier que le coffre est bien verrouillé au départ |
|
||||
|
||||
---
|
||||
|
||||
## Contact support
|
||||
|
||||
- Email : clement@lelectronrare.fr
|
||||
- Urgence session : +33 6 XX XX XX XX (disponible pendant sessions avec préavis)
|
||||
- Documentation complète : docs/DEPLOYMENT_RUNBOOK.md
|
||||
- Issues firmware : github.com/electron-rare/le-mystere-professeur-zacus/issues
|
||||
@@ -0,0 +1,101 @@
|
||||
# One-Pager : Le Mystère du Professeur Zacus
|
||||
|
||||
<!-- To print: open in browser, use print-to-PDF, A4 recto-verso -->
|
||||
|
||||
---
|
||||
|
||||
## RECTO
|
||||
|
||||
**[LOGO] L'Electron Rare**
|
||||
*Solutions technologiques créatives*
|
||||
|
||||
---
|
||||
|
||||
# Le Mystère du Professeur Zacus
|
||||
|
||||
### *L'escape room portable, adaptative, et alimentée par l'IA*
|
||||
|
||||
---
|
||||
|
||||
**7 énigmes physiques** — Séquence sonore, circuit LED, fréquence radio,
|
||||
code morse, symboles alchimiques, QR Treasure, coffre final verrouillé.
|
||||
|
||||
**IA adaptative** — Le Professeur profile votre groupe (tech / non-tech) en 10 minutes
|
||||
et ajuste la difficulté en temps réel. Chaque groupe vit une expérience calibrée.
|
||||
|
||||
**Voix clonée** — Professeur Zacus parle en direct via IA vocale (XTTS-v2 sur GPU)
|
||||
avec fallback Piper TTS — toujours disponible, même sans connexion internet.
|
||||
|
||||
**3 valises** — Déployable en 15 minutes, partout, sans technicien. Offline-first.
|
||||
|
||||
---
|
||||
|
||||
| | **Animation** | **Location** | **Kit** |
|
||||
|---|---|---|---|
|
||||
| **Prix** | 800 – 1 500 €/séance | 300 – 500 €/week-end | 3 000 – 5 000 € |
|
||||
| **Joueurs** | 4 – 15 | 4 – 15 | 4 – 15 |
|
||||
| **Durée** | 2 – 3h sur place | Vendredi → Lundi | Achat définitif |
|
||||
| **Inclus** | Animateur + débrief | Guide + support tél. | Firmware + 3 scénarios + 1h formation |
|
||||
| **Idéal pour** | Teambuilding, événements | Associations, anniversaires | Escape rooms, musées |
|
||||
| **CTA** | Demander un devis | Réserver | Acheter |
|
||||
|
||||
---
|
||||
|
||||
> *"Le Professeur nous a bluffés. On ne savait pas que c'était une IA."*
|
||||
> — Groupe bêta, session de test 2026
|
||||
|
||||
<!-- Add second testimonial after beta playtest Group 2 -->
|
||||
<!-- > *"Parfaitement adapté à notre groupe. Ni trop facile, ni trop difficile."* -->
|
||||
<!-- > — Groupe non-tech, bêta 2026 -->
|
||||
|
||||
---
|
||||
|
||||
**Contact :** clement@lelectronrare.fr | lelectronrare.fr/zacus | +33 6 XX XX XX XX
|
||||
|
||||
---
|
||||
|
||||
## VERSO — Fiche technique (Kit buyers)
|
||||
|
||||
### Architecture technique
|
||||
|
||||
| Composant | Détail |
|
||||
|-----------|--------|
|
||||
| Hub | BOX-3 (ESP32-S3) orchestrateur + RTC_PHONE custom (voix) |
|
||||
| Communication | ESP-NOW mesh offline-first, 7 nœuds |
|
||||
| TTS | XTTS-v2 (GPU) → Piper TTS → MP3 SD card (3 niveaux de fallback) |
|
||||
| Audio ambiance | 6 pistes pré-générées AudioCraft MusicGen (RTX 4090) |
|
||||
| Firmware | ESP-IDF, C, FreeRTOS, PlatformIO — open source MIT |
|
||||
| Scénarios | YAML → Runtime 3 IR — compilateur Python inclus |
|
||||
|
||||
### BOM indicatif
|
||||
|
||||
| Catégorie | Coût estimé |
|
||||
|-----------|-------------|
|
||||
| Électronique (ESP32 × 9, capteurs, relais) | ~280 € |
|
||||
| Boîtiers 3D imprimés ABS + découpe laser | ~120 € |
|
||||
| 3 valises Peli ou équivalent | ~180 € |
|
||||
| Câbles, batteries, accessoires | ~62 € |
|
||||
| **Total BOM** | **~642 €** |
|
||||
| **Marge Kit Standard (3 500 €)** | **~83 %** |
|
||||
|
||||
### Robustesse & support
|
||||
|
||||
- Conçu pour **>100 déploiements** (boîtiers industriels, contacts dorés)
|
||||
- Composants standards : Reed switches, NTAG213, SG90, ESP32-S3
|
||||
- Mises à jour firmware OTA via WiFi ou USB-C — **1 an inclus** dans le kit
|
||||
- Documentation complète + guide de déploiement 15 minutes
|
||||
- Support premium disponible : 500 €/an (téléphone + 2 scénarios supplémentaires)
|
||||
|
||||
### Scénarios disponibles
|
||||
|
||||
| Scénario | Thème | Durée cible |
|
||||
|----------|-------|-------------|
|
||||
| zacus_v3_complete | Laboratoire du Professeur (7 énigmes) | 30 – 90 min |
|
||||
| *(en développement)* | Bibliothèque mystérieuse | Q3 2026 |
|
||||
| *(en développement)* | Vaisseau spatial | Q4 2026 |
|
||||
|
||||
---
|
||||
|
||||
**Contact commercial :** clement@lelectronrare.fr
|
||||
**Site :** lelectronrare.fr/zacus
|
||||
**GitHub :** github.com/electron-rare/le-mystere-professeur-zacus
|
||||
@@ -0,0 +1,160 @@
|
||||
# Grille Tarifaire — Le Mystère du Professeur Zacus
|
||||
|
||||
*Tarifs HT. TVA 20% applicable selon statut client.*
|
||||
*Mis à jour : 2026-04-02*
|
||||
|
||||
---
|
||||
|
||||
## Les 3 Tiers
|
||||
|
||||
### Tier 1 — Animation
|
||||
|
||||
> **Le Professeur vient chez vous.** Clé en main : installation, animation, débrief.
|
||||
|
||||
| Formule | Prix HT | Détail |
|
||||
|---------|---------|--------|
|
||||
| Demi-journée (2–3h session) | **800 €** | 1 session de jeu, 4–15 joueurs |
|
||||
| Journée complète (2 sessions) | **1 500 €** | 2 sessions back-to-back (matin + après-midi) |
|
||||
| Soirée d'entreprise (1 session + cocktail) | **1 200 €** | Session de jeu + 30 min de débrief convivial |
|
||||
|
||||
**Inclus :**
|
||||
- Déplacement dans un rayon de 50 km (Île-de-France base)
|
||||
- Installation et rangement du kit (non compté dans la durée payante)
|
||||
- Animation complète par un formateur certifié
|
||||
- Diplôme numérique PDF pour chaque joueur
|
||||
- Compte-rendu statistique post-session (temps, score, indices utilisés)
|
||||
- Adaptation TECH / NON_TECH automatique (profiling IA)
|
||||
|
||||
**Frais additionnels :**
|
||||
- Déplacement > 50 km : +0,45 €/km aller-retour + hébergement si nécessaire
|
||||
- 3e session dans la journée : +500 €
|
||||
- Week-end / jours fériés : +20 %
|
||||
- Remise corporate (>3 sessions/an) : -10 %
|
||||
|
||||
---
|
||||
|
||||
### Tier 2 — Location
|
||||
|
||||
> **Le kit chez vous, le week-end.** Pour associations, anniversaires, séminaires résidentiels.
|
||||
|
||||
| Formule | Prix HT | Durée |
|
||||
|---------|---------|-------|
|
||||
| Week-end | **400 €** | Vendredi 18h → Lundi 10h |
|
||||
| Semaine | **650 €** | 7 jours |
|
||||
|
||||
**Inclus :**
|
||||
- Kit V3 complet (3 valises, 7 puzzles, hub, ambiance)
|
||||
- Guide de déploiement 15 minutes (papier + vidéo QR)
|
||||
- Support téléphonique pendant la location (heures ouvrées)
|
||||
- Formation vidéo game master (30 min, lien envoyé à J-3)
|
||||
- 3 scénarios chargés : `zacus_v3_complete` + 2 variantes de durée (30 min et 90 min)
|
||||
|
||||
**Caution :** 1 000 € (chèque ou CB pré-autorisé, restitué sans frais si kit rendu intact)
|
||||
|
||||
**Frais additionnels :**
|
||||
- Livraison/récupération > 50 km : +0,45 €/km A/R
|
||||
- Extension location : +60 €/jour supplémentaire
|
||||
- Remplacement pièce endommagée : coût pièce + 30 € de gestion
|
||||
|
||||
**Non inclus :** Animation humaine (prévoir une personne formée via la vidéo game master)
|
||||
|
||||
---
|
||||
|
||||
### Tier 3 — Kit (Achat définitif)
|
||||
|
||||
> **Votre propre Professeur Zacus.** Pour escape rooms professionnelles, musées, centres de loisirs.
|
||||
|
||||
| Référence | Prix HT | Contenu |
|
||||
|-----------|---------|---------|
|
||||
| **ZACUS-KIT-STD** | **3 500 €** | Kit Standard |
|
||||
| **ZACUS-KIT-PRO** | **4 500 €** | Kit Pro (+ support premium 1 an) |
|
||||
| **ZACUS-KIT-ULTIMATE** | **5 000 €** | Kit Ultimate (+ 5 scénarios + formation 4h) |
|
||||
|
||||
#### Kit Standard — 3 500 € HT
|
||||
|
||||
- 3 valises déployées et testées (7 puzzles + hub + ambiance)
|
||||
- Firmware open source — licence MIT
|
||||
- 3 scénarios YAML inclus (`zacus_v3_complete` + 2 variantes)
|
||||
- 1h de formation à distance (visio, prise en main complète)
|
||||
- Accès aux mises à jour firmware pendant **1 an** (OTA ou USB-C)
|
||||
- Documentation complète : DEPLOYMENT_RUNBOOK.md, code source commenté
|
||||
- Pool TTS pré-généré (>200 phrases, Piper TTS + XTTS-v2)
|
||||
|
||||
#### Kit Pro — 4 500 € HT
|
||||
|
||||
Tout le Standard +
|
||||
- Support téléphonique prioritaire pendant **1 an** (réponse < 4h en semaine)
|
||||
- 2 scénarios supplémentaires au choix (sur devis ou catalogue)
|
||||
- Session de formation présentielle 2h (ou 4h à distance)
|
||||
- Kit de pièces de rechange : 2× ESP32-S3 dev kit, 10× NTAG213, 2× SG90
|
||||
|
||||
#### Kit Ultimate — 5 000 € HT
|
||||
|
||||
Tout le Pro +
|
||||
- 5 scénarios YAML personnalisables
|
||||
- Formation approfondie 4h présentielle (game design + firmware de base)
|
||||
- Branding personnalisé : nom du professeur, voix personnalisée (enregistrement 20 min requis)
|
||||
- 1 session d'animation offerte pour inauguration
|
||||
|
||||
---
|
||||
|
||||
## Options et add-ons
|
||||
|
||||
| Référence | Prix HT | Détail |
|
||||
|-----------|---------|--------|
|
||||
| **ZACUS-SCENARIO** | 200 € | Scénario supplémentaire (YAML livré, compilé, testé) |
|
||||
| **ZACUS-SUPPORT-AN** | 500 €/an | Support premium annuel (renouvellement après 1 an inclus) |
|
||||
| **ZACUS-VOICE-CLONE** | 300 € | Clone vocal personnalisé XTTS-v2 (votre propre professeur) |
|
||||
| **ZACUS-BRANDING** | 150 € | Personnalisation visuelle BOX-3 (logo, couleurs, nom) |
|
||||
| **ZACUS-TRAINING-2H** | 200 € | Formation game master supplémentaire (2h visio) |
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif business
|
||||
|
||||
### Analyse de rentabilité (Kit Standard)
|
||||
|
||||
| Poste | Valeur |
|
||||
|-------|--------|
|
||||
| BOM total | ~642 € |
|
||||
| Prix de vente Kit Standard | 3 500 € HT |
|
||||
| **Marge brute** | **~2 858 € (82 %)** |
|
||||
| Temps de fabrication / formation | ~5h |
|
||||
| Coût horaire valorisé | ~571 €/h |
|
||||
|
||||
### Projections revenus annuels (estimatif)
|
||||
|
||||
| Scénario | Sessions Animation | Locations | Kits | CA estimé HT |
|
||||
|----------|--------------------|-----------|------|--------------|
|
||||
| **Minimal** | 4/mois × 800 € | 2/mois × 400 € | 0 | ~57 600 € |
|
||||
| **Réaliste** | 6/mois × 1 000 € | 4/mois × 400 € | 2/trimestre × 3 500 € | ~103 200 € |
|
||||
| **Ambitieux** | 10/mois × 1 200 € | 8/mois × 400 € | 2/mois × 4 000 € | ~241 600 € |
|
||||
|
||||
### Revenus récurrents (SaaS-like)
|
||||
|
||||
| Client | Montant annuel HT |
|
||||
|--------|-------------------|
|
||||
| Support premium (par client Kit) | 500 € |
|
||||
| 1 scénario/an par client Kit | 200 € |
|
||||
| Renouvellement location régulier (asso) | ~1 200 € |
|
||||
|
||||
---
|
||||
|
||||
## Conditions générales
|
||||
|
||||
- **Devis valable 30 jours** à compter de la date d'émission
|
||||
- **Acompte :** 30 % à la commande, solde à la livraison/prestation
|
||||
- **Paiement :** Virement bancaire, chèque, CB (via Stripe)
|
||||
- **Délai de livraison Kit :** 3 semaines (assemblage + tests + expédition)
|
||||
- **Annulation Animation :** remboursement 100 % si > 7 jours, 50 % si 2–7 jours, 0 % si < 48h
|
||||
- **Annulation Location :** remboursement 100 % si > 5 jours, 50 % si 2–5 jours
|
||||
|
||||
---
|
||||
|
||||
## Contact commercial
|
||||
|
||||
**Clément Saillant — L'Electron Rare**
|
||||
clement@lelectronrare.fr | lelectronrare.fr/zacus | +33 6 XX XX XX XX
|
||||
|
||||
*SIRET : XX XXX XXX XXXXX — TVA intracommunautaire : FR XX XXXXXXXXX*
|
||||
*Auto-entrepreneur / Micro-entreprise (seuil de franchise TVA selon chiffre d'affaires)*
|
||||
@@ -0,0 +1,167 @@
|
||||
# Script Vidéo de Démonstration — Le Mystère du Professeur Zacus
|
||||
|
||||
**Durée cible :** 2:30
|
||||
**Format :** Shot-by-shot, playthrough réel
|
||||
**Ton :** Mystérieux → engageant → impressionnant → actionnable
|
||||
**Équipement recommandé :** Smartphone 4K ou mirrorless, trépied, micro-cravate, éclairage d'ambiance bleu/vert
|
||||
|
||||
---
|
||||
|
||||
## 0:00 – 0:15 | HOOK — Le laboratoire s'éveille
|
||||
|
||||
**Cadrage :** Plan large, pièce sombre. Seules quelques LED vertes clignotent sur les puzzles.
|
||||
|
||||
**Action :** Un téléphone vintage sur une table sonne. Personne ne répond pendant 3 secondes.
|
||||
|
||||
**Audio :** Sonnerie rétro, puis voix du Professeur Zacus (XTTS-v2) :
|
||||
> *"Bienvenue dans mon laboratoire portable… Si vous entendez ceci, c'est que j'ai besoin de vous."*
|
||||
|
||||
**Texte overlay :** « Le Mystère du Professeur Zacus » (titre, font serif, fade in)
|
||||
|
||||
---
|
||||
|
||||
## 0:15 – 0:30 | SETUP — 15 minutes, n'importe où
|
||||
|
||||
**Cadrage :** Time-lapse à 8× — vue de dessus d'une table vide.
|
||||
|
||||
**Action :** Les 3 valises s'ouvrent, le matériel se déploie, les LED s'allument une par une.
|
||||
Dernier plan : 7 puzzles en place, BOX-3 affiche « MESH: 6/6 CONNECTED ».
|
||||
|
||||
**Audio :** Musique upbeat légère (piste AudioCraft `lab_ambiance`)
|
||||
|
||||
**Texte overlay :** « Déploiement : 15 minutes. Partout. »
|
||||
|
||||
---
|
||||
|
||||
## 0:30 – 1:00 | PUZZLES — La richesse physique
|
||||
|
||||
Série de coupes rapides (chaque puzzle ~4 secondes) :
|
||||
|
||||
**P1 — Séquence Sonore** (0:30)
|
||||
- Plan : 4 boutons arcade illuminés sur une boîte en bois
|
||||
- Action : Mélodie jouée, joueur reproduit la séquence, LED verte confirme
|
||||
- Son : Bips tonals + rire de surprise du joueur
|
||||
|
||||
**P2 — Circuit LED** (0:34)
|
||||
- Plan : Breadboard magnétique 60×40 cm posée au sol
|
||||
- Action : Main place un composant, le circuit s'allume
|
||||
- Son : Clic magnétique satisfaisant
|
||||
|
||||
**P5 — Code Morse** (0:38)
|
||||
- Plan : Gros plan sur le télégraphe en bois et laiton
|
||||
- Action : Doigt tape en rythme, BOX-3 affiche les lettres décodées en temps réel
|
||||
- Son : Tap tap tap... pause... tap (rythme morse de « ZACUS »)
|
||||
|
||||
**P6 — Symboles Alchimiques** (0:44)
|
||||
- Plan : Tablette laser-cut 30×20 cm, 12 tuiles NFC autour
|
||||
- Action : Joueur place la 9e tuile, NFC bipe, symbole s'illumine sur la tablette
|
||||
- Son : Bip NFC + accord harmonique montant
|
||||
|
||||
**P7 — Coffre Final** (0:50)
|
||||
- Plan : Coffre en bois centre scène, pad numérique
|
||||
- Action : Équipe entre le code à 8 chiffres ensemble, servo claque, couvercle s'ouvre
|
||||
- Son : Click mécanique + musique victoire (piste `victory`)
|
||||
|
||||
**Audio global :** Sons d'ambiance lab + réactions authentiques des joueurs
|
||||
|
||||
---
|
||||
|
||||
## 1:00 – 1:30 | IA ADAPTATIVE — Le Professeur vous observe
|
||||
|
||||
**Cadrage :** Split-screen gauche/droite
|
||||
|
||||
**Gauche — Groupe TECH :**
|
||||
- Joueurs en costume décontracté (profil ingénieurs)
|
||||
- Résolvent P2 Circuit en 2 minutes → BOX-3 classe « TECH »
|
||||
- Profil affiché en encart : icône engrenage, « TECH »
|
||||
|
||||
**Droite — Groupe NON_TECH :**
|
||||
- Joueurs variés (famille, asso)
|
||||
- P2 Circuit plus lent → profil « NON_TECH »
|
||||
- Ils reçoivent P6 Symboles en premier à la place
|
||||
|
||||
**Audio :** Voix Professeur :
|
||||
> *"Groupe technique détecté. Excellente nouvelle… J'ai quelque chose de plus… stimulant pour vous."*
|
||||
|
||||
**Texte overlay :** « Adapté à chaque groupe en temps réel »
|
||||
|
||||
---
|
||||
|
||||
## 1:30 – 1:50 | VOIX CLONÉE — L'IA qui parle
|
||||
|
||||
**Cadrage :** Gros plan sur l'écran BOX-3 montrant la forme d'onde TTS en direct.
|
||||
|
||||
**Action :** Animation de la forme d'onde audio pendant que la voix parle.
|
||||
|
||||
**Audio :** Voix Professeur (XTTS-v2 live) :
|
||||
> *"Vous allez trop vite. J'ai décidé d'ajouter… une petite surprise."*
|
||||
> (ton légèrement malicieux, naturaliste)
|
||||
|
||||
**Texte overlay :** « Voix clonée par IA — indiscernable du réel »
|
||||
|
||||
**Cut :** Réaction d'un joueur qui regarde le téléphone : « C'est vraiment une IA ?! »
|
||||
|
||||
---
|
||||
|
||||
## 1:50 – 2:10 | VICTOIRE + SCORE
|
||||
|
||||
**Cadrage :** Plan large de toute l'équipe autour du coffre ouvert.
|
||||
|
||||
**Action :**
|
||||
- Coffre s'ouvre
|
||||
- Score affiché sur BOX-3 : 1 340 / 1 600 pts
|
||||
- Diplôme PDF généré automatiquement
|
||||
- High-five de l'équipe
|
||||
|
||||
**Audio :** Fanfare victoire (`victory.wav` — AudioCraft)
|
||||
|
||||
**Texte overlay :** « NPS > 9/10 en bêta »
|
||||
|
||||
---
|
||||
|
||||
## 2:10 – 2:30 | CTA — Trois façons de jouer
|
||||
|
||||
**Cadrage :** 3 cartes flottantes (animation CSS-style) sur fond sombre.
|
||||
|
||||
**Cartes :**
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ ANIMATION │ │ LOCATION │ │ KIT │
|
||||
│ 800 – 1 500 € │ │ 300 – 500 €/WE │ │ 3 000 – 5 000 € │
|
||||
│ par séance │ │ │ │ achat définitif│
|
||||
│ │ │ │ │ │
|
||||
│ [Devis rapide] │ │ [Réserver] │ │ [Acheter] │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
**Audio :** Piano simple et chaleureux
|
||||
|
||||
**Texte final :**
|
||||
```
|
||||
À partir de 800 € — lelectronrare.fr/zacus
|
||||
clement@lelectronrare.fr
|
||||
```
|
||||
|
||||
**Dernier plan :** Logo L'Electron Rare + fade to black (0.5s)
|
||||
|
||||
---
|
||||
|
||||
## Notes de tournage
|
||||
|
||||
| Scène | Lieu suggéré | Éclairage | Durée tournage |
|
||||
|-------|--------------|-----------|----------------|
|
||||
| Hook (téléphone) | Salon/bureau sombre | LEDs vertes seulement | 15 min |
|
||||
| Setup time-lapse | Salle de réunion vide | Lumière naturelle | 1h (y compris vrai setup) |
|
||||
| Puzzles | Salle de test | Éclairage d'ambiance bleu | 2h |
|
||||
| Split-screen IA | 2 sessions distinctes | Identique des 2 côtés | 30 min chaque |
|
||||
| Voix clonée | Gros plan BOX-3 | Rétro-éclairage écran | 15 min |
|
||||
| Victoire | Même salle puzzles | Lumière chaude pour célébration | 30 min |
|
||||
| CTA | Post-production | N/A (motion design) | — |
|
||||
|
||||
**Post-production :**
|
||||
- Sous-titres FR (auto-générés + correction)
|
||||
- Musique : pistes AudioCraft générées (`generate_tracks.py`)
|
||||
- Color grading : tons froids/teal pour lab, chaud/doré pour victoire
|
||||
- Export : 1080p MP4 H.264 pour web + 4K master
|
||||
|
||||
**Approbation requise :** Vérifier que la voix XTTS est perçue comme naturelle sur tous les plans audio avant montage final.
|
||||
@@ -475,3 +475,181 @@ endings:
|
||||
key: "endings.many_hints.0"
|
||||
- text: "Les indices sont là pour aider, et vous les avez bien utilisés !"
|
||||
key: "endings.many_hints.1"
|
||||
|
||||
# ============================================================
|
||||
# V3 PUZZLE-SPECIFIC HINTS (P1–P7)
|
||||
# ============================================================
|
||||
P1_SON:
|
||||
level_1:
|
||||
- text: "La musique a une mémoire, dit-on. Le professeur en est convaincu. Écoutez bien… puis répétez."
|
||||
key: "hints.P1_SON.level_1.0"
|
||||
- text: "Quatre boutons. Quatre notes. Un seul ordre correct. Votre oreille est votre meilleur instrument."
|
||||
key: "hints.P1_SON.level_1.1"
|
||||
level_2:
|
||||
- text: "La séquence commence toujours par le bouton ROUGE. Le reste suit une logique musicale simple."
|
||||
key: "hints.P1_SON.level_2.0"
|
||||
- text: "Vous avez entendu la mélodie ? Elle se rejoue si vous attendez 10 secondes. Prenez des notes !"
|
||||
key: "hints.P1_SON.level_2.1"
|
||||
level_3:
|
||||
- text: "Rouge, Bleu, Jaune, Rouge, Vert. Dans cet ordre exact. Appuyez lentement et attendez le bip de confirmation."
|
||||
key: "hints.P1_SON.level_3.0"
|
||||
|
||||
P2_CIRCUIT:
|
||||
level_1:
|
||||
- text: "L'électricité aime les chemins fermés. Si la LED ne brille pas… le circuit n'est pas complet."
|
||||
key: "hints.P2_CIRCUIT.level_1.0"
|
||||
- text: "Regardez les picots magnétiques. Certains sont ronds, d'autres sont carrés. Ce n'est pas un hasard."
|
||||
key: "hints.P2_CIRCUIT.level_1.1"
|
||||
level_2:
|
||||
- text: "Il faut une résistance entre la pile et la LED. Sans quoi… *explosion dramatique* … enfin, presque."
|
||||
key: "hints.P2_CIRCUIT.level_2.0"
|
||||
- text: "Le schéma est inscrit sous le panneau en petits caractères. Cherchez bien. J'y ai mis des heures !"
|
||||
key: "hints.P2_CIRCUIT.level_2.1"
|
||||
level_3:
|
||||
- text: "Pile → fil rouge → résistance 220Ω → LED (longue patte vers la résistance) → fil noir → pile. C'est tout !"
|
||||
key: "hints.P2_CIRCUIT.level_3.0"
|
||||
|
||||
P3_QR:
|
||||
level_1:
|
||||
- text: "Six enveloppes. Six secrets. Mais l'ordre… l'ordre est gravé dans les étoiles. Ou dans ce laboratoire."
|
||||
key: "hints.P3_QR.level_1.0"
|
||||
- text: "Chaque QR code porte un numéro invisible à l'œil nu. Regardez sous chaque carte."
|
||||
key: "hints.P3_QR.level_1.1"
|
||||
level_2:
|
||||
- text: "L'ordre est: numéros croissants des cachettes. La cachette 1 est toujours visible depuis l'entrée."
|
||||
key: "hints.P3_QR.level_2.0"
|
||||
- text: "Commencez par scanner le QR code collé sur la porte d'entrée. C'est le numéro 1."
|
||||
key: "hints.P3_QR.level_2.1"
|
||||
level_3:
|
||||
- text: "Ordre: porte → étagère gauche → tiroir rouge → sous la table → fenêtre → boîte bleue. Scannez avec BOX-3."
|
||||
key: "hints.P3_QR.level_3.0"
|
||||
|
||||
P4_RADIO:
|
||||
level_1:
|
||||
- text: "Les ondes radio… fascinantes ! Ma fréquence préférée se trouve quelque part entre 800 et 1800 hertz."
|
||||
key: "hints.P4_RADIO.level_1.0"
|
||||
- text: "Quand vous approchez de la bonne fréquence, un petit indicateur s'allume. Chaud… froid… chaud !"
|
||||
key: "hints.P4_RADIO.level_1.1"
|
||||
level_2:
|
||||
- text: "1337. C'est un nombre que les informaticiens connaissent bien. Un nombre légendaire dans les cercles geek."
|
||||
key: "hints.P4_RADIO.level_2.0"
|
||||
- text: "La fréquence est un nombre à 4 chiffres. Commençant par 1. Très précis. Tournez doucement."
|
||||
key: "hints.P4_RADIO.level_2.1"
|
||||
level_3:
|
||||
- text: "1337 hertz ! LEET en langage de hacker. Tournez jusqu'à ce que l'écran affiche 1337. Exactement."
|
||||
key: "hints.P4_RADIO.level_3.0"
|
||||
|
||||
P5_MORSE:
|
||||
level_1:
|
||||
- text: "Le code morse… inventé en 1836 ! Long-court-long. Tiret-point-tiret. Écoutez le rythme."
|
||||
key: "hints.P5_MORSE.level_1.0"
|
||||
- text: "Vous avez une fiche de référence quelque part dans cette salle. Cherchez-la. Je l'y ai laissée exprès."
|
||||
key: "hints.P5_MORSE.level_1.1"
|
||||
level_2:
|
||||
- text: "Le message contient cinq lettres. C'est mon nom… enfin, mon nom de code. Commençant par Z."
|
||||
key: "hints.P5_MORSE.level_2.0"
|
||||
- text: "Z=--.. A=.- C=-.-. U=..- S=... Voilà l'alphabet morse pour ces lettres précises."
|
||||
key: "hints.P5_MORSE.level_2.1"
|
||||
level_3:
|
||||
- text: "ZACUS en morse: TIRET-TIRET-POINT-POINT POINT-TIRET TIRET-POINT-TIRET-POINT POINT-POINT-TIRET POINT-POINT-POINT. Tapez sur le télégraphe !"
|
||||
key: "hints.P5_MORSE.level_3.0"
|
||||
|
||||
P6_SYMBOLES:
|
||||
level_1:
|
||||
- text: "L'alchimie obéit à une logique ancienne. Terre, Eau, Feu, Air… puis les métaux. Il y a un ordre naturel."
|
||||
key: "hints.P6_SYMBOLES.level_1.0"
|
||||
- text: "Chaque symbole a un poids. Les légers en haut, les lourds en bas. Comme dans la nature."
|
||||
key: "hints.P6_SYMBOLES.level_1.1"
|
||||
level_2:
|
||||
- text: "L'ordre suit la table alchimique classique. Elle est affichée dans le couloir sous un cadre doré."
|
||||
key: "hints.P6_SYMBOLES.level_2.0"
|
||||
- text: "Commencez par placer les quatre éléments primordiaux dans les coins. Puis remplissez le centre."
|
||||
key: "hints.P6_SYMBOLES.level_2.1"
|
||||
level_3:
|
||||
- text: "Feu (coin haut-gauche) → Eau → Terre → Air → Or → Argent → Mercure → Soufre → Sel → Antimoine → Arsenic → Plomb. De gauche à droite, haut en bas."
|
||||
key: "hints.P6_SYMBOLES.level_3.0"
|
||||
|
||||
P7_COFFRE:
|
||||
level_1:
|
||||
- text: "Vous avez tous les fragments ? Chaque puzzle vous a donné un ou deux chiffres. Assemblez-les dans l'ordre des puzzles."
|
||||
key: "hints.P7_COFFRE.level_1.0"
|
||||
|
||||
# ============================================================
|
||||
# V3 ADAPTATION PHRASES (profiling + dynamic parcours)
|
||||
# ============================================================
|
||||
v3_adaptation:
|
||||
group_tech_detected:
|
||||
- text: "Hmm. Groupe technique détecté ! Excellente nouvelle. Je vais sortir mes puzzles… avancés."
|
||||
key: "v3_adaptation.group_tech_detected.0"
|
||||
- text: "Vous avez résolu le circuit en un temps record. Intéressant ! Passons aux choses sérieuses."
|
||||
key: "v3_adaptation.group_tech_detected.1"
|
||||
|
||||
group_non_tech_detected:
|
||||
- text: "Ah ! Des artistes, des créatifs. Parfait. J'ai préparé des énigmes qui correspondent à votre sensibilité."
|
||||
key: "v3_adaptation.group_non_tech_detected.0"
|
||||
- text: "Le circuit n'était pas votre fort ? Pas grave ! La logique prend mille formes. Voici la vôtre."
|
||||
key: "v3_adaptation.group_non_tech_detected.1"
|
||||
|
||||
group_mixed_detected:
|
||||
- text: "Groupe éclectique ! Moitié techniciens, moitié artistes. Mon scénario préféré. Tout le monde travaille !"
|
||||
key: "v3_adaptation.group_mixed_detected.0"
|
||||
|
||||
bonus_puzzle_added:
|
||||
- text: "Impressionnant ! Vous allez trop vite. J'ai décidé d'ajouter… une petite surprise."
|
||||
key: "v3_adaptation.bonus_puzzle_added.0"
|
||||
- text: "Trop facile ? Très bien. Voici un défi supplémentaire que je réserve aux meilleurs groupes."
|
||||
key: "v3_adaptation.bonus_puzzle_added.1"
|
||||
|
||||
puzzle_skipped:
|
||||
- text: "Hmm. Le temps passe. Je vais vous faire grâce de cette épreuve. Pour aujourd'hui."
|
||||
key: "v3_adaptation.puzzle_skipped.0"
|
||||
- text: "Je saute une étape. Non par pitié — mais parce que le temps est une expérience en soi."
|
||||
key: "v3_adaptation.puzzle_skipped.1"
|
||||
|
||||
duration_warning:
|
||||
- text: "Attention ! Vous avez consommé 80% du temps imparti. Le Professeur s'impatiente légèrement."
|
||||
key: "v3_adaptation.duration_warning.0"
|
||||
|
||||
# ============================================================
|
||||
# V3 AMBIANCE (intro / outro per profile type)
|
||||
# ============================================================
|
||||
v3_ambiance:
|
||||
intro:
|
||||
- text: "Bienvenue dans mon laboratoire… portable. Aujourd'hui, c'est VOUS qui êtes l'expérience."
|
||||
key: "v3_ambiance.intro.0"
|
||||
- text: "Je suis le Professeur Zacus. Scientifique, inventeur, et… testeur de groupes humains depuis 1987."
|
||||
key: "v3_ambiance.intro.1"
|
||||
- text: "Vous avez soixante minutes. Soixante petites minutes pour prouver que votre espèce mérite de continuer."
|
||||
key: "v3_ambiance.intro.2"
|
||||
|
||||
outro_success:
|
||||
- text: "Extraordinaire ! Vous avez passé mon test. Je dois admettre… je suis impressionné. Légèrement."
|
||||
key: "v3_ambiance.outro_success.0"
|
||||
- text: "Félicitations ! Le diplôme du Professeur Zacus vous est accordé. Avec mention… passable."
|
||||
key: "v3_ambiance.outro_success.1"
|
||||
|
||||
outro_success_tech:
|
||||
- text: "Groupe technique remarquable ! Vous avez décodé chaque signal avec une précision chirurgicale. Respect."
|
||||
key: "v3_ambiance.outro_success_tech.0"
|
||||
- text: "Les ingénieurs du futur sont parmi nous. J'en suis certain maintenant. Bravo !"
|
||||
key: "v3_ambiance.outro_success_tech.1"
|
||||
|
||||
outro_success_non_tech:
|
||||
- text: "Qui a dit que la technologie était réservée aux ingénieurs ? Vous venez de prouver le contraire !"
|
||||
key: "v3_ambiance.outro_success_non_tech.0"
|
||||
- text: "L'intuition et la créativité ont triomphé. Le Professeur Zacus est bluffé. Vraiment."
|
||||
key: "v3_ambiance.outro_success_non_tech.1"
|
||||
|
||||
outro_success_mixed:
|
||||
- text: "Un groupe mixte en parfaite harmonie. Techniciens et créatifs côte à côte. C'est ce qui sauve le monde."
|
||||
key: "v3_ambiance.outro_success_mixed.0"
|
||||
|
||||
outro_partial:
|
||||
- text: "Hmm. Vous n'avez pas tout résolu. Mais vous avez essayé. C'est… acceptable. Pour un humain."
|
||||
key: "v3_ambiance.outro_partial.0"
|
||||
|
||||
outro_timeout:
|
||||
- text: "Le temps est écoulé ! L'expérience se termine. Ne vous inquiétez pas — j'ai noté tout ce que vous avez fait."
|
||||
key: "v3_ambiance.outro_timeout.0"
|
||||
- text: "Soixante minutes, et vous avez donné tout ce que vous aviez. C'est tout ce que je demandais."
|
||||
key: "v3_ambiance.outro_timeout.1"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# game/scenarios/v3_constants.yaml
|
||||
# V3 runtime constants — NPC engine, scoring, profiling, hint escalation.
|
||||
# All thresholds centralized here. Referenced by npc_engine.cpp and
|
||||
# the adaptive parcours logic in compile_runtime3.py.
|
||||
|
||||
# ============================================================
|
||||
# NPC ENGINE THRESHOLDS
|
||||
# ============================================================
|
||||
npc:
|
||||
# Adaptive mood triggers (% of expected puzzle duration elapsed)
|
||||
fast_threshold_pct: 60 # < 60% elapsed → mood IMPRESSED, add bonus puzzle
|
||||
slow_threshold_pct: 130 # > 130% elapsed → mood WORRIED, proactive hint
|
||||
critical_threshold_pct: 160 # > 160% elapsed + hints maxed → skip_to_next
|
||||
|
||||
# Stuck detection
|
||||
stuck_timeout_seconds: 120 # no progress for 2 min → trigger hint offer
|
||||
proactive_hint_interval_seconds: 90 # re-offer if still stuck after 90s
|
||||
max_hint_level: 3
|
||||
|
||||
# Time warning trigger
|
||||
duration_warning_pct: 80 # warn when 80% of target_minutes consumed
|
||||
|
||||
# ============================================================
|
||||
# SCORING CONSTANTS
|
||||
# ============================================================
|
||||
scoring:
|
||||
base: 1000
|
||||
hint_penalty: 50 # per hint given (any level)
|
||||
time_penalty_per_minute: 10 # each full minute over target_minutes
|
||||
bonus_fast: 200 # finished before fast_threshold_pct of target
|
||||
bonus_no_hints: 300 # zero hints used across entire game
|
||||
bonus_perfect_morse: 100 # P5 decoded on first attempt (no retries)
|
||||
max_score: 1600
|
||||
|
||||
# ============================================================
|
||||
# PROFILING PHASE THRESHOLDS
|
||||
# ============================================================
|
||||
profiling:
|
||||
fast_seconds: 180 # < 3 min on a profiling puzzle → "fast" tag
|
||||
slow_seconds: 480 # > 8 min on a profiling puzzle → "slow" tag
|
||||
# "normal" = between fast_seconds and slow_seconds
|
||||
tech_time_seconds: 180 # PROFILING_TECH_TIME: P2_CIRCUIT fast threshold
|
||||
|
||||
# ============================================================
|
||||
# TARGET DURATIONS PER MODE
|
||||
# ============================================================
|
||||
duration_modes:
|
||||
mode_30:
|
||||
target_minutes: 30
|
||||
profiling_minutes: 5
|
||||
adaptive_minutes: 15
|
||||
climax_minutes: 5
|
||||
outro_minutes: 5
|
||||
puzzles_required: 4
|
||||
|
||||
mode_45:
|
||||
target_minutes: 45
|
||||
profiling_minutes: 8
|
||||
adaptive_minutes: 25
|
||||
climax_minutes: 7
|
||||
outro_minutes: 5
|
||||
puzzles_required: 5
|
||||
|
||||
mode_60:
|
||||
target_minutes: 60
|
||||
profiling_minutes: 10
|
||||
adaptive_minutes: 35
|
||||
climax_minutes: 10
|
||||
outro_minutes: 5
|
||||
puzzles_required: 6
|
||||
|
||||
mode_90:
|
||||
target_minutes: 90
|
||||
profiling_minutes: 10
|
||||
adaptive_minutes: 60
|
||||
climax_minutes: 10
|
||||
outro_minutes: 10
|
||||
puzzles_required: 7
|
||||
|
||||
# ============================================================
|
||||
# HINT ESCALATION TIMERS
|
||||
# ============================================================
|
||||
hint_escalation:
|
||||
first_hint_after_seconds: 120 # level_1 offered after 2 min stuck
|
||||
second_hint_after_seconds: 240 # level_2 offered after 4 min stuck
|
||||
third_hint_after_seconds: 360 # level_3 offered after 6 min stuck
|
||||
auto_skip_after_seconds: 540 # auto-skip after 9 min (if game_master enabled)
|
||||
|
||||
# ============================================================
|
||||
# PER-PUZZLE EXPECTED DURATIONS AND LIMITS
|
||||
# ============================================================
|
||||
puzzles:
|
||||
P1_SON:
|
||||
expected_s: 300
|
||||
max_hints: 3
|
||||
phase: profiling
|
||||
P2_CIRCUIT:
|
||||
expected_s: 360
|
||||
max_hints: 3
|
||||
phase: profiling
|
||||
P3_QR:
|
||||
expected_s: 240
|
||||
max_hints: 3
|
||||
phase: adaptive
|
||||
P4_RADIO:
|
||||
expected_s: 300
|
||||
max_hints: 3
|
||||
phase: adaptive
|
||||
P5_MORSE:
|
||||
expected_s: 420
|
||||
max_hints: 3
|
||||
phase: adaptive
|
||||
P6_SYMBOLES:
|
||||
expected_s: 360
|
||||
max_hints: 3
|
||||
phase: adaptive
|
||||
P7_COFFRE:
|
||||
expected_s: 300
|
||||
max_hints: 1
|
||||
phase: climax
|
||||
@@ -0,0 +1,313 @@
|
||||
# game/scenarios/zacus_v3_complete.yaml
|
||||
# V3 Complete — 7 puzzle objects, adaptive NPC, ESP-NOW mesh
|
||||
id: zacus_v3_complete
|
||||
version: "3.1"
|
||||
title: "Le Mystère du Professeur Zacus — V3 Complete"
|
||||
theme: "Escape room mobile adaptatif, laboratoire du Professeur"
|
||||
|
||||
players:
|
||||
min: 4
|
||||
max: 15
|
||||
|
||||
ages: "8+ ans (corporate et enfants)"
|
||||
|
||||
duration:
|
||||
target_minutes: 60 # game master configures at boot (30–90)
|
||||
min_minutes: 30
|
||||
max_minutes: 90
|
||||
profiling_minutes: 10
|
||||
climax_minutes: 10
|
||||
outro_minutes: 5
|
||||
|
||||
# ============================================================
|
||||
# HARDWARE TOPOLOGY
|
||||
# ============================================================
|
||||
hardware:
|
||||
hub:
|
||||
- id: RTC_PHONE
|
||||
type: custom_esp32_slic
|
||||
role: voice_hub
|
||||
comm: esp_now
|
||||
- id: BOX_3
|
||||
type: esp32s3_box3
|
||||
role: orchestrator_display_camera
|
||||
comm: esp_now_master
|
||||
|
||||
puzzles:
|
||||
- id: P1_SON
|
||||
name: "Séquence Sonore"
|
||||
type: esp32s3_devkit
|
||||
profile: BOTH
|
||||
phase: profiling
|
||||
- id: P2_CIRCUIT
|
||||
name: "Circuit LED"
|
||||
type: esp32_devkit
|
||||
profile: TECH
|
||||
phase: profiling
|
||||
- id: P3_QR
|
||||
name: "QR Treasure"
|
||||
type: box3_camera
|
||||
profile: BOTH
|
||||
phase: adaptive
|
||||
- id: P4_RADIO
|
||||
name: "Fréquence Radio"
|
||||
type: esp32_devkit
|
||||
profile: TECH
|
||||
phase: adaptive
|
||||
- id: P5_MORSE
|
||||
name: "Code Morse"
|
||||
type: esp32_devkit
|
||||
profile: MIXED
|
||||
phase: adaptive
|
||||
- id: P6_SYMBOLES
|
||||
name: "Symboles Alchimiques"
|
||||
type: esp32_devkit
|
||||
profile: NON_TECH
|
||||
phase: adaptive
|
||||
- id: P7_COFFRE
|
||||
name: "Coffre Final"
|
||||
type: esp32_devkit
|
||||
profile: BOTH
|
||||
phase: climax
|
||||
|
||||
# ============================================================
|
||||
# SCORING SYSTEM
|
||||
# ============================================================
|
||||
scoring:
|
||||
base_score: 1000
|
||||
time_penalty_per_minute: 10 # deducted after target_minutes
|
||||
hint_penalty: 50 # per hint given
|
||||
bonus_fast_completion: 200 # if finished < 80% of target
|
||||
bonus_no_hints: 300 # if zero hints used
|
||||
bonus_perfect_morse: 100 # P5 decoded on first attempt
|
||||
max_score: 1600
|
||||
|
||||
# ============================================================
|
||||
# PHASES
|
||||
# ============================================================
|
||||
phases:
|
||||
|
||||
intro:
|
||||
id: PHASE_INTRO
|
||||
duration_minutes: 5
|
||||
trigger: game_start
|
||||
npc_audio: "intro/welcome"
|
||||
description: >
|
||||
Phone rings. Professor introduces himself. Players learn
|
||||
the rules through the Professor's monologue.
|
||||
|
||||
profiling:
|
||||
id: PHASE_PROFILING
|
||||
duration_minutes: 10
|
||||
puzzles: [P1_SON, P2_CIRCUIT]
|
||||
expected_time_each_seconds: 300
|
||||
npc_audio: "profiling/start"
|
||||
profiling_thresholds:
|
||||
fast_seconds: 180 # < 3 min = fast on this puzzle
|
||||
slow_seconds: 480 # > 8 min = slow on this puzzle
|
||||
group_classification:
|
||||
TECH:
|
||||
condition: "P2_fast AND (P1_normal OR P1_slow)"
|
||||
parcours: [P2_CIRCUIT, P4_RADIO, P5_MORSE, P3_QR, P7_COFFRE]
|
||||
NON_TECH:
|
||||
condition: "P1_fast AND (P2_normal OR P2_slow)"
|
||||
parcours: [P1_SON, P6_SYMBOLES, P3_QR, P5_MORSE_LIGHT, P7_COFFRE]
|
||||
MIXED:
|
||||
condition: "default"
|
||||
parcours: [P1_SON, P2_CIRCUIT, P3_QR, P5_MORSE, P6_SYMBOLES, P7_COFFRE]
|
||||
|
||||
adaptive:
|
||||
id: PHASE_ADAPTIVE
|
||||
duration_minutes: 20 # to 60, dynamically adjusted
|
||||
fast_threshold_pct: 60
|
||||
slow_threshold_pct: 130
|
||||
max_bonus_puzzles: 2
|
||||
npc_mood_rules:
|
||||
- condition: "pct < 60"
|
||||
mood: IMPRESSED
|
||||
action: add_bonus_puzzle
|
||||
- condition: "pct > 130"
|
||||
mood: WORRIED
|
||||
action: proactive_hint
|
||||
- condition: "pct > 160 AND hints_maxed"
|
||||
mood: WORRIED
|
||||
action: skip_to_next
|
||||
|
||||
climax:
|
||||
id: PHASE_CLIMAX
|
||||
duration_minutes: 10
|
||||
puzzle: P7_COFFRE
|
||||
requires_all_codes: true
|
||||
description: >
|
||||
Final code assembled from all previous puzzle solutions.
|
||||
All players must cooperate to enter 8-digit code.
|
||||
code_assembly:
|
||||
P1_SON: digits 1-2
|
||||
P2_CIRCUIT: digit 3
|
||||
P4_RADIO: digit 4
|
||||
P5_MORSE: digit 5
|
||||
P6_SYMBOLES: digits 6-7
|
||||
P3_QR: digit 8
|
||||
|
||||
outro:
|
||||
id: PHASE_OUTRO
|
||||
duration_minutes: 5
|
||||
npc_audio: "outro/verdict"
|
||||
outputs:
|
||||
- diplome_pdf: true
|
||||
- stats_display: true # score, time, hints, rating
|
||||
|
||||
# ============================================================
|
||||
# PUZZLE DEFINITIONS (timing, expected durations)
|
||||
# ============================================================
|
||||
puzzles:
|
||||
|
||||
P1_SON:
|
||||
id: P1_SON
|
||||
name: "Séquence Sonore"
|
||||
object: "Boîte avec haut-parleur + 4 boutons arcade"
|
||||
interaction: "Écouter la mélodie, reproduire la séquence"
|
||||
expected_duration_seconds: 300
|
||||
max_hints: 3
|
||||
code_contribution: "digits 1-2 (index de la séquence correcte, base-10)"
|
||||
difficulty_variants:
|
||||
TECH: sequence_length_5
|
||||
NON_TECH: sequence_length_3
|
||||
MIXED: sequence_length_4
|
||||
hint_keys:
|
||||
level_1: "hints.P1_SON.level_1"
|
||||
level_2: "hints.P1_SON.level_2"
|
||||
level_3: "hints.P1_SON.level_3"
|
||||
|
||||
P2_CIRCUIT:
|
||||
id: P2_CIRCUIT
|
||||
name: "Circuit LED"
|
||||
object: "Breadboard magnétique 60×40cm pliable"
|
||||
interaction: "Placer les composants pour allumer la LED"
|
||||
expected_duration_seconds: 360
|
||||
max_hints: 3
|
||||
code_contribution: "digit 3 (numéro de la configuration validée, 1-9)"
|
||||
difficulty_variants:
|
||||
TECH: full_circuit_5_components
|
||||
NON_TECH: guided_circuit_3_components
|
||||
hint_keys:
|
||||
level_1: "hints.P2_CIRCUIT.level_1"
|
||||
level_2: "hints.P2_CIRCUIT.level_2"
|
||||
level_3: "hints.P2_CIRCUIT.level_3"
|
||||
|
||||
P3_QR:
|
||||
id: P3_QR
|
||||
name: "QR Treasure"
|
||||
object: "6 QR codes plastifiés A5 cachés dans la salle"
|
||||
interaction: "Scanner dans l'ordre correct via BOX-3"
|
||||
expected_duration_seconds: 240
|
||||
max_hints: 3
|
||||
code_contribution: "digit 8 (checksum des QR scannés)"
|
||||
hint_keys:
|
||||
level_1: "hints.P3_QR.level_1"
|
||||
level_2: "hints.P3_QR.level_2"
|
||||
level_3: "hints.P3_QR.level_3"
|
||||
|
||||
P4_RADIO:
|
||||
id: P4_RADIO
|
||||
name: "Fréquence Radio"
|
||||
object: "Boîtier radio rétro 3D imprimé"
|
||||
interaction: "Trouver la bonne fréquence au codeur rotatif"
|
||||
expected_duration_seconds: 300
|
||||
max_hints: 3
|
||||
code_contribution: "digit 4 (fréquence cible encodée sur 1 chiffre)"
|
||||
target_frequency_hz: 1337
|
||||
frequency_range: [800, 1800]
|
||||
tolerance_hz: 10
|
||||
hint_keys:
|
||||
level_1: "hints.P4_RADIO.level_1"
|
||||
level_2: "hints.P4_RADIO.level_2"
|
||||
level_3: "hints.P4_RADIO.level_3"
|
||||
|
||||
P5_MORSE:
|
||||
id: P5_MORSE
|
||||
name: "Code Morse"
|
||||
object: "Télégraphe en bois + laiton"
|
||||
interaction: "Décoder le message morse du Professeur"
|
||||
expected_duration_seconds: 420
|
||||
max_hints: 3
|
||||
code_contribution: "digit 5 (dernier chiffre du message morse)"
|
||||
morse_message: "ZACUS" # Z=--.. A=.- C=-.-. U=..- S=...
|
||||
difficulty_variants:
|
||||
TECH: full_morse_decode
|
||||
NON_TECH: morse_light_mode # visual light pulses instead of sound
|
||||
hint_keys:
|
||||
level_1: "hints.P5_MORSE.level_1"
|
||||
level_2: "hints.P5_MORSE.level_2"
|
||||
level_3: "hints.P5_MORSE.level_3"
|
||||
|
||||
P6_SYMBOLES:
|
||||
id: P6_SYMBOLES
|
||||
name: "Symboles Alchimiques"
|
||||
object: "Tablette en bois découpée laser 30×20cm"
|
||||
interaction: "Placer les 12 symboles NFC dans l'ordre correct"
|
||||
expected_duration_seconds: 360
|
||||
max_hints: 3
|
||||
code_contribution: "digits 6-7 (index de la configuration, 10-99)"
|
||||
nfc_tags: 12
|
||||
correct_order: [7, 2, 11, 4, 9, 1, 8, 3, 12, 6, 10, 5]
|
||||
hint_keys:
|
||||
level_1: "hints.P6_SYMBOLES.level_1"
|
||||
level_2: "hints.P6_SYMBOLES.level_2"
|
||||
level_3: "hints.P6_SYMBOLES.level_3"
|
||||
|
||||
P7_COFFRE:
|
||||
id: P7_COFFRE
|
||||
name: "Coffre Final"
|
||||
object: "Coffre en bois 25×20×15cm avec verrou électronique"
|
||||
interaction: "Entrer le code 8 chiffres assemblé des puzzles précédents"
|
||||
expected_duration_seconds: 300
|
||||
max_hints: 1 # minimal hints for final puzzle
|
||||
hint_keys:
|
||||
level_1: "hints.P7_COFFRE.level_1"
|
||||
|
||||
# ============================================================
|
||||
# DURATION MODES (game master selects at boot)
|
||||
# ============================================================
|
||||
duration_modes:
|
||||
mode_30:
|
||||
target_minutes: 30
|
||||
profiling_minutes: 5
|
||||
adaptive_minutes: 15
|
||||
climax_minutes: 5
|
||||
outro_minutes: 5
|
||||
puzzles_required: 4 # skip lowest-priority adaptive puzzles
|
||||
|
||||
mode_45:
|
||||
target_minutes: 45
|
||||
profiling_minutes: 8
|
||||
adaptive_minutes: 25
|
||||
climax_minutes: 7
|
||||
outro_minutes: 5
|
||||
puzzles_required: 5
|
||||
|
||||
mode_60:
|
||||
target_minutes: 60
|
||||
profiling_minutes: 10
|
||||
adaptive_minutes: 35
|
||||
climax_minutes: 10
|
||||
outro_minutes: 5
|
||||
puzzles_required: 6
|
||||
|
||||
mode_90:
|
||||
target_minutes: 90
|
||||
profiling_minutes: 10
|
||||
adaptive_minutes: 60
|
||||
climax_minutes: 10
|
||||
outro_minutes: 10
|
||||
puzzles_required: 7 # all puzzles + bonus possible
|
||||
|
||||
# ============================================================
|
||||
# HINT ESCALATION TIMERS
|
||||
# ============================================================
|
||||
hint_escalation:
|
||||
first_hint_after_seconds: 120 # offer level_1 after 2 min stuck
|
||||
second_hint_after_seconds: 240 # offer level_2 after 4 min stuck
|
||||
third_hint_after_seconds: 360 # offer level_3 after 6 min stuck
|
||||
auto_skip_after_seconds: 540 # skip puzzle after 9 min (if enabled)
|
||||
proactive_hint_interval_seconds: 90
|
||||
@@ -0,0 +1,30 @@
|
||||
# docker-compose.audiocraft.yml — AudioCraft MusicGen deployment on KXKM-AI
|
||||
# Run: docker compose -f docker-compose.audiocraft.yml run audiocraft
|
||||
# Output: ./output/*.wav (mounted from host)
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
audiocraft:
|
||||
image: pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime
|
||||
container_name: audiocraft_gen
|
||||
working_dir: /workspace
|
||||
command: python3 generate_tracks.py
|
||||
volumes:
|
||||
- ./:/workspace
|
||||
- audiocraft_output:/workspace/output
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- HF_HOME=/workspace/.cache/huggingface
|
||||
# pip install audiocraft is run at container start via entrypoint below
|
||||
entrypoint: >
|
||||
bash -c "pip install -q audiocraft torchaudio && python3 generate_tracks.py"
|
||||
|
||||
volumes:
|
||||
audiocraft_output:
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
generate_tracks.py — Generate 6 ambient audio tracks using AudioCraft MusicGen.
|
||||
Run on KXKM-AI (RTX 4090) via docker-compose.audiocraft.yml or directly.
|
||||
|
||||
Output: output/{track_name}.wav (mono, 32kHz — AudioCraft native sample rate)
|
||||
|
||||
Usage:
|
||||
python3 generate_tracks.py [--dry-run] [--track lab_ambiance]
|
||||
|
||||
Notes:
|
||||
- AudioCraft MusicGen max generation duration per call is 30s.
|
||||
- Tracks longer than 30s are assembled from multiple segments.
|
||||
- Model: facebook/musicgen-medium (1.5B params, good quality/speed balance).
|
||||
- RTX 4090 generates ~30s in ~10s wall time.
|
||||
"""
|
||||
import argparse
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
OUTPUT_DIR = pathlib.Path("output")
|
||||
|
||||
TRACKS = [
|
||||
{
|
||||
"name": "lab_ambiance.wav",
|
||||
"duration": 30,
|
||||
"segments": 1,
|
||||
"prompt": (
|
||||
"Laboratory ambient sound, machines humming, electronic beeps, "
|
||||
"ventilation fan, subtle mechanical sounds, science lab, mysterious, "
|
||||
"loopable, no melody, background ambiance"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "tension_rising.wav",
|
||||
"duration": 30, # generate 30s base; extend in post-processing for 5min loop
|
||||
"segments": 1,
|
||||
"prompt": (
|
||||
"Dramatic tension building music, suspenseful, orchestral, "
|
||||
"slow crescendo, escape room atmosphere, mysterious puzzle solving, "
|
||||
"cinematic, no lyrics, dark ambient"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "victory.wav",
|
||||
"duration": 30,
|
||||
"segments": 1,
|
||||
"prompt": (
|
||||
"Victory fanfare, joyful celebration music, brass orchestra, "
|
||||
"applause, triumphant, escape room win, uplifting, energetic, "
|
||||
"short stinger"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "failure.wav",
|
||||
"duration": 15,
|
||||
"segments": 1,
|
||||
"prompt": (
|
||||
"Failure buzzer sound effect, trombone descending, game over, "
|
||||
"humorous sad tuba, cartoon fail sound, short, comedic"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "thinking.wav",
|
||||
"duration": 30,
|
||||
"segments": 1,
|
||||
"prompt": (
|
||||
"Subtle suspense music for thinking, minimal, ambient, "
|
||||
"slow piano, light electronic, puzzle solving concentration, "
|
||||
"loopable, calm but mysterious, soft"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "transition.wav",
|
||||
"duration": 10,
|
||||
"segments": 1,
|
||||
"prompt": (
|
||||
"Scene transition sound effect, swoosh, magical glitter, "
|
||||
"short stinger, puzzle reveal, sparkle effect, 10 seconds, "
|
||||
"ascending chime"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def generate_track(model, description: str, duration_s: int, filename: str) -> pathlib.Path:
|
||||
"""Generate a single track and save as WAV."""
|
||||
import torch
|
||||
import torchaudio
|
||||
|
||||
print(f" Generating: {filename} ({duration_s}s)")
|
||||
print(f" Prompt: {description[:80]}...")
|
||||
|
||||
# Cap duration at 30s per AudioCraft limit
|
||||
actual_duration = min(duration_s, 30)
|
||||
model.set_generation_params(duration=actual_duration)
|
||||
|
||||
t0 = time.time()
|
||||
with torch.no_grad():
|
||||
wav = model.generate(
|
||||
descriptions=[description],
|
||||
progress=True,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
print(f" Generated in {elapsed:.1f}s")
|
||||
|
||||
out_path = OUTPUT_DIR / filename
|
||||
# AudioCraft returns tensor [batch, channels, samples] at model.sample_rate
|
||||
torchaudio.save(
|
||||
str(out_path),
|
||||
wav[0].cpu(),
|
||||
model.sample_rate,
|
||||
format="wav",
|
||||
)
|
||||
print(f" Saved: {out_path} ({out_path.stat().st_size // 1024} KB)")
|
||||
return out_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate ambient tracks for Zacus V3")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print track list without generating")
|
||||
parser.add_argument("--track", metavar="NAME", help="Generate only the specified track by name stem")
|
||||
parser.add_argument("--model", default="facebook/musicgen-medium", help="HuggingFace model ID")
|
||||
args = parser.parse_args()
|
||||
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
selected = TRACKS
|
||||
if args.track:
|
||||
selected = [t for t in TRACKS if pathlib.Path(t["name"]).stem == args.track]
|
||||
if not selected:
|
||||
print(f"ERROR: track '{args.track}' not found. Available: {[t['name'] for t in TRACKS]}")
|
||||
return 1
|
||||
|
||||
if args.dry_run:
|
||||
print("=== DRY RUN — tracks that would be generated ===")
|
||||
for t in selected:
|
||||
print(f" {t['name']:30s} {t['duration']:3d}s {t['prompt'][:60]}...")
|
||||
print(f"\nTotal: {len(selected)} track(s)")
|
||||
return 0
|
||||
|
||||
print(f"Loading model: {args.model}")
|
||||
from audiocraft.models import MusicGen
|
||||
model = MusicGen.get_pretrained(args.model)
|
||||
print(f"Model loaded: {args.model}\n")
|
||||
|
||||
results = []
|
||||
for i, track in enumerate(selected, 1):
|
||||
print(f"[{i}/{len(selected)}] {track['name']}")
|
||||
out = generate_track(
|
||||
model,
|
||||
description=track["prompt"],
|
||||
duration_s=track["duration"],
|
||||
filename=track["name"],
|
||||
)
|
||||
results.append(out)
|
||||
print()
|
||||
|
||||
print("=" * 50)
|
||||
print(f"All {len(results)} track(s) generated successfully.")
|
||||
print(f"Output directory: {OUTPUT_DIR.absolute()}")
|
||||
for r in results:
|
||||
print(f" {r.name:30s} {r.stat().st_size // 1024} KB")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
# docker-compose.xtts.yml — XTTS-v2 deployment on KXKM-AI (RTX 4090)
|
||||
# OpenAI-compatible /v1/audio/speech endpoint with Professor Zacus voice clone.
|
||||
#
|
||||
# Deploy:
|
||||
# scp tools/tts/docker-compose.xtts.yml kxkm@kxkm-ai:~/zacus-tts/
|
||||
# scp voices/zacus_reference.wav kxkm@kxkm-ai:~/zacus-tts/voices/
|
||||
# ssh kxkm@kxkm-ai "cd ~/zacus-tts && docker compose -f docker-compose.xtts.yml up -d"
|
||||
#
|
||||
# Test:
|
||||
# curl -X POST http://kxkm-ai:5002/v1/audio/speech \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{"input": "Bienvenue dans mon laboratoire portable."}' \
|
||||
# --output /tmp/test.wav
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
xtts:
|
||||
image: ghcr.io/coqui-ai/tts:latest
|
||||
container_name: xtts_zacus
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- COQUI_TOS_AGREED=1
|
||||
command: >
|
||||
python3 -c "
|
||||
from TTS.api import TTS
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
import io, soundfile as sf, numpy as np, sys
|
||||
|
||||
print('Loading XTTS-v2 model...', flush=True)
|
||||
tts = TTS('tts_models/multilingual/multi-dataset/xtts_v2', gpu=True)
|
||||
app = FastAPI(title='XTTS-v2 Zacus Voice Clone')
|
||||
|
||||
@app.post('/v1/audio/speech')
|
||||
async def synthesize(req: Request):
|
||||
body = await req.json()
|
||||
text = body.get('input', '')
|
||||
if not text:
|
||||
return JSONResponse({'error': 'input required'}, status_code=400)
|
||||
if len(text) > 500:
|
||||
return JSONResponse({'error': 'input too long (max 500 chars)'}, status_code=400)
|
||||
wav = tts.tts(
|
||||
text=text,
|
||||
speaker_wav='/voices/zacus_reference.wav',
|
||||
language='fr'
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, np.array(wav), 24000, format='wav')
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type='audio/wav',
|
||||
headers={'Content-Disposition': 'attachment; filename=speech.wav'}
|
||||
)
|
||||
|
||||
@app.get('/health')
|
||||
def health():
|
||||
return {'status': 'ok', 'model': 'xtts_v2', 'voice': 'zacus'}
|
||||
|
||||
print('XTTS-v2 ready on :5002', flush=True)
|
||||
uvicorn.run(app, host='0.0.0.0', port=5002)
|
||||
"
|
||||
ports:
|
||||
- "5002:5002"
|
||||
volumes:
|
||||
- ./voices:/voices:ro # mount voice reference samples (read-only)
|
||||
- xtts_cache:/root/.local/share/tts
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:5002/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 120s # model loading takes ~60s on first start
|
||||
|
||||
volumes:
|
||||
xtts_cache:
|
||||
# Persists downloaded XTTS-v2 model weights (~1.8 GB) across container restarts
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
generate_xtts_pool.py — Pre-generate all NPC phrases via XTTS-v2 (GPU voice clone).
|
||||
|
||||
Reads game/scenarios/npc_phrases.yaml, calls XTTS-v2 on kxkm-ai, saves WAV files
|
||||
to hotline_tts/ directory, updates hotline_tts/manifest.json.
|
||||
|
||||
Idempotent: skips already-generated files unless --force is passed.
|
||||
|
||||
Usage:
|
||||
python3 tools/tts/generate_xtts_pool.py [--dry-run] [--force] [--host kxkm-ai:5002]
|
||||
|
||||
Requirements:
|
||||
pip install pyyaml requests
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
# Paths relative to project root (run from repo root)
|
||||
PHRASES_YAML = pathlib.Path("game/scenarios/npc_phrases.yaml")
|
||||
OUTPUT_DIR = pathlib.Path("hotline_tts")
|
||||
MANIFEST = OUTPUT_DIR / "manifest.json"
|
||||
DEFAULT_HOST = "kxkm-ai:5002"
|
||||
|
||||
# HTTP request settings
|
||||
REQUEST_TIMEOUT_S = 30
|
||||
RETRY_COUNT = 2
|
||||
RETRY_DELAY_S = 3
|
||||
|
||||
|
||||
def flatten_phrases(data: dict[str, Any], prefix: str = "") -> list[dict[str, str]]:
|
||||
"""Recursively extract all phrase entries with their keys and text.
|
||||
|
||||
Handles both list-of-dicts (with 'text' + 'key') and nested dicts.
|
||||
Returns list of {"key": str, "text": str} dicts.
|
||||
"""
|
||||
phrases: list[dict[str, str]] = []
|
||||
for k, v in data.items():
|
||||
path = f"{prefix}.{k}" if prefix else k
|
||||
if isinstance(v, list):
|
||||
for i, item in enumerate(v):
|
||||
if isinstance(item, dict) and "text" in item:
|
||||
phrases.append({
|
||||
"key": item.get("key", f"{path}.{i}"),
|
||||
"text": item["text"],
|
||||
})
|
||||
elif isinstance(v, dict):
|
||||
phrases.extend(flatten_phrases(v, path))
|
||||
return phrases
|
||||
|
||||
|
||||
def key_to_path(key: str) -> pathlib.Path:
|
||||
"""Convert phrase key to output file path.
|
||||
|
||||
Example: hints.P1_SON.level_1.0 → hotline_tts/hints/P1_SON/level_1/0.wav
|
||||
"""
|
||||
parts = key.replace(".", "/")
|
||||
return OUTPUT_DIR / f"{parts}.wav"
|
||||
|
||||
|
||||
def synthesize(text: str, host: str) -> bytes:
|
||||
"""Call XTTS-v2 /v1/audio/speech endpoint, return raw WAV bytes.
|
||||
|
||||
Raises requests.HTTPError on non-2xx response.
|
||||
"""
|
||||
url = f"http://{host}/v1/audio/speech"
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={"input": text},
|
||||
timeout=REQUEST_TIMEOUT_S,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def check_xtts_health(host: str) -> bool:
|
||||
"""Return True if XTTS-v2 service is reachable and healthy."""
|
||||
try:
|
||||
resp = requests.get(f"http://{host}/health", timeout=5)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pre-generate NPC TTS pool via XTTS-v2"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Parse phrases and print plan without calling XTTS")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Re-generate files that already exist")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST,
|
||||
help=f"XTTS-v2 host:port (default: {DEFAULT_HOST})")
|
||||
parser.add_argument("--phrases-yaml", default=str(PHRASES_YAML),
|
||||
help="Path to npc_phrases.yaml")
|
||||
args = parser.parse_args()
|
||||
|
||||
phrases_path = pathlib.Path(args.phrases_yaml)
|
||||
if not phrases_path.exists():
|
||||
print(f"ERROR: phrases file not found: {phrases_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Parse phrase bank
|
||||
with open(phrases_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
phrases = flatten_phrases(data)
|
||||
print(f"Phrase bank: {len(phrases)} phrases found in {phrases_path}")
|
||||
|
||||
# Health check before starting (skip in dry-run)
|
||||
if not args.dry_run:
|
||||
print(f"Checking XTTS-v2 at {args.host}...", end=" ", flush=True)
|
||||
if not check_xtts_health(args.host):
|
||||
print("UNREACHABLE")
|
||||
print(f"ERROR: XTTS-v2 not reachable at http://{args.host}/health",
|
||||
file=sys.stderr)
|
||||
print("Start the service first: docker compose -f tools/tts/docker-compose.xtts.yml up -d",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
print("OK")
|
||||
|
||||
# Load existing manifest
|
||||
manifest: dict[str, str] = {}
|
||||
if MANIFEST.exists():
|
||||
with open(MANIFEST, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
generated = skipped = errors = 0
|
||||
|
||||
for ph in phrases:
|
||||
key = ph["key"]
|
||||
text = ph["text"]
|
||||
path = key_to_path(key)
|
||||
|
||||
# Skip if already generated (unless --force)
|
||||
if not args.force and path.exists():
|
||||
skipped += 1
|
||||
manifest[key] = str(path)
|
||||
continue
|
||||
|
||||
label = "[DRY]" if args.dry_run else "[GEN]"
|
||||
preview = text[:70] + "..." if len(text) > 70 else text
|
||||
print(f" {label} {key}")
|
||||
print(f" {preview}")
|
||||
|
||||
if args.dry_run:
|
||||
generated += 1
|
||||
continue
|
||||
|
||||
# Synthesize with retries
|
||||
wav_bytes: bytes | None = None
|
||||
for attempt in range(1, RETRY_COUNT + 1):
|
||||
try:
|
||||
wav_bytes = synthesize(text, args.host)
|
||||
break
|
||||
except Exception as exc:
|
||||
print(f" WARNING attempt {attempt}/{RETRY_COUNT}: {exc}",
|
||||
file=sys.stderr)
|
||||
if attempt < RETRY_COUNT:
|
||||
time.sleep(RETRY_DELAY_S)
|
||||
|
||||
if wav_bytes is None:
|
||||
print(f" ERROR: all retries failed for key={key}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# Write file
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(wav_bytes)
|
||||
manifest[key] = str(path)
|
||||
generated += 1
|
||||
except OSError as exc:
|
||||
print(f" ERROR writing {path}: {exc}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
# Save manifest
|
||||
if not args.dry_run:
|
||||
MANIFEST.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(MANIFEST, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
print(f"\nManifest written: {MANIFEST} ({len(manifest)} entries)")
|
||||
|
||||
print(f"\nResult: {generated} generated, {skipped} skipped, {errors} errors")
|
||||
return 0 if errors == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user