#!/usr/bin/env python3
# RedVoix : la voix des RedApps, en francais, par un modele neuronal.
#
# ═══ POURQUOI UN DEMON ET PAS UN APPEL PAR PHRASE ═══
# ☠️ Mesure du 12/08 sur joyeuse : charger le modele coute 3,26 s, la synthese
#    d'une phrase 0,28 s. Lancer un processus par annonce — ce que fait espeak-ng
#    aujourd'hui — ferait donc payer 3,26 s AVANT CHAQUE PHRASE, c'est-a-dire une
#    voix qui arrive apres le carrefour. Le modele reste donc charge ici.
# 🔑 Le reste de la mesure, pour ne pas la reperdre : 2,39 s de calcul pour 8,82 s
#    de parole, soit 0,27x — il parle 3,7 fois plus vite qu'il ne se prononce.
#    144 Mio de resident, modele compris.
#
# ⚖️ MAIS ON NE GARDE PAS 144 MIO A VIE. Le modele se charge a la premiere phrase
#    et se libere apres un quart d'heure sans usage. Le guidage, lui, previent :
#    RedMaps envoie PRECHAUFFE en demarrant, donc la premiere annonce est chaude.

import os
import queue
import socket
import subprocess
import sys
import threading
import time
import wave

MODELE = "/usr/share/redvoix/fr.onnx"
OISIF = 900

_verrou = threading.Lock()
_session = None
_config = None
_derniere = 0.0
_lecture = None

# ☠️ SANS FILE D'ATTENTE, LA SECONDE ANNONCE COUPE LA PREMIERE AU MILIEU D'UN MOT.
#    En ville, deux manoeuvres a moins de 300 m l'une de l'autre suffisent : on
#    entend « Dans trois cents metres, tourn— » puis la suivante. La file a UNE
#    place : ce qui attend est remplace par plus recent, mais ce qui PARLE va au
#    bout. Une instruction a moitie dite ne vaut rien, et deux voix superposees
#    encore moins.
_attente = queue.Queue()
_numero = 0
_generation = 0


def journal(texte):
    print("redvoix: %s" % texte, flush=True)


def charger():
    global _session, _config, _derniere

    if _session is not None:
        return True

    try:
        import json

        import onnxruntime

        depart = time.time()

        with open(MODELE + ".json") as fichier:
            _config = json.load(fichier)

        _session = onnxruntime.InferenceSession(
            MODELE, providers=["CPUExecutionProvider"])

        journal("modele charge en %.2f s" % (time.time() - depart))

        # ☠️ SANS CETTE LIGNE, LE PRECHAUFFAGE S'ANNULE TOUT SEUL. `_derniere` ne
        #    valait que ce que `dire()` y ecrivait : apres un PRECHAUFFE sans
        #    phrase, il restait a zero, donc le surveillant voyait « plus de
        #    900 s sans usage » des sa premiere minute et LIBERAIT le modele.
        #    Le guidage payait alors les 3,26 s de chargement a sa premiere
        #    annonce — exactement ce que PRECHAUFFE devait eviter.
        global _derniere
        _derniere = time.time()

        return True

    except Exception as souci:
        journal("modele indisponible (%s) — l'appelant retombera sur espeak-ng" % souci)

        return False


def liberer():
    global _session, _config

    with _verrou:
        if _session is None:
            return

        _session = None
        _config = None

    journal("modele libere apres %d s sans usage" % OISIF)


def phonemes(texte):
    # ☠️ espeak-ng ne rend AUCUNE ponctuation dans sa sortie phonetique : il marque
    #    les pauses par un SAUT DE LIGNE. Les transformer en simples espaces fait
    #    debiter la phrase d'un trait, sans respiration — alors que la table du
    #    modele connait la virgule et le point, et que Piper s'en sert pour le
    #    rythme. On les retablit donc a partir du texte d'origine.
    langue = _config["espeak"]["voice"]

    brut = subprocess.run(
        ["espeak-ng", "-q", "--ipa=3", "-v", langue, "--", texte],
        capture_output=True, text=True).stdout

    morceaux = [ligne.strip() for ligne in brut.split("\n") if ligne.strip()]

    phrase = ", ".join(morceaux)

    fin = texte.rstrip()[-1:] if texte.rstrip() else ""

    if fin in ".!?":
        phrase += fin
    elif phrase:
        phrase += "."

    table = _config["phoneme_id_map"]
    suite = [table["^"][0]]

    for caractere in phrase:
        if caractere in table:
            suite.append(table[caractere][0])
            suite.append(table["_"][0])

    suite.append(table["$"][0])

    return suite


