#!/usr/bin/env python3
"""The rig's voice, as a subprocess the sidecar keeps alive instead of restarting per sentence.

Why a separate process at all: the same reason as `asr/driver.py`. The sidecar installs nothing
(`backend/README.md`) so that it runs under whatever python3 a rig already has, and piper needs
onnxruntime and numpy. Keeping the model out here is what lets that rule stay literally true.

**Why a LONG-LIVED one, which is the whole point of this file.** Until 2026-08-28 the sidecar
spawned `piper` once per sentence: a fresh interpreter, an onnxruntime import and a 63 MB model
load, to do about 0.13 s of work. Measured on an M3 Pro, over the same four lines:

    spawn per line     0.873 s each   (0.53 s of it the model load, paid every time)
    one live process   0.134 s each   (the load paid once, at startup)

That 0.74 s of waste was not academic. It landed on the sentences that CANNOT be pre-cached --
the ones carrying a step number or a measured figure, which are built per utterance and said
while a teacher is standing there waiting -- and it put them over the 0.5 s budget on their own.
It also made the 82-line prewarm take about 71 s instead of 11 s.

Protocol, deliberately minimal and the same shape as the recogniser's:

    stdin   one JSON object per line: {"text": ..., "out": "/path/to/write.wav"}
    stdout  one JSON object per line: {"type":"ready",...} then one reply per request
    stderr  human-readable diagnostics only. Never parsed; the sidecar copies it into sidecar.log.

Replies:

    {"type":"ready","voice":...,"sample_rate":N}
    {"type":"ok","out":...,"bytes":N,"took_s":F}
    {"type":"error","message":...}

**The audio goes through the filesystem, not the pipe, and that is deliberate.** A 3 s line is
~130 kB of WAV. `streaming_stt` already documents what happens when a lot of bytes meet a pipe:
the buffer fills (64 kB on Linux), the child blocks on its next write, stops reading stdin, and
the whole thing stops -- with the app still looking healthy, because stdout never closed. The
caller names the path it wants written and reads the file itself, so this pipe only ever carries
short JSON lines and cannot wedge that way.

An unparseable request or a failed synthesis is answered with an `error` reply and the loop
carries on: one bad sentence must not take the voice down for the rest of a teaching session.
Anything that makes the MODEL unusable exits non-zero instead, because that is not one sentence's
problem and the sidecar needs to hear about it as a death rather than as 82 identical errors.

**This process runs with its working directory inside piper's package**, and that is deliberate:
see `ESPEAK_PATH_LIMIT`. A relative `out` is therefore resolved against the directory the driver
was LAUNCHED from, so a hand run writes where its caller expects rather than into site-packages.
"""

from __future__ import annotations

import argparse
import io
import json
import os
import sys
import time
from pathlib import Path
from typing import Optional
import wave


#: How much to lift each voice, keyed by the stem of its `.onnx`. See the measurement table at the
#: `SynthesisConfig` below for how these were arrived at and how to redo it.
_VOICE_GAIN = {
    "en_US-john-medium": 1.9,
    "en_US-hfc_female-medium": 1.85,
    "en_US-hfc_male-medium": 1.53,
    "en_US-lessac-medium": 1.0,
    "en_US-norman-medium": 1.3,
}

#: What an unmeasured voice gets. Deliberately 1.0 -- no lift at all -- rather than any of the
#: numbers above: a voice nobody has measured is one whose peak nobody knows, and the failure of
#: guessing too high is a permanently distorted cache, while the failure of guessing too low is a
#: quiet rig that somebody turns up. The startup line says which it took.
_UNMEASURED_GAIN = 1.0


def _gain_for(model_path: str) -> float:
    return _VOICE_GAIN.get(Path(model_path).stem, _UNMEASURED_GAIN)


