Hangman, and it plays for chips — which the plan had down as a free game, on the grounds that trivia has no euro coupling in gogobee. But a free game in a casino reads as a demo, so it stakes like everything else. The idea that makes it a casino game rather than hangman with a wager stapled on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a tenth off what a win is worth, because those are the same event and showing them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands back less than the stake, and the rake still comes out of winnings only. State.Pays() is the number the felt quotes and the number settle() lands on. They were briefly two sums, and the table spent an afternoon advertising a pre-rake payout it didn't honour. Two things the storage layer had already decided for us, and one it hadn't: game_live_hands is keyed on the player, so "one game at a time" holds across games for free (a live hangman 409s a blackjack deal, stake intact). But table() unmarshalled every live row as a blackjack hand, which does not fail on a hangman row — it quietly yields an empty hand. It dispatches on the game now. commit() is the settle path both games share, and casinoRoutes() the one route list, since the dev rig wires its own mux and a second copy is a copy that stops including the newest game. Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on it; six wrong took the stake and no more; a reload mid-phrase brought back the board, the limbs, the multiple, the spent keys and the chips on the spot. The browser found the two bugs a Go test can't — a lives counter under the house rack, and a word wrapping early because the rack's clearance was on the whole column instead of the one row beside it.
200 lines
6.1 KiB
Go
200 lines
6.1 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"pete/internal/config"
|
|
)
|
|
|
|
const (
|
|
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
|
|
ttsTimeout = 60 * time.Second
|
|
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
|
|
)
|
|
|
|
// ttsVoice is one selectable voice, as sent to the client.
|
|
type ttsVoice struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// ttsService runs Piper (https://github.com/rhasspy/piper) as a subprocess to
|
|
// synthesize read-aloud audio server-side. It holds the validated voice
|
|
// registry and a concurrency semaphore; there is no long-lived process, each
|
|
// request spawns a short-lived piper that loads its model, synthesizes one
|
|
// chunk of text to a WAV on stdout, and exits (~0.5s for a paragraph).
|
|
type ttsService struct {
|
|
piperBin string
|
|
voices []ttsVoice // menu order, for the client selector
|
|
models map[string]string // voice id -> absolute .onnx path
|
|
def string // default voice id
|
|
sem chan struct{} // buffered to ttsMaxConcurrent
|
|
}
|
|
|
|
// newTTS validates the Piper install and builds the voice registry. It returns
|
|
// (nil, nil) when TTS is disabled. A configured voice whose model file is
|
|
// missing is skipped with a warning rather than failing startup; if that leaves
|
|
// no usable voices, TTS is disabled.
|
|
func newTTS(cfg config.TTSConfig) (*ttsService, error) {
|
|
if !cfg.Enabled {
|
|
return nil, nil
|
|
}
|
|
bin := cfg.PiperBin
|
|
if bin == "" {
|
|
bin = "piper"
|
|
}
|
|
if p, err := exec.LookPath(bin); err != nil {
|
|
slog.Error("web: TTS enabled but piper binary not found; read-aloud disabled", "piper_bin", bin, "err", err)
|
|
return nil, nil
|
|
} else {
|
|
bin = p
|
|
}
|
|
|
|
dir := cfg.VoicesDir
|
|
svc := &ttsService{piperBin: bin, models: make(map[string]string)}
|
|
|
|
want := cfg.Voices
|
|
if len(want) == 0 {
|
|
// Auto-discover every *.onnx in voices_dir, labelled by filename stem.
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
slog.Error("web: TTS voices_dir unreadable; read-aloud disabled", "voices_dir", dir, "err", err)
|
|
return nil, nil
|
|
}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasSuffix(name, ".onnx") {
|
|
continue
|
|
}
|
|
id := strings.TrimSuffix(name, ".onnx")
|
|
want = append(want, config.VoiceConfig{ID: id, Label: id})
|
|
}
|
|
sort.Slice(want, func(i, j int) bool { return want[i].ID < want[j].ID })
|
|
}
|
|
|
|
for _, v := range want {
|
|
if v.ID == "" {
|
|
continue
|
|
}
|
|
model := filepath.Join(dir, v.ID+".onnx")
|
|
if _, err := os.Stat(model); err != nil {
|
|
slog.Warn("web: TTS voice model missing; skipping", "voice", v.ID, "path", model)
|
|
continue
|
|
}
|
|
label := v.Label
|
|
if label == "" {
|
|
label = v.ID
|
|
}
|
|
svc.models[v.ID] = model
|
|
svc.voices = append(svc.voices, ttsVoice{ID: v.ID, Label: label})
|
|
}
|
|
if len(svc.voices) == 0 {
|
|
slog.Error("web: TTS enabled but no usable voices found; read-aloud disabled", "voices_dir", dir)
|
|
return nil, nil
|
|
}
|
|
|
|
svc.def = cfg.Default
|
|
if _, ok := svc.models[svc.def]; !ok {
|
|
svc.def = svc.voices[0].ID
|
|
}
|
|
svc.sem = make(chan struct{}, ttsMaxConcurrent)
|
|
slog.Info("web: server-side TTS enabled", "piper", bin, "voices", len(svc.voices), "default", svc.def)
|
|
return svc, nil
|
|
}
|
|
|
|
// clientConfig is the JSON handed to the page as window.PETE_TTS so the reader
|
|
// can build its voice selector and know TTS is available.
|
|
func (t *ttsService) clientConfig() template.JS {
|
|
payload := struct {
|
|
Enabled bool `json:"enabled"`
|
|
Default string `json:"default"`
|
|
Voices []ttsVoice `json:"voices"`
|
|
}{Enabled: true, Default: t.def, Voices: t.voices}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return template.JS("null")
|
|
}
|
|
return jsForScript(b)
|
|
}
|
|
|
|
// handleTTS synthesizes one chunk of text to WAV audio with the requested
|
|
// voice. It is registered only under the authenticated route group, so callers
|
|
// are already signed in (read-aloud is a signed-in perk). The request body is
|
|
// JSON {voice, text}; the response is audio/wav.
|
|
func (s *Server) handleTTS(w http.ResponseWriter, r *http.Request) {
|
|
if s.tts == nil {
|
|
http.Error(w, "tts disabled", http.StatusNotFound)
|
|
return
|
|
}
|
|
if s.auth == nil || s.auth.userFromRequest(r) == nil {
|
|
http.Error(w, "sign-in required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Voice string `json:"voice"`
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
text := strings.TrimSpace(req.Text)
|
|
if text == "" {
|
|
http.Error(w, "empty text", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(text) > ttsMaxTextLen {
|
|
text = text[:ttsMaxTextLen]
|
|
}
|
|
model, ok := s.tts.models[req.Voice]
|
|
if !ok {
|
|
model = s.tts.models[s.tts.def] // unknown/blank voice -> default
|
|
}
|
|
|
|
// Bound concurrent piper processes; give up if the client leaves or we wait
|
|
// too long for a slot rather than queueing unboundedly.
|
|
slotCtx, cancelSlot := context.WithTimeout(r.Context(), ttsTimeout)
|
|
defer cancelSlot()
|
|
select {
|
|
case s.tts.sem <- struct{}{}:
|
|
defer func() { <-s.tts.sem }()
|
|
case <-slotCtx.Done():
|
|
http.Error(w, "tts busy", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), ttsTimeout)
|
|
defer cancel()
|
|
// piper -m <model> -f - : write a full WAV to stdout. Text is fed on stdin,
|
|
// so there is no shell and nothing user-controlled reaches the arg list
|
|
// besides the model path, which is looked up from a fixed allowlist above.
|
|
cmd := exec.CommandContext(ctx, s.tts.piperBin, "-m", model, "-f", "-")
|
|
cmd.Stdin = strings.NewReader(text)
|
|
var out, errb bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &errb
|
|
if err := cmd.Run(); err != nil {
|
|
slog.Error("web: piper synthesis failed", "err", err, "stderr", strings.TrimSpace(errb.String()))
|
|
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "audio/wav")
|
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
|
w.Header().Set("Content-Length", strconv.Itoa(out.Len()))
|
|
_, _ = w.Write(out.Bytes())
|
|
}
|