def dire(texte):
    global _derniere, _lecture

    with _verrou:
        if not charger():
            return False

        _derniere = time.time()

        import numpy

        suite = phonemes(texte)

        depart = time.time()

        son = _session.run(None, {
            "input": numpy.array([suite], dtype=numpy.int64),
            "input_lengths": numpy.array([len(suite)], dtype=numpy.int64),
            "scales": numpy.array([0.667, 1.0, 0.8], dtype=numpy.float32),
        })[0].squeeze()

        echantillonnage = _config["audio"]["sample_rate"]
        secondes = len(son) / echantillonnage

        journal("« %s » — %.2f s de calcul pour %.2f s de parole"
                % (texte[:60], time.time() - depart, secondes))

        # ☠️ UN SEUL NOM DE FICHIER NE MARCHE PAS AVEC UNE FILE : la synthese
        #    suivante ecraserait le wav que paplay est en train de lire, et l'on
        #    entendrait la fin d'une phrase collee au debut d'une autre.
        global _numero
        _numero += 1
        chemin = "/tmp/redvoix-%d.wav" % (_numero % 4)

        with wave.open(chemin, "wb") as fichier:
            fichier.setnchannels(1)
            fichier.setsampwidth(2)
            fichier.setframerate(echantillonnage)
            fichier.writeframes((son * 32767).astype(numpy.int16).tobytes())

    # ⚖️ Une seule annonce en attente. Au-dela, c'est la plus RECENTE qui compte :
    #    en navigation, une instruction perimee est pire que pas d'instruction.
    while not _attente.empty():
        try:
            _attente.get_nowait()
            journal("annonce en attente ecartee : une plus recente arrive")
        except queue.Empty:
            break

    _attente.put((chemin, _generation))

    return True


def jouer_la_file():
    global _lecture, _generation

    while True:
        chemin, generation = _attente.get()

        # ☠️ SANS CE CONTROLE, UN « TAIRE » PASSE ENTRE LE DEPILEMENT ET LE LANCEMENT
        #    NE COUPE RIEN. La boucle se reveille au put(), mais `_lecture` n'est
        #    affecte qu'apres le retour de Popen — quelques millisecondes de fork
        #    et d'exec pendant lesquelles la file est vide ET `_lecture` vaut None :
        #    TAIRE ne trouve alors rien a arreter, et la phrase sort quand meme,
        #    apres l'appui sur « Arreter ».
        # 🔑 Le numero de generation ferme la fenetre : TAIRE l'incremente, et tout
        #    ce qui a ete depose avant est abandonne, meme deja depile.
        if chemin is None or generation != _generation:
            continue

        _lecture = subprocess.Popen(["paplay", chemin],
                                    stdout=subprocess.DEVNULL,
                                    stderr=subprocess.DEVNULL)
        _lecture.wait()
        _lecture = None


def taire():
    global _lecture, _generation

    _generation += 1

    while not _attente.empty():
        try:
            _attente.get_nowait()
        except queue.Empty:
            break

    if _lecture is None:
        return

    if _lecture.poll() is None:
        _lecture.kill()


def surveiller():
    while True:
        time.sleep(60)

        if _session is not None and (time.time() - _derniere) > OISIF:
            liberer()


def servir(chemin):
    if os.path.exists(chemin):
        os.unlink(chemin)

    ecoute = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    ecoute.bind(chemin)
    os.chmod(chemin, 0o600)
    ecoute.listen(4)

    journal("a l'ecoute sur %s" % chemin)

    threading.Thread(target=surveiller, daemon=True).start()
    threading.Thread(target=jouer_la_file, daemon=True).start()

    while True:
        client, _ = ecoute.accept()

        try:
            ordre = client.recv(4096).decode("utf-8", "replace").strip()

            if not ordre:
                continue

            if ordre == "PRECHAUFFE":
                with _verrou:
                    charger()

                client.sendall(b"OK\n")

            elif ordre == "TAIRE":
                taire()
                client.sendall(b"OK\n")

            elif ordre.startswith("DIS "):
                client.sendall(b"OK\n" if dire(ordre[4:]) else b"NON\n")

            else:
                client.sendall(b"NON\n")

        except Exception as souci:
            journal("ordre abandonne : %s" % souci)

        finally:
            client.close()


if __name__ == "__main__":
    dossier = os.environ.get("XDG_RUNTIME_DIR", "/tmp")

    servir(os.path.join(dossier, "redvoix.sock"))