#: The longest data path espeak-ng will read, in BYTES. Measured 2026-09-24 with piper-tts 1.7.0
#: through symlinks of exact length to its own `espeak-ng-data`: 159 phonemizes, 160 fails -- and so
#: does a 150-character path that is 160 bytes of UTF-8, so this counts bytes, not characters.
#:
#: Past it, espeak-ng does not refuse. It quietly swaps in the folder baked in on piper's BUILD
#: machine (`/Users/runner/work/piper1-gpl/...`), fails to read `phontab` there, and calls C
#: `exit(1)` from inside piper's first phonemization -- which no Python `try` can catch. piper hands
#: it the ABSOLUTE path of the folder beside its own code, which in a packaged app is 85 characters
#: inside the `.app`, so any copy of Phos at a path over 74 characters had a working window and a
#: mute rig. That was seen on 2026-09-24 in a copy at 147 characters; App Translocation (a download
#: opened without first being moved) runs the app from about 121.
#:
#: So espeak-ng is never handed that path. `main` changes into the folder that holds the data and
#: names it relatively -- a constant 14 bytes wherever Phos is installed, on every platform, with
#: nothing written to disk. This limit stays as the refusal that keeps that true.
ESPEAK_PATH_LIMIT = 159


def espeak_path_refusal(path: str) -> Optional[str]:
    """Why espeak-ng cannot be handed `path`, or None when it can.

    A sentence rather than a boolean so the refusal names the path and the limit itself: past the
    limit espeak-ng's only message names a folder on piper's build machine, which sends whoever
    reads it looking in entirely the wrong place.
    """
    size = len(os.fsencode(path))
    if size <= ESPEAK_PATH_LIMIT:
        return None
    return (
        f"espeak-ng cannot be given its data at {path}: that path is {size} bytes and espeak-ng "
        f"reads at most {ESPEAK_PATH_LIMIT}. Past that it looks on piper's build machine instead "
        "and exits, so the voice would never speak."
    )


