Compare commits
6 Commits
8f9fcc45f3
...
weather-fx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a723418ff | ||
|
|
9b20040b49 | ||
|
|
e91b423b1a | ||
|
|
dbcb459908 | ||
|
|
fceeb12ad5 | ||
|
|
74aa578a2d |
@@ -77,6 +77,25 @@ interval_minutes = 360
|
|||||||
# item doesn't ping everyone.
|
# item doesn't ping everyone.
|
||||||
min_stories = 3
|
min_stories = 3
|
||||||
|
|
||||||
|
# Server-side neural read-aloud (Piper, https://github.com/rhasspy/piper).
|
||||||
|
# When enabled, the reader's "Listen" button streams real Piper voices instead
|
||||||
|
# of the browser's robotic Web Speech voice. Signed-in only, so it needs
|
||||||
|
# [web.auth] on too. Install the piper binary and one or more voice models
|
||||||
|
# (<id>.onnx + <id>.onnx.json) into voices_dir first.
|
||||||
|
[web.tts]
|
||||||
|
enabled = false
|
||||||
|
piper_bin = "/opt/piper/piper" # path to the piper executable
|
||||||
|
voices_dir = "/opt/piper/voices" # dir holding <id>.onnx (+ .onnx.json) models
|
||||||
|
default = "en_US-amy-medium" # voice id selected until the reader picks another
|
||||||
|
# List the voices to offer, in menu order. Omit the whole [[web.tts.voices]]
|
||||||
|
# list to auto-discover every *.onnx in voices_dir (labelled by filename).
|
||||||
|
[[web.tts.voices]]
|
||||||
|
id = "en_US-amy-medium"
|
||||||
|
label = "Amy (US, female)"
|
||||||
|
[[web.tts.voices]]
|
||||||
|
id = "en_US-ryan-high"
|
||||||
|
label = "Ryan (US, male, HQ)"
|
||||||
|
|
||||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type WebConfig struct {
|
|||||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||||
|
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||||
@@ -53,6 +54,29 @@ type PushConfig struct {
|
|||||||
MinStories int `toml:"min_stories"`
|
MinStories int `toml:"min_stories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
|
||||||
|
// signed-in users get the reader's "Listen" button backed by real Piper voices
|
||||||
|
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
|
||||||
|
// perk, so this does nothing unless auth is also enabled.
|
||||||
|
type TTSConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
PiperBin string `toml:"piper_bin"` // path to the piper executable
|
||||||
|
VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
|
||||||
|
// Voices lists the voices to offer, in menu order. Each id is a model
|
||||||
|
// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
|
||||||
|
// Leave empty to auto-discover every *.onnx in voices_dir.
|
||||||
|
Voices []VoiceConfig `toml:"voices"`
|
||||||
|
// Default is the voice id selected until the reader picks another. Empty
|
||||||
|
// falls back to the first available voice.
|
||||||
|
Default string `toml:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceConfig is one selectable Piper voice.
|
||||||
|
type VoiceConfig struct {
|
||||||
|
ID string `toml:"id"` // model filename stem, e.g. "en_US-ryan-high"
|
||||||
|
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
||||||
|
}
|
||||||
|
|
||||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// else the body scraped during paywall detection) for reader mode. Stories
|
// else the body scraped during paywall detection) for reader mode. Stories
|
||||||
// ingested before this column existed simply have NULL and fall back to lede.
|
// ingested before this column existed simply have NULL and fall back to lede.
|
||||||
addColumnIfMissing(d, "stories", "content", "TEXT")
|
addColumnIfMissing(d, "stories", "content", "TEXT")
|
||||||
|
// content_chars caches the character count of content so the "N min read"
|
||||||
|
// chip never has to LENGTH() the full body on the hot listing path. Filled at
|
||||||
|
// insert time; the backfill below populates rows that predate the column.
|
||||||
|
addColumnIfMissing(d, "stories", "content_chars", "INTEGER NOT NULL DEFAULT 0")
|
||||||
|
backfillContentChars(d)
|
||||||
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
|
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
|
||||||
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
||||||
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
||||||
@@ -154,6 +159,20 @@ func exec(label, query string, args ...any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backfillContentChars populates content_chars for rows carrying a body but a
|
||||||
|
// zero count — i.e. stories ingested before the column existed. LENGTH() counts
|
||||||
|
// characters (code points) for TEXT, matching the utf8.RuneCountInString done at
|
||||||
|
// insert. After the first run this matches no rows (bodied stories are set,
|
||||||
|
// bodyless ones stay 0 and are filtered by content IS NOT NULL), so it's a cheap
|
||||||
|
// startup no-op thereafter.
|
||||||
|
func backfillContentChars(d *sql.DB) {
|
||||||
|
if _, err := d.Exec(
|
||||||
|
`UPDATE stories SET content_chars = LENGTH(content)
|
||||||
|
WHERE content_chars = 0 AND content IS NOT NULL AND content <> ''`); err != nil {
|
||||||
|
slog.Error("backfill content_chars failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
|
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
|
||||||
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
||||||
if _, err := d.Exec(q); err != nil {
|
if _, err := d.Exec(q); err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func nowUnix() int64 {
|
func nowUnix() int64 {
|
||||||
@@ -38,9 +39,9 @@ func InsertStory(s *Story) error {
|
|||||||
publishedAt = s.PublishedAt
|
publishedAt = s.PublishedAt
|
||||||
}
|
}
|
||||||
_, err := Get().Exec(
|
_, err := Get().Exec(
|
||||||
`INSERT INTO stories (guid, headline, lede, content, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
|
`INSERT INTO stories (guid, headline, lede, content, content_chars, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
|
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), utf8.RuneCountInString(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -414,8 +415,10 @@ func TrendingStories(limit int, sinceDay int64) ([]Story, error) {
|
|||||||
|
|
||||||
// StoryContentLengths returns the captured article text length (in characters)
|
// StoryContentLengths returns the captured article text length (in characters)
|
||||||
// for the given story ids, as a map keyed by id. Used to estimate a "N min read"
|
// for the given story ids, as a map keyed by id. Used to estimate a "N min read"
|
||||||
// chip without transferring the full body: only LENGTH(content) crosses the
|
// chip. It reads the precomputed content_chars column rather than LENGTH()-ing
|
||||||
// wire. Ids with no captured content are absent (treated as zero by callers).
|
// the full body, so the hot listing path never scans article text. Ids with no
|
||||||
|
// captured content have content_chars = 0 and are absent from the map (treated
|
||||||
|
// as zero by callers).
|
||||||
func StoryContentLengths(ids []int64) map[int64]int {
|
func StoryContentLengths(ids []int64) map[int64]int {
|
||||||
out := make(map[int64]int, len(ids))
|
out := make(map[int64]int, len(ids))
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
@@ -423,8 +426,8 @@ func StoryContentLengths(ids []int64) map[int64]int {
|
|||||||
}
|
}
|
||||||
ph, args := intInClause(ids)
|
ph, args := intInClause(ids)
|
||||||
rows, err := Get().Query(
|
rows, err := Get().Query(
|
||||||
`SELECT id, LENGTH(content) FROM stories
|
`SELECT id, content_chars FROM stories
|
||||||
WHERE id IN (`+ph+`) AND content IS NOT NULL AND content <> ''`, args...)
|
WHERE id IN (`+ph+`) AND content_chars > 0`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("story content lengths query failed", "err", err)
|
slog.Error("story content lengths query failed", "err", err)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS stories (
|
|||||||
headline TEXT NOT NULL,
|
headline TEXT NOT NULL,
|
||||||
lede TEXT,
|
lede TEXT,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
|
content_chars INTEGER NOT NULL DEFAULT 0,
|
||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
article_url TEXT NOT NULL,
|
article_url TEXT NOT NULL,
|
||||||
url_canonical TEXT,
|
url_canonical TEXT,
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ type pageData struct {
|
|||||||
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
||||||
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
||||||
PushPublicKey string // VAPID public key handed to the client to subscribe
|
PushPublicKey string // VAPID public key handed to the client to subscribe
|
||||||
|
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelPage struct {
|
type channelPage struct {
|
||||||
@@ -166,6 +167,10 @@ func (s *Server) base(r *http.Request) pageData {
|
|||||||
PostingEnabled: s.postingEnabled,
|
PostingEnabled: s.postingEnabled,
|
||||||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||||||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||||||
|
TTS: template.JS("null"),
|
||||||
|
}
|
||||||
|
if s.tts != nil {
|
||||||
|
d.TTS = s.tts.clientConfig()
|
||||||
}
|
}
|
||||||
if s.auth != nil {
|
if s.auth != nil {
|
||||||
if u := s.auth.userFromRequest(r); u != nil {
|
if u := s.auth.userFromRequest(r); u != nil {
|
||||||
@@ -396,7 +401,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
|
demoVariants = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "petals", "jacaranda", "motes", "leaves"}
|
||||||
demoIntensities = []string{"light", "medium", "heavy"}
|
demoIntensities = []string{"light", "medium", "heavy"}
|
||||||
demoPhases = []string{"day", "dawn", "dusk", "night"}
|
demoPhases = []string{"day", "dawn", "dusk", "night"}
|
||||||
)
|
)
|
||||||
@@ -426,10 +431,12 @@ func seasonForVariant(v string) string {
|
|||||||
return "winter"
|
return "winter"
|
||||||
case "petals", "jacaranda":
|
case "petals", "jacaranda":
|
||||||
return "spring"
|
return "spring"
|
||||||
case "motes":
|
case "motes", "haze":
|
||||||
return "summer"
|
return "summer"
|
||||||
case "leaves":
|
case "leaves", "wind":
|
||||||
return "autumn"
|
return "autumn"
|
||||||
|
case "hail":
|
||||||
|
return "winter"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type Server struct {
|
|||||||
srv *http.Server
|
srv *http.Server
|
||||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||||
auth *Authenticator // nil when sign-in is disabled or unavailable
|
auth *Authenticator // nil when sign-in is disabled or unavailable
|
||||||
|
tts *ttsService // nil when server-side read-aloud is disabled
|
||||||
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
||||||
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
||||||
|
|
||||||
@@ -115,6 +116,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
|
||||||
|
// only useful alongside auth; newTTS logs and disables itself on any misconfig.
|
||||||
|
if s.auth != nil {
|
||||||
|
tts, err := newTTS(cfg.TTS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.tts = tts
|
||||||
|
} else if cfg.TTS.Enabled {
|
||||||
|
slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
@@ -167,6 +180,9 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||||
}
|
}
|
||||||
|
if s.tts != nil {
|
||||||
|
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.srv = &http.Server{
|
s.srv = &http.Server{
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ html[data-phase="night"] {
|
|||||||
|
|
||||||
/* Text-options popover, anchored under the Aa button in the reader bar. */
|
/* Text-options popover, anchored under the Aa button in the reader bar. */
|
||||||
.pete-reader-type { position: relative; display: inline-flex; }
|
.pete-reader-type { position: relative; display: inline-flex; }
|
||||||
|
.pete-reader-typemenu[hidden] { display: none; }
|
||||||
.pete-reader-typemenu {
|
.pete-reader-typemenu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.5rem);
|
top: calc(100% + 0.5rem);
|
||||||
@@ -282,6 +283,23 @@ html[data-phase="night"] {
|
|||||||
color: #1c1305;
|
color: #1c1305;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
.pete-reader-voicerow[hidden] { display: none; }
|
||||||
|
.pete-reader-voice {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
border: 1px solid rgba(20, 14, 6, 0.18);
|
||||||
|
background: rgba(20, 14, 6, 0.06);
|
||||||
|
color: inherit;
|
||||||
|
max-width: 11rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
html[data-phase="night"] .pete-reader-voice {
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.pete-reader-scroll {
|
.pete-reader-scroll {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -289,6 +307,7 @@ html[data-phase="night"] {
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
border-radius: 1.75rem;
|
border-radius: 1.75rem;
|
||||||
}
|
}
|
||||||
|
.pete-reader-scroll:focus { outline: none; }
|
||||||
|
|
||||||
.pete-reader-article {
|
.pete-reader-article {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,8 @@
|
|||||||
var shareBtn = overlay.querySelector("[data-reader-share]");
|
var shareBtn = overlay.querySelector("[data-reader-share]");
|
||||||
var typeBtn = overlay.querySelector("[data-reader-type]");
|
var typeBtn = overlay.querySelector("[data-reader-type]");
|
||||||
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
|
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
|
||||||
|
var voiceRow = overlay.querySelector("[data-reader-voicerow]");
|
||||||
|
var voiceSel = overlay.querySelector("[data-reader-voice]");
|
||||||
var relatedEl = overlay.querySelector("[data-reader-related]");
|
var relatedEl = overlay.querySelector("[data-reader-related]");
|
||||||
var relatedCache = {}; // id -> results array
|
var relatedCache = {}; // id -> results array
|
||||||
|
|
||||||
@@ -34,10 +36,12 @@
|
|||||||
var SIGNED_IN = !!(window.PETE_USER);
|
var SIGNED_IN = !!(window.PETE_USER);
|
||||||
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
||||||
|
|
||||||
// Read-aloud is a signed-in-only perk and needs Web Speech support. Share is
|
// Read-aloud is a signed-in-only perk, backed by server-side Piper voices
|
||||||
// available to everyone (native sheet where present, clipboard copy otherwise).
|
// (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
|
||||||
var TTS_OK = SIGNED_IN && typeof window.speechSynthesis !== "undefined" &&
|
// is available to everyone (native sheet where present, clipboard otherwise).
|
||||||
typeof window.SpeechSynthesisUtterance !== "undefined";
|
var TTS_CFG = (window.PETE_TTS && window.PETE_TTS.enabled &&
|
||||||
|
window.PETE_TTS.voices && window.PETE_TTS.voices.length) ? window.PETE_TTS : null;
|
||||||
|
var TTS_OK = SIGNED_IN && !!TTS_CFG;
|
||||||
|
|
||||||
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
||||||
var index = 0;
|
var index = 0;
|
||||||
@@ -484,12 +488,34 @@
|
|||||||
window.open(url, "_blank", "noopener");
|
window.open(url, "_blank", "noopener");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- read aloud (signed-in only) ------------------------------------------
|
// ---- read aloud (signed-in only, server-side Piper) -----------------------
|
||||||
|
// Each paragraph is synthesized on demand by POSTing its text to /api/tts and
|
||||||
|
// playing the returned WAV. We fetch the next paragraph while the current one
|
||||||
|
// plays, so the model-load + synthesis latency is hidden behind playback.
|
||||||
|
// speakGen is bumped on every stop/start/voice-change; any async callback that
|
||||||
|
// fires afterwards checks it against its captured gen and no-ops if stale.
|
||||||
|
var VOICE_KEY = "pete.reader.voice.v1";
|
||||||
var speaking = false;
|
var speaking = false;
|
||||||
var speakGen = 0; // bumped on every stop/start; stale utterance callbacks that
|
var speakGen = 0;
|
||||||
// fire after speechSynthesis.cancel() check it and no-op
|
var speakParas = []; // <p> elements currently highlighted
|
||||||
var speakParas = []; // <p> elements being read, for highlight
|
var speakItems = []; // [{el, text}] for the whole article
|
||||||
var speakQueue = []; // remaining {el, text} to utter
|
var speakIdx = 0; // index into speakItems currently playing
|
||||||
|
var speakReq = null; // in-flight fetch for the current paragraph (has .ctrl)
|
||||||
|
var prefetch = null; // { idx, gen, promise, abort } for the next paragraph
|
||||||
|
var audioEl = null; // shared <audio> element
|
||||||
|
var currentURL = null; // object URL currently loaded into audioEl
|
||||||
|
|
||||||
|
// Device-local voice choice, like the type prefs (see loadType).
|
||||||
|
var ttsVoice = "";
|
||||||
|
(function loadVoice() {
|
||||||
|
if (!TTS_CFG) return;
|
||||||
|
var saved = "";
|
||||||
|
try { saved = localStorage.getItem(VOICE_KEY) || ""; } catch (e) {}
|
||||||
|
var ok = TTS_CFG.voices.some(function (v) { return v.id === saved; });
|
||||||
|
ttsVoice = ok ? saved : TTS_CFG.default;
|
||||||
|
})();
|
||||||
|
function saveVoice() { try { localStorage.setItem(VOICE_KEY, ttsVoice); } catch (e) {} }
|
||||||
|
|
||||||
function speakingText() {
|
function speakingText() {
|
||||||
if (!articleEl) return [];
|
if (!articleEl) return [];
|
||||||
var out = [];
|
var out = [];
|
||||||
@@ -505,30 +531,95 @@
|
|||||||
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
|
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
|
||||||
speakParas = [];
|
speakParas = [];
|
||||||
}
|
}
|
||||||
function stopSpeak() {
|
function highlight(item) {
|
||||||
if (!TTS_OK) return;
|
|
||||||
speakGen++;
|
|
||||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
|
||||||
speaking = false;
|
|
||||||
speakQueue = [];
|
|
||||||
clearSpeakHighlight();
|
clearSpeakHighlight();
|
||||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
if (item && item.el) {
|
||||||
}
|
|
||||||
function speakNext() {
|
|
||||||
if (!speaking) return;
|
|
||||||
var item = speakQueue.shift();
|
|
||||||
if (!item) { stopSpeak(); return; }
|
|
||||||
clearSpeakHighlight();
|
|
||||||
if (item.el) {
|
|
||||||
item.el.classList.add("is-speaking");
|
item.el.classList.add("is-speaking");
|
||||||
speakParas.push(item.el);
|
speakParas.push(item.el);
|
||||||
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
|
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttsFetch kicks off synthesis of one paragraph. Returns { ctrl, promise },
|
||||||
|
// where promise resolves to an object URL for the audio blob.
|
||||||
|
function ttsFetch(text) {
|
||||||
|
var ctrl = new AbortController();
|
||||||
|
var promise = fetch("/api/tts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ voice: ttsVoice, text: text }),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("tts " + r.status);
|
||||||
|
return r.blob();
|
||||||
|
}).then(function (b) { return URL.createObjectURL(b); });
|
||||||
|
return { ctrl: ctrl, promise: promise };
|
||||||
|
}
|
||||||
|
function abortPrefetch() {
|
||||||
|
if (!prefetch) return;
|
||||||
|
try { prefetch.ctrl.abort(); } catch (e) {}
|
||||||
|
// If it already resolved to a URL, reclaim it.
|
||||||
|
prefetch.promise.then(function (u) { URL.revokeObjectURL(u); }, function () {});
|
||||||
|
prefetch = null;
|
||||||
|
}
|
||||||
|
function startPrefetch(i, gen) {
|
||||||
|
var item = speakItems[i];
|
||||||
|
if (!item) { prefetch = null; return; }
|
||||||
|
var req = ttsFetch(item.text);
|
||||||
|
prefetch = { idx: i, gen: gen, ctrl: req.ctrl, promise: req.promise };
|
||||||
|
}
|
||||||
|
function playURL(url, gen) {
|
||||||
|
if (!audioEl) audioEl = new Audio();
|
||||||
|
if (currentURL) URL.revokeObjectURL(currentURL);
|
||||||
|
currentURL = url;
|
||||||
|
audioEl.src = url;
|
||||||
|
audioEl.onended = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
|
||||||
|
audioEl.onerror = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
|
||||||
|
var pr = audioEl.play();
|
||||||
|
if (pr && pr.catch) pr.catch(function () {}); // ignore autoplay/abort rejections
|
||||||
|
}
|
||||||
|
function playIdx(i) {
|
||||||
|
if (!speaking) return;
|
||||||
var gen = speakGen;
|
var gen = speakGen;
|
||||||
var u = new SpeechSynthesisUtterance(item.text);
|
var item = speakItems[i];
|
||||||
u.onend = function () { if (speaking && gen === speakGen) speakNext(); };
|
if (!item) { stopSpeak(); return; }
|
||||||
u.onerror = function () { if (speaking && gen === speakGen) speakNext(); };
|
speakIdx = i;
|
||||||
try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); }
|
highlight(item);
|
||||||
|
var urlP;
|
||||||
|
if (prefetch && prefetch.idx === i && prefetch.gen === gen) {
|
||||||
|
urlP = prefetch.promise;
|
||||||
|
prefetch = null; // hand off; do not abort/revoke this one
|
||||||
|
} else {
|
||||||
|
abortPrefetch();
|
||||||
|
var req = ttsFetch(item.text);
|
||||||
|
speakReq = req;
|
||||||
|
urlP = req.promise;
|
||||||
|
}
|
||||||
|
urlP.then(function (url) {
|
||||||
|
if (!speaking || gen !== speakGen) { URL.revokeObjectURL(url); return; }
|
||||||
|
speakReq = null;
|
||||||
|
startPrefetch(i + 1, gen); // synthesize the next paragraph during playback
|
||||||
|
playURL(url, gen);
|
||||||
|
}).catch(function () {
|
||||||
|
if (!speaking || gen !== speakGen) return;
|
||||||
|
speakReq = null;
|
||||||
|
playIdx(i + 1); // skip a paragraph that failed rather than stalling
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function teardownAudio() {
|
||||||
|
if (speakReq) { try { speakReq.ctrl.abort(); } catch (e) {} speakReq = null; }
|
||||||
|
abortPrefetch();
|
||||||
|
if (audioEl) { try { audioEl.pause(); } catch (e) {} audioEl.onended = null; audioEl.onerror = null; audioEl.removeAttribute("src"); }
|
||||||
|
if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
|
||||||
|
}
|
||||||
|
function stopSpeak() {
|
||||||
|
if (!TTS_OK) return;
|
||||||
|
speakGen++;
|
||||||
|
speaking = false;
|
||||||
|
teardownAudio();
|
||||||
|
speakItems = [];
|
||||||
|
clearSpeakHighlight();
|
||||||
|
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
|
||||||
}
|
}
|
||||||
function startSpeak() {
|
function startSpeak() {
|
||||||
if (!TTS_OK) return;
|
if (!TTS_OK) return;
|
||||||
@@ -536,16 +627,43 @@
|
|||||||
var parts = speakingText();
|
var parts = speakingText();
|
||||||
if (!parts.length) { toast("Nothing to read yet"); return; }
|
if (!parts.length) { toast("Nothing to read yet"); return; }
|
||||||
speakGen++;
|
speakGen++;
|
||||||
try { window.speechSynthesis.cancel(); } catch (e) {}
|
teardownAudio();
|
||||||
speaking = true;
|
speaking = true;
|
||||||
speakQueue = parts.slice();
|
speakItems = parts;
|
||||||
|
speakIdx = 0;
|
||||||
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
|
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
|
||||||
speakNext();
|
playIdx(0);
|
||||||
}
|
}
|
||||||
function toggleSpeak() {
|
function toggleSpeak() {
|
||||||
if (!TTS_OK) return;
|
if (!TTS_OK) return;
|
||||||
if (speaking) stopSpeak(); else startSpeak();
|
if (speaking) stopSpeak(); else startSpeak();
|
||||||
}
|
}
|
||||||
|
// Switching voice restarts the current paragraph with the new voice so the
|
||||||
|
// change is audible immediately, keeping our place in the article.
|
||||||
|
function changeVoice(id) {
|
||||||
|
if (!TTS_CFG) return;
|
||||||
|
ttsVoice = id;
|
||||||
|
saveVoice();
|
||||||
|
if (voiceSel) voiceSel.value = ttsVoice;
|
||||||
|
if (speaking) {
|
||||||
|
speakGen++;
|
||||||
|
teardownAudio();
|
||||||
|
playIdx(speakIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildVoiceMenu() {
|
||||||
|
if (!voiceSel || !TTS_CFG) return;
|
||||||
|
voiceSel.innerHTML = "";
|
||||||
|
TTS_CFG.voices.forEach(function (v) {
|
||||||
|
var o = document.createElement("option");
|
||||||
|
o.value = v.id;
|
||||||
|
o.textContent = v.label;
|
||||||
|
voiceSel.appendChild(o);
|
||||||
|
});
|
||||||
|
voiceSel.value = ttsVoice;
|
||||||
|
voiceSel.addEventListener("change", function () { changeVoice(voiceSel.value); });
|
||||||
|
if (voiceRow) voiceRow.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- open / close ---------------------------------------------------------
|
// ---- open / close ---------------------------------------------------------
|
||||||
function openReader() {
|
function openReader() {
|
||||||
@@ -556,6 +674,9 @@
|
|||||||
document.body.classList.add("overflow-hidden");
|
document.body.classList.add("overflow-hidden");
|
||||||
applyType();
|
applyType();
|
||||||
show(firstUnread());
|
show(firstUnread());
|
||||||
|
// Move focus into the scroll region so Space / PageUp-Down / arrow keys
|
||||||
|
// scroll the story, not the page behind it, without a click first.
|
||||||
|
if (scrollEl) { try { scrollEl.focus({ preventScroll: true }); } catch (e) { scrollEl.focus(); } }
|
||||||
}
|
}
|
||||||
function closeReader() {
|
function closeReader() {
|
||||||
open = false;
|
open = false;
|
||||||
@@ -587,9 +708,9 @@
|
|||||||
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read-aloud: signed-in only, and only where Web Speech exists.
|
// Read-aloud: signed-in only, and only when server-side TTS is configured.
|
||||||
if (listenBtn) {
|
if (listenBtn) {
|
||||||
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); }
|
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); buildVoiceMenu(); }
|
||||||
else listenBtn.style.display = "none";
|
else listenBtn.style.display = "none";
|
||||||
}
|
}
|
||||||
// Share: available to everyone.
|
// Share: available to everyone.
|
||||||
|
|||||||
487
internal/web/static/js/weather-2d.js
Normal file
487
internal/web/static/js/weather-2d.js
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
// Canvas2D weather engine — the fallback when WebGL2 isn't available. This is
|
||||||
|
// the original hand-drawn renderer wrapped as an engine factory; weather.js
|
||||||
|
// owns the toggle, storage and the PeteWeather API and just calls
|
||||||
|
// set/start/stop here. New GPU-only variants (hail, haze, wind, aurora) alias
|
||||||
|
// to their nearest 2D look so the fallback never renders a blank page.
|
||||||
|
(function () {
|
||||||
|
window.PeteWeatherEngines = window.PeteWeatherEngines || {};
|
||||||
|
|
||||||
|
window.PeteWeatherEngines.canvas2d = function (canvas) {
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
var root = document.documentElement;
|
||||||
|
|
||||||
|
var W = 0, H = 0, DPR = 1;
|
||||||
|
function resize() {
|
||||||
|
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
W = canvas.clientWidth || window.innerWidth;
|
||||||
|
H = canvas.clientHeight || window.innerHeight;
|
||||||
|
canvas.width = Math.floor(W * DPR);
|
||||||
|
canvas.height = Math.floor(H * DPR);
|
||||||
|
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||||
|
}
|
||||||
|
resize();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
|
var aliases = { hail: "snow", haze: "motes", wind: "leaves", aurora: "clear" };
|
||||||
|
|
||||||
|
var counts = {
|
||||||
|
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
|
||||||
|
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
|
||||||
|
};
|
||||||
|
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
|
||||||
|
|
||||||
|
function rand(a, b) { return a + Math.random() * (b - a); }
|
||||||
|
|
||||||
|
var variant = null; // current rendered variant
|
||||||
|
var intensity = "heavy"; // light | medium | heavy
|
||||||
|
var particles = [];
|
||||||
|
var flash = 0; // lightning flash decay (storm only)
|
||||||
|
var nextBolt = 2; // seconds until next lightning bolt (storm only)
|
||||||
|
|
||||||
|
function spawn(initial) {
|
||||||
|
var p = {};
|
||||||
|
p.x = rand(0, W);
|
||||||
|
p.y = initial ? rand(0, H) : rand(-40, -10);
|
||||||
|
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
|
||||||
|
if (variant === "rain" || variant === "storm") {
|
||||||
|
p.vy = rand(700, 1100) * p.z;
|
||||||
|
p.vx = -rand(60, 120);
|
||||||
|
p.len = rand(10, 18) * p.z;
|
||||||
|
p.alpha = rand(0.25, 0.55);
|
||||||
|
} else if (variant === "snow") {
|
||||||
|
p.vy = rand(35, 75) * p.z;
|
||||||
|
p.swayAmp = rand(12, 34);
|
||||||
|
p.swayFreq = rand(0.3, 0.8);
|
||||||
|
p.swayPhase = rand(0, Math.PI * 2);
|
||||||
|
p.size = rand(2, 4.6) * p.z;
|
||||||
|
p.alpha = rand(0.65, 0.95);
|
||||||
|
} else if (variant === "clouds") {
|
||||||
|
// Soft puffs drifting across the upper sky.
|
||||||
|
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
|
||||||
|
p.x = initial ? rand(0, W) : -rand(120, 320);
|
||||||
|
p.vx = rand(8, 22);
|
||||||
|
p.size = rand(80, 170) * p.z;
|
||||||
|
p.alpha = rand(0.32, 0.55);
|
||||||
|
p.sprite = Math.floor(rand(0, 3));
|
||||||
|
} else if (variant === "fog") {
|
||||||
|
// Wide translucent bands creeping sideways.
|
||||||
|
p.y = rand(H * 0.1, H);
|
||||||
|
p.x = initial ? rand(0, W) : -rand(200, 500);
|
||||||
|
p.vx = rand(6, 16);
|
||||||
|
p.w = rand(260, 520);
|
||||||
|
p.h = rand(70, 150);
|
||||||
|
p.alpha = rand(0.05, 0.14);
|
||||||
|
} else if (variant === "petals" || variant === "jacaranda") {
|
||||||
|
p.vy = rand(60, 120);
|
||||||
|
p.swayAmp = rand(20, 50);
|
||||||
|
p.swayFreq = rand(0.4, 0.9);
|
||||||
|
p.swayPhase = rand(0, Math.PI * 2);
|
||||||
|
p.rot = rand(0, Math.PI * 2);
|
||||||
|
p.vrot = rand(-1.2, 1.2);
|
||||||
|
p.size = rand(8, 16) * p.z;
|
||||||
|
p.alpha = rand(0.75, 1.0);
|
||||||
|
if (variant === "jacaranda") {
|
||||||
|
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
|
||||||
|
p.sat = rand(55, 75);
|
||||||
|
p.light = rand(70, 82);
|
||||||
|
} else {
|
||||||
|
p.hue = rand(335, 355); // pink (almond/cherry)
|
||||||
|
p.sat = rand(60, 80);
|
||||||
|
p.light = rand(78, 88);
|
||||||
|
}
|
||||||
|
} else if (variant === "motes") {
|
||||||
|
p.vx = rand(-15, 25);
|
||||||
|
p.vy = rand(8, 25);
|
||||||
|
p.size = rand(1.2, 2.8) * p.z;
|
||||||
|
p.alpha = rand(0.35, 0.7);
|
||||||
|
p.twinklePhase = rand(0, Math.PI * 2);
|
||||||
|
p.twinkleFreq = rand(0.5, 1.5);
|
||||||
|
} else if (variant === "leaves") {
|
||||||
|
p.vy = rand(70, 130);
|
||||||
|
p.swayAmp = rand(30, 70);
|
||||||
|
p.swayFreq = rand(0.3, 0.6);
|
||||||
|
p.swayPhase = rand(0, Math.PI * 2);
|
||||||
|
p.rot = rand(0, Math.PI * 2);
|
||||||
|
p.vrot = rand(-2.0, 2.0);
|
||||||
|
p.size = rand(10, 18) * p.z;
|
||||||
|
p.alpha = rand(0.8, 1.0);
|
||||||
|
p.hue = rand(18, 42); // orange→amber→brown
|
||||||
|
p.light = rand(38, 55);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
var night = root.dataset.phase === "night";
|
||||||
|
function refreshNight() { night = root.dataset.phase === "night"; }
|
||||||
|
|
||||||
|
function drawRain(p) {
|
||||||
|
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
|
||||||
|
// more saturated blue plus a slightly thicker line to stay visible. Night
|
||||||
|
// keeps a paler tone so streaks read against the dark palette.
|
||||||
|
if (night) {
|
||||||
|
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
|
||||||
|
ctx.lineWidth = 1.3;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSnow(p) {
|
||||||
|
// Soft round flake with a faint glow so it reads on light and dark palettes.
|
||||||
|
var col = night ? "235,242,255" : "255,255,255";
|
||||||
|
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
if (!night) {
|
||||||
|
// A thin cool outline keeps white flakes visible against cream backgrounds.
|
||||||
|
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
|
||||||
|
ctx.lineWidth = 0.6;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
|
||||||
|
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
|
||||||
|
// offscreen canvases, then just drifted — cheap to animate.
|
||||||
|
var cloudSprites = null;
|
||||||
|
var cloudSpritesNight = null;
|
||||||
|
|
||||||
|
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
|
||||||
|
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
|
||||||
|
var cloudShapes = [
|
||||||
|
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
|
||||||
|
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
|
||||||
|
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
|
||||||
|
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
|
||||||
|
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
|
||||||
|
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeCloudSprite(col, shape) {
|
||||||
|
var c = document.createElement("canvas");
|
||||||
|
var w = 320, h = 224;
|
||||||
|
c.width = w; c.height = h;
|
||||||
|
var cx = c.getContext("2d");
|
||||||
|
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
|
||||||
|
cx.fillStyle = "rgba(" + col + ",1)";
|
||||||
|
for (var i = 0; i < shape.length; i++) {
|
||||||
|
cx.beginPath();
|
||||||
|
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
|
||||||
|
cx.fill();
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCloudSprites() {
|
||||||
|
if (cloudSprites && cloudSpritesNight === night) return;
|
||||||
|
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
|
||||||
|
// tone against the dark palette.
|
||||||
|
var col = night ? "206,216,236" : "150,164,190";
|
||||||
|
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
|
||||||
|
cloudSpritesNight = night;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCloud(p) {
|
||||||
|
ensureCloudSprites();
|
||||||
|
var spr = cloudSprites[p.sprite % cloudSprites.length];
|
||||||
|
var w = p.size * 2.6;
|
||||||
|
var h = w * (spr.height / spr.width);
|
||||||
|
ctx.globalAlpha = p.alpha;
|
||||||
|
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFog(p) {
|
||||||
|
var col = night ? "150,160,180" : "230,228,224";
|
||||||
|
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
|
||||||
|
g.addColorStop(0, "rgba(" + col + ",0)");
|
||||||
|
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
|
||||||
|
g.addColorStop(1, "rgba(" + col + ",0)");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMoon(cx, cy, mr) {
|
||||||
|
var TAU = Math.PI * 2;
|
||||||
|
ctx.save();
|
||||||
|
// Clip everything to the lunar disc so shading stays inside the sphere.
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, mr, 0, TAU);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
// Spherical body shading — light source upper-right, terminator lower-left.
|
||||||
|
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
|
||||||
|
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
|
||||||
|
body.addColorStop(0, "rgba(249,251,255,0.97)");
|
||||||
|
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
|
||||||
|
body.addColorStop(1, "rgba(140,151,180,0.85)");
|
||||||
|
ctx.fillStyle = body;
|
||||||
|
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||||
|
|
||||||
|
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
|
||||||
|
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
|
||||||
|
limb.addColorStop(0, "rgba(18,24,46,0)");
|
||||||
|
limb.addColorStop(1, "rgba(18,24,46,0.4)");
|
||||||
|
ctx.fillStyle = limb;
|
||||||
|
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Soft outer halo, drawn unclipped around the disc.
|
||||||
|
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
|
||||||
|
halo.addColorStop(0, "rgba(220,230,255,0.28)");
|
||||||
|
halo.addColorStop(1, "rgba(220,230,255,0)");
|
||||||
|
ctx.fillStyle = halo;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawClearGlow(t) {
|
||||||
|
// No particles — render a single calm sun (day) or moon (night) glow that
|
||||||
|
// breathes very slowly, plus a few stars at night.
|
||||||
|
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
|
||||||
|
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
|
||||||
|
if (night) {
|
||||||
|
// Ambient sky glow around the moon.
|
||||||
|
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||||
|
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
|
||||||
|
g.addColorStop(1, "rgba(220,230,255,0)");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
drawMoon(cx, cy, r * 0.16);
|
||||||
|
} else {
|
||||||
|
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||||
|
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
|
||||||
|
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
|
||||||
|
gd.addColorStop(1, "rgba(255,214,120,0)");
|
||||||
|
ctx.fillStyle = gd;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic star field for clear nights (seeded so they don't jitter).
|
||||||
|
var stars = [];
|
||||||
|
function buildStars() {
|
||||||
|
stars = [];
|
||||||
|
var n = 60;
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
// Cheap LCG-ish spread keyed by index — stable across frames.
|
||||||
|
var sx = ((i * 73 + 11) % 100) / 100;
|
||||||
|
var sy = ((i * 37 + 7) % 100) / 100;
|
||||||
|
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function drawStars(t) {
|
||||||
|
for (var i = 0; i < stars.length; i++) {
|
||||||
|
var s = stars[i];
|
||||||
|
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
|
||||||
|
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPetals(p) {
|
||||||
|
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
|
||||||
|
var s = p.size;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate(p.rot);
|
||||||
|
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
var cx = Math.cos(a) * s * 0.32;
|
||||||
|
var cy = Math.sin(a) * s * 0.32;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLeaf(p) {
|
||||||
|
var s = p.size;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate(p.rot);
|
||||||
|
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
|
||||||
|
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
|
||||||
|
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
// Almond/lozenge leaf — pointed at both ends.
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-s * 0.55, 0);
|
||||||
|
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
|
||||||
|
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
// Midrib vein.
|
||||||
|
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-s * 0.5, 0);
|
||||||
|
ctx.lineTo(s * 0.5, 0);
|
||||||
|
ctx.stroke();
|
||||||
|
// Small stem nub at the base.
|
||||||
|
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
|
||||||
|
ctx.lineWidth = 1.0;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-s * 0.55, 0);
|
||||||
|
ctx.lineTo(-s * 0.7, -s * 0.04);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMote(p, t) {
|
||||||
|
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
|
||||||
|
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
|
||||||
|
// use a deeper saturated honey so the motes stand out against warm cream
|
||||||
|
// and peach backgrounds; alpha also gets a bump.
|
||||||
|
var color, a;
|
||||||
|
if (night) {
|
||||||
|
color = "255,240,180";
|
||||||
|
a = p.alpha * (0.45 + 0.55 * twinkle);
|
||||||
|
} else {
|
||||||
|
color = "130,80,180";
|
||||||
|
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
|
||||||
|
}
|
||||||
|
// Soft glow halo.
|
||||||
|
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
|
||||||
|
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
|
||||||
|
g.addColorStop(1, "rgba(" + color + ",0)");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Bright core.
|
||||||
|
ctx.fillStyle = "rgba(" + color + "," + a + ")";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
var last = performance.now();
|
||||||
|
var raf = 0;
|
||||||
|
|
||||||
|
function step(now) {
|
||||||
|
var dt = Math.min(0.05, (now - last) / 1000);
|
||||||
|
last = now;
|
||||||
|
var t = now / 1000;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
refreshNight();
|
||||||
|
|
||||||
|
if (variant === "clear") {
|
||||||
|
drawClearGlow(t);
|
||||||
|
if (night) drawStars(t);
|
||||||
|
raf = requestAnimationFrame(step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storm: drive the lightning flash before drawing rain over it.
|
||||||
|
if (variant === "storm") {
|
||||||
|
nextBolt -= dt;
|
||||||
|
if (nextBolt <= 0) {
|
||||||
|
flash = 1;
|
||||||
|
nextBolt = rand(2.5, 7);
|
||||||
|
}
|
||||||
|
if (flash > 0) {
|
||||||
|
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < particles.length; i++) {
|
||||||
|
var p = particles[i];
|
||||||
|
if (variant === "rain" || variant === "storm") {
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
if (p.y > H + 20 || p.x < -40) {
|
||||||
|
particles[i] = spawn(false);
|
||||||
|
particles[i].x = rand(0, W + 60);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
drawRain(p);
|
||||||
|
} else if (variant === "snow") {
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||||
|
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
|
||||||
|
var ox = p.x; p.x = ox + sxOff;
|
||||||
|
drawSnow(p);
|
||||||
|
p.x = ox;
|
||||||
|
} else if (variant === "clouds") {
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
|
||||||
|
drawCloud(p);
|
||||||
|
} else if (variant === "fog") {
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
|
||||||
|
drawFog(p);
|
||||||
|
} else if (variant === "motes") {
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
|
||||||
|
particles[i] = spawn(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
drawMote(p, t);
|
||||||
|
} else {
|
||||||
|
// Drifting variants: petals, jacaranda, leaves.
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
p.rot += p.vrot * dt;
|
||||||
|
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
||||||
|
var origX = p.x;
|
||||||
|
p.x = origX + sway;
|
||||||
|
if (p.y > H + 30) {
|
||||||
|
particles[i] = spawn(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
|
||||||
|
else drawLeaf(p);
|
||||||
|
p.x = origX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (raf) return;
|
||||||
|
last = performance.now();
|
||||||
|
raf = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
raf = 0;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the particle pool for a variant. The controller decides whether to
|
||||||
|
// start rendering afterwards.
|
||||||
|
function set(v, inten) {
|
||||||
|
variant = aliases[v] || v || null;
|
||||||
|
intensity = inten || "heavy";
|
||||||
|
flash = 0; nextBolt = rand(1.5, 4);
|
||||||
|
particles = [];
|
||||||
|
if (variant === "clear") { buildStars(); }
|
||||||
|
if (!variant) { stop(); return; }
|
||||||
|
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
|
||||||
|
for (var i = 0; i < N; i++) particles.push(spawn(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: "canvas2d", set: set, start: start, stop: stop };
|
||||||
|
};
|
||||||
|
})();
|
||||||
1028
internal/web/static/js/weather-gl.js
Normal file
1028
internal/web/static/js/weather-gl.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
// Canvas-based weather rendered behind the page. Two drivers feed it:
|
// Weather controller. Two drivers feed the background canvas:
|
||||||
//
|
//
|
||||||
// 1. The server picks a *seasonal* variant + intensity from the Lisbon-local
|
// 1. The server picks a *seasonal* variant + intensity from the Lisbon-local
|
||||||
// date and writes them to <html data-weather / data-intensity>. This is the
|
// date and writes them to <html data-weather / data-intensity>. This is the
|
||||||
@@ -7,15 +7,21 @@
|
|||||||
// forecast and calls PeteWeather.set(variant, intensity) to override the
|
// forecast and calls PeteWeather.set(variant, intensity) to override the
|
||||||
// background with live conditions.
|
// background with live conditions.
|
||||||
//
|
//
|
||||||
// Each variant has a hand-drawn shape so silhouettes are recognizable instead
|
// Rendering is delegated to an engine: the WebGL2 one (weather-gl.js) when the
|
||||||
// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves.
|
// browser supports it — GPU sprites, shader fog/aurora, real lightning — with
|
||||||
// Live-weather variants add: clear, clouds, snow, fog, storm.
|
// the original Canvas2D renderer (weather-2d.js) as the fallback. This file
|
||||||
|
// only owns the toggle button, the on/off preference and the public API.
|
||||||
(function () {
|
(function () {
|
||||||
var root = document.documentElement;
|
var root = document.documentElement;
|
||||||
var canvas = document.getElementById("pete-weather");
|
var canvas = document.getElementById("pete-weather");
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
var engines = window.PeteWeatherEngines || {};
|
||||||
|
var engine = (engines.webgl2 && engines.webgl2(canvas)) || null;
|
||||||
|
if (!engine && engines.canvas2d) engine = engines.canvas2d(canvas);
|
||||||
|
if (!engine) return;
|
||||||
|
|
||||||
var STORAGE_KEY = "pete-weather-off";
|
var STORAGE_KEY = "pete-weather-off";
|
||||||
var toggleBtn = document.querySelector("[data-weather-toggle]");
|
var toggleBtn = document.querySelector("[data-weather-toggle]");
|
||||||
var star = document.querySelector("[data-weather-star]");
|
var star = document.querySelector("[data-weather-star]");
|
||||||
@@ -41,504 +47,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ctx = canvas.getContext("2d");
|
|
||||||
var W = 0, H = 0, DPR = 1;
|
|
||||||
function resize() {
|
|
||||||
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
W = canvas.clientWidth || window.innerWidth;
|
|
||||||
H = canvas.clientHeight || window.innerHeight;
|
|
||||||
canvas.width = Math.floor(W * DPR);
|
|
||||||
canvas.height = Math.floor(H * DPR);
|
|
||||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
|
||||||
}
|
|
||||||
resize();
|
|
||||||
window.addEventListener("resize", resize);
|
|
||||||
|
|
||||||
var counts = {
|
|
||||||
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
|
|
||||||
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
|
|
||||||
};
|
|
||||||
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
|
|
||||||
|
|
||||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
|
||||||
|
|
||||||
var variant = null; // current rendered variant
|
|
||||||
var intensity = "heavy"; // light | medium | heavy
|
|
||||||
var particles = [];
|
|
||||||
var flash = 0; // lightning flash decay (storm only)
|
|
||||||
var nextBolt = 2; // seconds until next lightning bolt (storm only)
|
|
||||||
|
|
||||||
function spawn(initial) {
|
|
||||||
var p = {};
|
|
||||||
p.x = rand(0, W);
|
|
||||||
p.y = initial ? rand(0, H) : rand(-40, -10);
|
|
||||||
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
|
|
||||||
if (variant === "rain" || variant === "storm") {
|
|
||||||
p.vy = rand(700, 1100) * p.z;
|
|
||||||
p.vx = -rand(60, 120);
|
|
||||||
p.len = rand(10, 18) * p.z;
|
|
||||||
p.alpha = rand(0.25, 0.55);
|
|
||||||
} else if (variant === "snow") {
|
|
||||||
p.vy = rand(35, 75) * p.z;
|
|
||||||
p.swayAmp = rand(12, 34);
|
|
||||||
p.swayFreq = rand(0.3, 0.8);
|
|
||||||
p.swayPhase = rand(0, Math.PI * 2);
|
|
||||||
p.size = rand(2, 4.6) * p.z;
|
|
||||||
p.alpha = rand(0.65, 0.95);
|
|
||||||
} else if (variant === "clouds") {
|
|
||||||
// Soft puffs drifting across the upper sky.
|
|
||||||
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
|
|
||||||
p.x = initial ? rand(0, W) : -rand(120, 320);
|
|
||||||
p.vx = rand(8, 22);
|
|
||||||
p.size = rand(80, 170) * p.z;
|
|
||||||
p.alpha = rand(0.32, 0.55);
|
|
||||||
p.sprite = Math.floor(rand(0, 3));
|
|
||||||
} else if (variant === "fog") {
|
|
||||||
// Wide translucent bands creeping sideways.
|
|
||||||
p.y = rand(H * 0.1, H);
|
|
||||||
p.x = initial ? rand(0, W) : -rand(200, 500);
|
|
||||||
p.vx = rand(6, 16);
|
|
||||||
p.w = rand(260, 520);
|
|
||||||
p.h = rand(70, 150);
|
|
||||||
p.alpha = rand(0.05, 0.14);
|
|
||||||
} else if (variant === "petals" || variant === "jacaranda") {
|
|
||||||
p.vy = rand(60, 120);
|
|
||||||
p.swayAmp = rand(20, 50);
|
|
||||||
p.swayFreq = rand(0.4, 0.9);
|
|
||||||
p.swayPhase = rand(0, Math.PI * 2);
|
|
||||||
p.rot = rand(0, Math.PI * 2);
|
|
||||||
p.vrot = rand(-1.2, 1.2);
|
|
||||||
p.size = rand(8, 16) * p.z;
|
|
||||||
p.alpha = rand(0.75, 1.0);
|
|
||||||
if (variant === "jacaranda") {
|
|
||||||
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
|
|
||||||
p.sat = rand(55, 75);
|
|
||||||
p.light = rand(70, 82);
|
|
||||||
} else {
|
|
||||||
p.hue = rand(335, 355); // pink (almond/cherry)
|
|
||||||
p.sat = rand(60, 80);
|
|
||||||
p.light = rand(78, 88);
|
|
||||||
}
|
|
||||||
} else if (variant === "motes") {
|
|
||||||
p.vx = rand(-15, 25);
|
|
||||||
p.vy = rand(8, 25);
|
|
||||||
p.size = rand(1.2, 2.8) * p.z;
|
|
||||||
p.alpha = rand(0.35, 0.7);
|
|
||||||
p.twinklePhase = rand(0, Math.PI * 2);
|
|
||||||
p.twinkleFreq = rand(0.5, 1.5);
|
|
||||||
} else if (variant === "leaves") {
|
|
||||||
p.vy = rand(70, 130);
|
|
||||||
p.swayAmp = rand(30, 70);
|
|
||||||
p.swayFreq = rand(0.3, 0.6);
|
|
||||||
p.swayPhase = rand(0, Math.PI * 2);
|
|
||||||
p.rot = rand(0, Math.PI * 2);
|
|
||||||
p.vrot = rand(-2.0, 2.0);
|
|
||||||
p.size = rand(10, 18) * p.z;
|
|
||||||
p.alpha = rand(0.8, 1.0);
|
|
||||||
p.hue = rand(18, 42); // orange→amber→brown
|
|
||||||
p.light = rand(38, 55);
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
var night = root.dataset.phase === "night";
|
|
||||||
function refreshNight() { night = root.dataset.phase === "night"; }
|
|
||||||
|
|
||||||
function drawRain(p) {
|
|
||||||
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
|
|
||||||
// more saturated blue plus a slightly thicker line to stay visible. Night
|
|
||||||
// keeps a paler tone so streaks read against the dark palette.
|
|
||||||
if (night) {
|
|
||||||
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
} else {
|
|
||||||
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
|
|
||||||
ctx.lineWidth = 1.3;
|
|
||||||
}
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(p.x, p.y);
|
|
||||||
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawSnow(p) {
|
|
||||||
// Soft round flake with a faint glow so it reads on light and dark palettes.
|
|
||||||
var col = night ? "235,242,255" : "255,255,255";
|
|
||||||
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
if (!night) {
|
|
||||||
// A thin cool outline keeps white flakes visible against cream backgrounds.
|
|
||||||
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
|
|
||||||
ctx.lineWidth = 0.6;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
|
|
||||||
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
|
|
||||||
// offscreen canvases, then just drifted — cheap to animate.
|
|
||||||
var cloudSprites = null;
|
|
||||||
var cloudSpritesNight = null;
|
|
||||||
|
|
||||||
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
|
|
||||||
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
|
|
||||||
var cloudShapes = [
|
|
||||||
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
|
|
||||||
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
|
|
||||||
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
|
|
||||||
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
|
|
||||||
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
|
|
||||||
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
|
|
||||||
];
|
|
||||||
|
|
||||||
function makeCloudSprite(col, shape) {
|
|
||||||
var c = document.createElement("canvas");
|
|
||||||
var w = 320, h = 224;
|
|
||||||
c.width = w; c.height = h;
|
|
||||||
var cx = c.getContext("2d");
|
|
||||||
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
|
|
||||||
cx.fillStyle = "rgba(" + col + ",1)";
|
|
||||||
for (var i = 0; i < shape.length; i++) {
|
|
||||||
cx.beginPath();
|
|
||||||
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
|
|
||||||
cx.fill();
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureCloudSprites() {
|
|
||||||
if (cloudSprites && cloudSpritesNight === night) return;
|
|
||||||
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
|
|
||||||
// tone against the dark palette.
|
|
||||||
var col = night ? "206,216,236" : "150,164,190";
|
|
||||||
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
|
|
||||||
cloudSpritesNight = night;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawCloud(p) {
|
|
||||||
ensureCloudSprites();
|
|
||||||
var spr = cloudSprites[p.sprite % cloudSprites.length];
|
|
||||||
var w = p.size * 2.6;
|
|
||||||
var h = w * (spr.height / spr.width);
|
|
||||||
ctx.globalAlpha = p.alpha;
|
|
||||||
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawFog(p) {
|
|
||||||
var col = night ? "150,160,180" : "230,228,224";
|
|
||||||
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
|
|
||||||
g.addColorStop(0, "rgba(" + col + ",0)");
|
|
||||||
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
|
|
||||||
g.addColorStop(1, "rgba(" + col + ",0)");
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawMoon(cx, cy, mr) {
|
|
||||||
var TAU = Math.PI * 2;
|
|
||||||
ctx.save();
|
|
||||||
// Clip everything to the lunar disc so shading stays inside the sphere.
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, mr, 0, TAU);
|
|
||||||
ctx.clip();
|
|
||||||
|
|
||||||
// Spherical body shading — light source upper-right, terminator lower-left.
|
|
||||||
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
|
|
||||||
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
|
|
||||||
body.addColorStop(0, "rgba(249,251,255,0.97)");
|
|
||||||
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
|
|
||||||
body.addColorStop(1, "rgba(140,151,180,0.85)");
|
|
||||||
ctx.fillStyle = body;
|
|
||||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
|
||||||
|
|
||||||
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
|
|
||||||
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
|
|
||||||
limb.addColorStop(0, "rgba(18,24,46,0)");
|
|
||||||
limb.addColorStop(1, "rgba(18,24,46,0.4)");
|
|
||||||
ctx.fillStyle = limb;
|
|
||||||
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Soft outer halo, drawn unclipped around the disc.
|
|
||||||
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
|
|
||||||
halo.addColorStop(0, "rgba(220,230,255,0.28)");
|
|
||||||
halo.addColorStop(1, "rgba(220,230,255,0)");
|
|
||||||
ctx.fillStyle = halo;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawClearGlow(t) {
|
|
||||||
// No particles — render a single calm sun (day) or moon (night) glow that
|
|
||||||
// breathes very slowly, plus a few stars at night.
|
|
||||||
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
|
|
||||||
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
|
|
||||||
if (night) {
|
|
||||||
// Ambient sky glow around the moon.
|
|
||||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
|
||||||
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
|
|
||||||
g.addColorStop(1, "rgba(220,230,255,0)");
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
drawMoon(cx, cy, r * 0.16);
|
|
||||||
} else {
|
|
||||||
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
|
||||||
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
|
|
||||||
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
|
|
||||||
gd.addColorStop(1, "rgba(255,214,120,0)");
|
|
||||||
ctx.fillStyle = gd;
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deterministic star field for clear nights (seeded so they don't jitter).
|
|
||||||
var stars = [];
|
|
||||||
function buildStars() {
|
|
||||||
stars = [];
|
|
||||||
var n = 60;
|
|
||||||
for (var i = 0; i < n; i++) {
|
|
||||||
// Cheap LCG-ish spread keyed by index — stable across frames.
|
|
||||||
var sx = ((i * 73 + 11) % 100) / 100;
|
|
||||||
var sy = ((i * 37 + 7) % 100) / 100;
|
|
||||||
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function drawStars(t) {
|
|
||||||
for (var i = 0; i < stars.length; i++) {
|
|
||||||
var s = stars[i];
|
|
||||||
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
|
|
||||||
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPetals(p) {
|
|
||||||
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
|
|
||||||
var s = p.size;
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(p.x, p.y);
|
|
||||||
ctx.rotate(p.rot);
|
|
||||||
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
|
|
||||||
for (var i = 0; i < 5; i++) {
|
|
||||||
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
|
||||||
var cx = Math.cos(a) * s * 0.32;
|
|
||||||
var cy = Math.sin(a) * s * 0.32;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawLeaf(p) {
|
|
||||||
var s = p.size;
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(p.x, p.y);
|
|
||||||
ctx.rotate(p.rot);
|
|
||||||
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
|
|
||||||
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
|
|
||||||
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
// Almond/lozenge leaf — pointed at both ends.
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-s * 0.55, 0);
|
|
||||||
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
|
|
||||||
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
// Midrib vein.
|
|
||||||
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
|
|
||||||
ctx.lineWidth = 0.8;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-s * 0.5, 0);
|
|
||||||
ctx.lineTo(s * 0.5, 0);
|
|
||||||
ctx.stroke();
|
|
||||||
// Small stem nub at the base.
|
|
||||||
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
|
|
||||||
ctx.lineWidth = 1.0;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-s * 0.55, 0);
|
|
||||||
ctx.lineTo(-s * 0.7, -s * 0.04);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawMote(p, t) {
|
|
||||||
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
|
|
||||||
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
|
|
||||||
// use a deeper saturated honey so the motes stand out against warm cream
|
|
||||||
// and peach backgrounds; alpha also gets a bump.
|
|
||||||
var color, a;
|
|
||||||
if (night) {
|
|
||||||
color = "255,240,180";
|
|
||||||
a = p.alpha * (0.45 + 0.55 * twinkle);
|
|
||||||
} else {
|
|
||||||
color = "130,80,180";
|
|
||||||
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
|
|
||||||
}
|
|
||||||
// Soft glow halo.
|
|
||||||
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
|
|
||||||
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
|
|
||||||
g.addColorStop(1, "rgba(" + color + ",0)");
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
// Bright core.
|
|
||||||
ctx.fillStyle = "rgba(" + color + "," + a + ")";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
var last = performance.now();
|
|
||||||
var raf = 0;
|
|
||||||
|
|
||||||
function step(now) {
|
|
||||||
var dt = Math.min(0.05, (now - last) / 1000);
|
|
||||||
last = now;
|
|
||||||
var t = now / 1000;
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
refreshNight();
|
|
||||||
|
|
||||||
if (variant === "clear") {
|
|
||||||
drawClearGlow(t);
|
|
||||||
if (night) drawStars(t);
|
|
||||||
raf = requestAnimationFrame(step);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Storm: drive the lightning flash before drawing rain over it.
|
|
||||||
if (variant === "storm") {
|
|
||||||
nextBolt -= dt;
|
|
||||||
if (nextBolt <= 0) {
|
|
||||||
flash = 1;
|
|
||||||
nextBolt = rand(2.5, 7);
|
|
||||||
}
|
|
||||||
if (flash > 0) {
|
|
||||||
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < particles.length; i++) {
|
|
||||||
var p = particles[i];
|
|
||||||
if (variant === "rain" || variant === "storm") {
|
|
||||||
p.x += p.vx * dt;
|
|
||||||
p.y += p.vy * dt;
|
|
||||||
if (p.y > H + 20 || p.x < -40) {
|
|
||||||
particles[i] = spawn(false);
|
|
||||||
particles[i].x = rand(0, W + 60);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
drawRain(p);
|
|
||||||
} else if (variant === "snow") {
|
|
||||||
p.y += p.vy * dt;
|
|
||||||
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
|
||||||
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
|
|
||||||
var ox = p.x; p.x = ox + sxOff;
|
|
||||||
drawSnow(p);
|
|
||||||
p.x = ox;
|
|
||||||
} else if (variant === "clouds") {
|
|
||||||
p.x += p.vx * dt;
|
|
||||||
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
|
|
||||||
drawCloud(p);
|
|
||||||
} else if (variant === "fog") {
|
|
||||||
p.x += p.vx * dt;
|
|
||||||
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
|
|
||||||
drawFog(p);
|
|
||||||
} else if (variant === "motes") {
|
|
||||||
p.x += p.vx * dt;
|
|
||||||
p.y += p.vy * dt;
|
|
||||||
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
|
|
||||||
particles[i] = spawn(false);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
drawMote(p, t);
|
|
||||||
} else {
|
|
||||||
// Drifting variants: petals, jacaranda, leaves.
|
|
||||||
p.y += p.vy * dt;
|
|
||||||
p.rot += p.vrot * dt;
|
|
||||||
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
|
|
||||||
var origX = p.x;
|
|
||||||
p.x = origX + sway;
|
|
||||||
if (p.y > H + 30) {
|
|
||||||
particles[i] = spawn(false);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
|
|
||||||
else drawLeaf(p);
|
|
||||||
p.x = origX;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
if (raf) return;
|
|
||||||
last = performance.now();
|
|
||||||
raf = requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
function stop() {
|
|
||||||
if (raf) cancelAnimationFrame(raf);
|
|
||||||
raf = 0;
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the particle pool for a variant and (re)start rendering if enabled.
|
|
||||||
function configure(v, inten) {
|
|
||||||
variant = v || null;
|
|
||||||
intensity = inten || "heavy";
|
|
||||||
flash = 0; nextBolt = rand(1.5, 4);
|
|
||||||
particles = [];
|
|
||||||
if (variant === "clear") { buildStars(); }
|
|
||||||
if (!variant) { stop(); return; }
|
|
||||||
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
|
|
||||||
for (var i = 0; i < N; i++) particles.push(spawn(true));
|
|
||||||
if (enabled) start();
|
|
||||||
}
|
|
||||||
|
|
||||||
var enabled = !userDisabled() && !reducedMotion;
|
var enabled = !userDisabled() && !reducedMotion;
|
||||||
|
|
||||||
// Public hook so weather-forecast.js can swap the seasonal default for live
|
// Public hook so weather-forecast.js (and the /weather demo) can swap the
|
||||||
// conditions. Passing a falsy variant clears the canvas.
|
// seasonal default for other conditions. Passing a falsy variant clears the
|
||||||
|
// canvas.
|
||||||
window.PeteWeather = {
|
window.PeteWeather = {
|
||||||
set: function (v, inten) {
|
set: function (v, inten) {
|
||||||
root.dataset.weather = v || "";
|
root.dataset.weather = v || "";
|
||||||
if (inten) root.dataset.intensity = inten;
|
if (inten) root.dataset.intensity = inten;
|
||||||
configure(v, inten || intensity);
|
engine.set(v || null, inten || root.dataset.intensity || "heavy");
|
||||||
|
if (enabled && v) engine.start();
|
||||||
},
|
},
|
||||||
isEnabled: function () { return enabled; }
|
isEnabled: function () { return enabled; },
|
||||||
|
renderer: function () { return engine.name; }
|
||||||
};
|
};
|
||||||
|
|
||||||
syncBtn(enabled);
|
syncBtn(enabled);
|
||||||
configure(root.dataset.weather, root.dataset.intensity);
|
engine.set(root.dataset.weather || null, root.dataset.intensity || "heavy");
|
||||||
|
if (enabled && root.dataset.weather) engine.start();
|
||||||
|
|
||||||
if (toggleBtn) {
|
if (toggleBtn) {
|
||||||
toggleBtn.addEventListener("click", function () {
|
toggleBtn.addEventListener("click", function () {
|
||||||
enabled = !enabled;
|
enabled = !enabled;
|
||||||
setDisabled(!enabled);
|
setDisabled(!enabled);
|
||||||
syncBtn(enabled);
|
syncBtn(enabled);
|
||||||
if (enabled) start(); else stop();
|
if (enabled) engine.start(); else engine.stop();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", function () {
|
document.addEventListener("visibilitychange", function () {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
if (document.hidden) stop();
|
if (document.hidden) engine.stop();
|
||||||
else start();
|
else engine.start();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||||
// drops every cache that doesn't match the current version.
|
// drops every cache that doesn't match the current version.
|
||||||
var CACHE_VERSION = "v3";
|
var CACHE_VERSION = "v4";
|
||||||
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
|||||||
var SHELL_ASSETS = [
|
var SHELL_ASSETS = [
|
||||||
"/static/css/output.css",
|
"/static/css/output.css",
|
||||||
"/static/js/prefs.js",
|
"/static/js/prefs.js",
|
||||||
|
"/static/js/weather-2d.js",
|
||||||
|
"/static/js/weather-gl.js",
|
||||||
"/static/js/weather.js",
|
"/static/js/weather.js",
|
||||||
"/static/js/weather-forecast.js",
|
"/static/js/weather-forecast.js",
|
||||||
"/static/js/search.js",
|
"/static/js/search.js",
|
||||||
|
|||||||
@@ -246,6 +246,10 @@
|
|||||||
<button type="button" data-paper="sepia">Sepia</button>
|
<button type="button" data-paper="sepia">Sepia</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pete-reader-typerow pete-reader-voicerow" data-reader-voicerow hidden>
|
||||||
|
<span class="pete-reader-typelabel">Voice</span>
|
||||||
|
<select class="pete-reader-voice" data-reader-voice aria-label="Read-aloud voice"></select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
||||||
@@ -253,7 +257,7 @@
|
|||||||
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
|
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pete-reader-scroll" data-reader-scroll>
|
<div class="pete-reader-scroll" data-reader-scroll tabindex="-1">
|
||||||
<article class="pete-reader-article" data-reader-article></article>
|
<article class="pete-reader-article" data-reader-article></article>
|
||||||
<div class="pete-reader-related" data-reader-related hidden></div>
|
<div class="pete-reader-related" data-reader-related hidden></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,8 +309,11 @@
|
|||||||
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
||||||
window.PETE_PREFS = {{.UserPrefs}};
|
window.PETE_PREFS = {{.UserPrefs}};
|
||||||
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
||||||
|
window.PETE_TTS = {{.TTS}};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/prefs.js" defer></script>
|
<script src="/static/js/prefs.js" defer></script>
|
||||||
|
<script src="/static/js/weather-2d.js" defer></script>
|
||||||
|
<script src="/static/js/weather-gl.js" defer></script>
|
||||||
<script src="/static/js/weather.js" defer></script>
|
<script src="/static/js/weather.js" defer></script>
|
||||||
<script src="/static/js/weather-forecast.js" defer></script>
|
<script src="/static/js/weather-forecast.js" defer></script>
|
||||||
<script src="/static/js/search.js" defer></script>
|
<script src="/static/js/search.js" defer></script>
|
||||||
|
|||||||
@@ -3,14 +3,21 @@
|
|||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
<h1 class="font-display text-3xl font-bold mb-1">Weather demo</h1>
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
|
||||||
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. The canvas reloads on each click.</p>
|
<h1 class="font-display text-3xl font-bold">Weather demo</h1>
|
||||||
|
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||||
|
<span data-demo-renderer class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 uppercase tracking-wider text-[color:var(--ink)]/60">renderer: …</span>
|
||||||
|
<span data-demo-fps class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 tabular-nums text-[color:var(--ink)]/60">— fps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. Switching is instant, no reload.</p>
|
||||||
|
|
||||||
<div class="space-y-3 text-sm">
|
<div class="space-y-3 text-sm">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
|
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
|
||||||
{{range $v := .Variants}}
|
{{range $v := .Variants}}
|
||||||
<a href="?variant={{$v}}&intensity={{$.Intensity}}&phase={{$.PhaseSel}}"
|
<a href="?variant={{$v}}&intensity={{$.Intensity}}&phase={{$.PhaseSel}}"
|
||||||
|
data-demo-variant="{{$v}}"
|
||||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||||
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
|
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -19,6 +26,7 @@
|
|||||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
|
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
|
||||||
{{range $i := .Intensities}}
|
{{range $i := .Intensities}}
|
||||||
<a href="?variant={{$.Variant}}&intensity={{$i}}&phase={{$.PhaseSel}}"
|
<a href="?variant={{$.Variant}}&intensity={{$i}}&phase={{$.PhaseSel}}"
|
||||||
|
data-demo-intensity="{{$i}}"
|
||||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||||
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
|
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -27,6 +35,7 @@
|
|||||||
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
|
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
|
||||||
{{range $p := .Phases}}
|
{{range $p := .Phases}}
|
||||||
<a href="?variant={{$.Variant}}&intensity={{$.Intensity}}&phase={{$p}}"
|
<a href="?variant={{$.Variant}}&intensity={{$.Intensity}}&phase={{$p}}"
|
||||||
|
data-demo-phase="{{$p}}"
|
||||||
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
|
||||||
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
|
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -34,7 +43,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
|
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
|
||||||
Current: <code class="font-semibold">{{.Weather.Season}} / {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
|
Current: <code data-demo-current class="font-semibold">{{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
|
||||||
|
<span class="mx-1">·</span> Tip: aurora and clear night want the night phase; wind and haze are the new autumn gusts and Saharan calima.
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -50,8 +60,76 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
<p class="font-display">Card B</p>
|
<p class="font-display">Card B</p>
|
||||||
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Move your window taller to give the particles more room.</p>
|
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Make your window taller to give the particles more room.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Instant switching: the pill links stay real URLs for no-JS visitors, but
|
||||||
|
// with JS we swap the effect in place via PeteWeather.set and keep the URL
|
||||||
|
// in sync with replaceState.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var state = {
|
||||||
|
variant: {{.Variant}},
|
||||||
|
intensity: {{.Intensity}},
|
||||||
|
phase: {{.PhaseSel}}
|
||||||
|
};
|
||||||
|
|
||||||
|
function markActive(attr, value) {
|
||||||
|
document.querySelectorAll("[" + attr + "]").forEach(function (el) {
|
||||||
|
var on = el.getAttribute(attr) === value;
|
||||||
|
el.classList.toggle("bg-[color:var(--accent)]", on);
|
||||||
|
el.classList.toggle("text-white", on);
|
||||||
|
el.classList.toggle("font-semibold", on);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
root.dataset.phase = state.phase;
|
||||||
|
if (window.PeteWeather) window.PeteWeather.set(state.variant, state.intensity);
|
||||||
|
markActive("data-demo-variant", state.variant);
|
||||||
|
markActive("data-demo-intensity", state.intensity);
|
||||||
|
markActive("data-demo-phase", state.phase);
|
||||||
|
var cur = document.querySelector("[data-demo-current]");
|
||||||
|
if (cur) cur.textContent = state.variant + " / " + state.intensity + " / " + state.phase;
|
||||||
|
var label = document.querySelector("[data-phase-label]");
|
||||||
|
if (label) label.textContent = state.phase;
|
||||||
|
history.replaceState(null, "", "?variant=" + state.variant + "&intensity=" + state.intensity + "&phase=" + state.phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
var a = e.target.closest && e.target.closest("[data-demo-variant],[data-demo-intensity],[data-demo-phase]");
|
||||||
|
if (!a || !window.PeteWeather) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (a.hasAttribute("data-demo-variant")) state.variant = a.getAttribute("data-demo-variant");
|
||||||
|
if (a.hasAttribute("data-demo-intensity")) state.intensity = a.getAttribute("data-demo-intensity");
|
||||||
|
if (a.hasAttribute("data-demo-phase")) state.phase = a.getAttribute("data-demo-phase");
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
|
||||||
|
var rEl = document.querySelector("[data-demo-renderer]");
|
||||||
|
if (rEl && window.PeteWeather && window.PeteWeather.renderer) {
|
||||||
|
rEl.textContent = "renderer: " + window.PeteWeather.renderer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight FPS meter, demo page only. Counts frames on its own rAF and
|
||||||
|
// refreshes the pill once a second.
|
||||||
|
var fpsEl = document.querySelector("[data-demo-fps]");
|
||||||
|
if (fpsEl) {
|
||||||
|
var frames = 0, lastStamp = performance.now();
|
||||||
|
function tick(now) {
|
||||||
|
frames++;
|
||||||
|
if (now - lastStamp >= 1000) {
|
||||||
|
fpsEl.textContent = Math.round(frames * 1000 / (now - lastStamp)) + " fps";
|
||||||
|
frames = 0;
|
||||||
|
lastStamp = now;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
199
internal/web/tts.go
Normal file
199
internal/web/tts.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user