def _emit(obj: dict) -> None:
    """One JSON object, one line, flushed. The sidecar reads this with `readline`."""
    sys.stdout.write(json.dumps(obj) + "\n")
    sys.stdout.flush()


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--model", required=True, help="path to the piper .onnx voice")
    parser.add_argument("--config", default="", help="path to the voice's .onnx.json")
    # LibriTTS is one model holding 904 readers, so the voice is only half-chosen by the file.
    # Passed per process rather than per request because a teaching session does not change
    # speaker mid-sentence, and a per-request field would be a second place it could disagree
    # with `local_tts.DEFAULT_SPEAKER`.
    parser.add_argument("--speaker", type=int, default=0, help="speaker id for a multi-speaker voice")
    # Cadence overrides. Left None on purpose when unset -- piper reads `None` as "use the voice's
    # own `inference` block", so an unflagged voice behaves exactly as it did before these existed.
    # Which voice gets which is decided in `phos_sidecar/local_tts._VOICE_CADENCE`, not here: it is
    # a property of the voice, and the sidecar has to know it too because the TTS cache directory
    # is named after it.
    parser.add_argument("--length-scale", type=float, default=None,
                        help="phoneme duration multiplier; below 1 is faster")
    parser.add_argument("--noise-w", type=float, default=None,
                        help="width fed to the stochastic duration predictor; lower is steadier")
    args = parser.parse_args(argv)

    # Imported here rather than at module scope so that `--help` works without piper installed,
    # and so an import failure is reported as this driver's own message rather than a traceback
    # about a file the operator has never heard of.
    try:
        from piper import PiperVoice, SynthesisConfig
        from piper.phonemize_espeak import ESPEAK_DATA_DIR
    except Exception as exc:  # noqa: BLE001 -- reported, then fatal; see the docstring
        print(f"piper is not importable by {sys.executable}: {exc}", file=sys.stderr, flush=True)
        return 2

    # espeak-ng's data, named relative to the folder that holds it, which this process moves into
    # below. See `ESPEAK_PATH_LIMIT` for why it is never given piper's absolute path.
    espeak_data = ESPEAK_DATA_DIR.name
    refusal = espeak_path_refusal(espeak_data)
    if refusal:
        print(refusal, file=sys.stderr, flush=True)
        return 2

    config = args.config or f"{args.model}.json"
    launched_from = Path.cwd()
    try:
        # Loaded BEFORE the change of directory, so a relative --model or --config in a hand run
        # still means what its caller meant. piper also takes its download folder from the working
        # directory at load, and that too should be the caller's.
        voice = PiperVoice.load(args.model, config_path=config, espeak_data_dir=espeak_data)
    except Exception as exc:  # noqa: BLE001 -- a voice that will not load is fatal, not per-line
        print(f"the voice at {args.model} would not load: {exc}", file=sys.stderr, flush=True)
        return 2

    speakers = int(getattr(voice.config, "num_speakers", 1) or 1)
    if args.speaker >= speakers:
        # Refused rather than clamped: a clamp would speak every line in a voice nobody chose,
        # and it would sound entirely fine, which is the shape of fault this repo refuses.
        print(
            f"speaker {args.speaker} does not exist in {args.model} ({speakers} speakers)",
            file=sys.stderr,
            flush=True,
        )
        return 2

    # For the life of the process: espeak-ng opens its files lazily (a voice's dictionary on first
    # use), each time relative to wherever this process is standing.
    os.chdir(ESPEAK_DATA_DIR.parent)
    # piper starts espeak-ng on the first line it phonemizes. Started here instead, before `ready`,
    # so a phonemizer that cannot start -- whose failure may be a C `exit(1)` nothing here can catch
    # -- is a driver that would not start, not one that says `ready` and dies on its first line.
    try:
        voice.phonemize("ready")
    except Exception as exc:  # noqa: BLE001 -- no phonemizer is no voice, not one bad line
        print(
            f"espeak-ng would not start for {args.model} (data: {ESPEAK_DATA_DIR}): {exc}",
            file=sys.stderr,
            flush=True,
        )
        return 2

    # Always a config now, single-speaker voices included, because `normalize_audio` has to be
    # turned off and piper's default for it is True. Passing `speaker_id` to a one-speaker voice is
    # harmless -- `PiperVoice` nulls it itself when `num_speakers <= 1`.
    #
    # **Why normalization is off.** Piper normalizes to full scale PER SENTENCE, not per line, and
    # a third of the rig's fixed lines are two sentences ("Okay, step 3 is deleted. Step away from
    # the robot."). So a line's natural fall in loudness was reset at each full stop, and the last
    # clause arrived at the same peak as the first -- heard as a gear change, and as a trailing
    # sentence pasted on rather than spoken. One line is one utterance and is levelled as one.
    #
    # **Why there is a gain in front of it.** Normalization was also setting the rig's level, and
    # taking it away costs about 8 dB on this voice. A robot cell is not a quiet room, so the loss
    # is made up with one fixed number rather than left to be heard as the voice going weak.
    #
    # Measured 2026-08-29 on `en_US-john-medium`, over all 82 prewarm lines in each of the four
    # candidate configurations (espeak en / en-us, noise_w 0.8 / 0.4): the loudest line of the 328
    # peaked at 0.42 of full scale, and the four configurations agreed within 7% of each other --
    # which is why one constant covers whichever of them the listening test settled on. 1.9 puts
    # that loudest line at 0.80, and would not clip until a line reached 0.53 raw, 25% above
    # anything observed.
    #
    # The headroom is deliberately wide because SYNTHESIS IS STOCHASTIC and unseeded: re-rendering
    # one configuration's 82 lines moved its loudest peak from 0.59 to 0.63. A margin sized to a
    # single render would be a distorted line months later, on a machine nobody was watching.
    #
    # A CONSTANT, never a per-line adjustment: the entire point of dropping normalization is that
    # lines keep their loudness relative to one another. Piper clips to [-1, 1] after the gain, so
    # being wrong here costs distortion on one line rather than an overflow.
    #
    # **Per voice, because voices differ by nearly a factor of two.** The warning this replaces
    # said "re-measure when the voice changes", and 2026-08-31 is what it was for: at john's 1.9,
    # `en_US-lessac-medium` clips outright (1.43) and `en_US-hfc_male-medium` reaches 0.945 -- not
    # clipping, but 5% of headroom against a ~7% render-to-render swing, which is the same defect
    # arriving later. A distorted line is permanent, because every line is cached forever.
    # `en_US-norman-medium` was noted at 0.59 raw for the same reason.
    #
    # Measured the same way as john's original figure: all 82 prewarm lines at volume 1.0 with
    # normalization off, taking the loudest peak of the set (2026-08-31).
    #
    #     voice                    raw peak   gain   lands at   clips above
    #     en_US-john-medium        0.401      1.9    0.76       2.49
    #     en_US-hfc_male-medium    0.497      1.53   0.76       2.01
    #     en_US-lessac-medium      0.752      1.0    0.75       1.33
    #     en_US-hfc_female-medium  0.416      1.85   0.77       2.40
    #
    # hfc_female's row is the first measured over THREE runs of the 83 lines rather than one, and
    # the number recorded is the WORST of them: 0.370, 0.400, 0.416, a 12% spread. That is the
    # stochasticity this margin exists for, sized from the data instead of assumed -- and it is a
    # caution about the rows above, each of which is a single render and could be 12% low. Redo any
    # of them the same way before trusting it to the third digit.
    #
    # Every row lands the loudest line at about 0.75 of full scale, which is the point: switching
    # the voice must not change how loud the cell is. Each keeps about a third of headroom over the
    # loudest thing measured, which is what covers SYNTHESIS BEING STOCHASTIC and unseeded --
    # re-rendering one configuration moved its loudest peak by ~7%.
    #
    # **The cadence, separately from the gain.** `noise_w_scale` and `length_scale` REPLACE the
    # voice config's `inference.noise_w` and `inference.length_scale` when they are not None, which
    # is why an override here is exactly equivalent to editing the `.onnx.json` -- and is the form
    # this repo uses for a voice whose config is fetched rather than committed (hfc_male's: it is
    # research-licensed), where a fetch would silently undo an edit to the file. john, the voice
    # every build speaks with, carries its tuning in its own committed config instead.
    syn_config = SynthesisConfig(
        speaker_id=args.speaker,
        normalize_audio=False,
        volume=_gain_for(args.model),
        length_scale=args.length_scale,
        noise_w_scale=args.noise_w,
    )
    _emit(
        {
            "type": "ready",
            "voice": args.model,
            "speaker": args.speaker,
            "speakers": speakers,
            "sample_rate": int(getattr(voice.config, "sample_rate", 0)),
            # Which lift this voice got, so an unmeasured one is visible in the log rather than
            # only audible as a rig that is quieter than it was yesterday.
            "gain": syn_config.volume,
            # And how it was paced. None here means the voice's own config decided, which is the
            # difference between "nobody has tuned this voice" and "it is tuned to these numbers".
            "length_scale": syn_config.length_scale,
            "noise_w": syn_config.noise_w_scale,
        }
    )

    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            request = json.loads(line)
            text, out = request["text"], request["out"]
        except Exception as exc:  # noqa: BLE001 -- one malformed request, not a dead voice
            _emit({"type": "error", "message": f"unreadable request: {exc}"})
            continue
        started = time.perf_counter()
        try:
            # Synthesized into memory and written whole. A reader that opened the path while it
            # was still being filled would get a truncated WAV, and the caller is watching for
            # this reply rather than for the file, so the file must be complete before the reply.
            buffer = io.BytesIO()
            with wave.open(buffer, "wb") as sink:
                voice.synthesize_wav(text, sink, syn_config=syn_config)
            payload = buffer.getvalue()
            with open(launched_from / out, "wb") as handle:
                handle.write(payload)
        except Exception as exc:  # noqa: BLE001 -- this sentence failed; the next may not
            _emit({"type": "error", "message": f"piper failed on {text!r}: {exc}"})
            continue
        _emit(
            {
                "type": "ok",
                "out": out,
                "bytes": len(payload),
                "took_s": round(time.perf_counter() - started, 4),
            }
        )
    return 0


if __name__ == "__main__":
    sys.exit(main())
