Update from source aab42616
This commit is contained in:
parent
6870db970d
commit
65c1339fb2
46 changed files with 2696 additions and 83 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -145,3 +145,6 @@ data-private/
|
|||
/public/assets/
|
||||
/public/__gallery/
|
||||
/public/index.html
|
||||
|
||||
# Local plugin backends (backend/<name>/) — the compiled binaries are rebuilt from source, never committed.
|
||||
/backend/bin/
|
||||
|
|
|
|||
456
backend/rudder-engine/devices.go
Normal file
456
backend/rudder-engine/devices.go
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
// devices.go — microphone discovery, hot-plug, and the sound-server environment.
|
||||
//
|
||||
// The Jitsi Meet pattern (lib-jitsi-meet modules/RTC/RTCUtils.js), done natively because Rudder records
|
||||
// outside the browser:
|
||||
//
|
||||
// Jitsi (browser) Rudder (native, PipeWire/PulseAudio)
|
||||
// navigator.mediaDevices.enumerateDevices() → pactl -f json list sources
|
||||
// 'devicechange' event + polling fallback → pactl subscribe (+ re-list on every source/card event)
|
||||
// compareAvailableMediaDevices(new) → sameDevices(old, new) — only emit on a real change
|
||||
// getUserMedia({audio:{deviceId:{exact:id}}}) → parec --device=<id>
|
||||
//
|
||||
// Jitsi also reports the device list to analytics (sendDeviceListToAnalytics). Rudder never does:
|
||||
// the list goes to the local plugin over SSE and nowhere else.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// audioEnv gives child processes a path to the user's sound server even when the engine was launched
|
||||
// from a context without a desktop session (a detached shell, a service, an agent's terminal). Measured
|
||||
// 2026-09-27: started with no XDG_RUNTIME_DIR, parec exited instantly with "Connection refused" and every
|
||||
// recording captured nothing, although the socket sat at /run/user/<uid>/pulse/native all along.
|
||||
func audioEnv() []string {
|
||||
env := os.Environ()
|
||||
rt := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if rt == "" {
|
||||
rt = fmt.Sprintf("/run/user/%d", os.Getuid())
|
||||
if st, err := os.Stat(rt); err == nil && st.IsDir() {
|
||||
env = append(env, "XDG_RUNTIME_DIR="+rt)
|
||||
}
|
||||
}
|
||||
// systemctl --user (a headset-reset command may restart PipeWire) needs the session bus.
|
||||
if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
|
||||
if _, err := os.Stat(rt + "/bus"); err == nil {
|
||||
env = append(env, "DBUS_SESSION_BUS_ADDRESS=unix:path="+rt+"/bus")
|
||||
}
|
||||
}
|
||||
if os.Getenv("PULSE_SERVER") == "" {
|
||||
if _, err := os.Stat(rt + "/pulse/native"); err == nil {
|
||||
env = append(env, "PULSE_SERVER=unix:"+rt+"/pulse/native")
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// Device is one microphone as the plugin shows it.
|
||||
type Device struct {
|
||||
ID string `json:"id"` // the sound server's source name: what parec --device takes
|
||||
Label string `json:"label"` // human name ("My Headset", "Built-in Audio Analog Stereo")
|
||||
Bus string `json:"bus"` // bluetooth | usb | pci | ...
|
||||
Default bool `json:"default"` // the system's default input
|
||||
Muted bool `json:"muted"`
|
||||
Volume string `json:"volume"` // e.g. "52%"
|
||||
State string `json:"state"` // RUNNING | IDLE | SUSPENDED
|
||||
}
|
||||
|
||||
// DeviceList is the whole picture: the mics, plus sound cards that have a mic but are switched off
|
||||
// (the laptop's built-in card, profile "off", is the common case — its mic simply doesn't exist until
|
||||
// the card is turned on in Sound settings).
|
||||
type DeviceList struct {
|
||||
Devices []Device `json:"devices"`
|
||||
Default string `json:"default"`
|
||||
OffCards []string `json:"offCards"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func runPactl(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("pactl", args...)
|
||||
cmd.Env = audioEnv()
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
func listDevices() DeviceList {
|
||||
out := DeviceList{Devices: []Device{}, OffCards: []string{}}
|
||||
if _, err := exec.LookPath("pactl"); err != nil {
|
||||
out.Error = "pactl not found: install pipewire-pulse or pulseaudio-utils to list microphones"
|
||||
return out
|
||||
}
|
||||
if b, err := runPactl("get-default-source"); err == nil {
|
||||
out.Default = strings.TrimSpace(string(b))
|
||||
}
|
||||
b, err := runPactl("-f", "json", "list", "sources")
|
||||
if err != nil {
|
||||
out.Error = "cannot reach the sound server (pactl list sources failed: " + err.Error() + ")"
|
||||
return out
|
||||
}
|
||||
var raw []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"`
|
||||
Mute bool `json:"mute"`
|
||||
Volume map[string]any `json:"volume"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
out.Error = "unexpected pactl output: " + err.Error()
|
||||
return out
|
||||
}
|
||||
for _, s := range raw {
|
||||
// A ".monitor" source is the loopback of a SPEAKER, not a microphone: listing it would let the
|
||||
// user "record" their own music. Hidden, as Jitsi lists only audioinput kinds.
|
||||
if strings.HasSuffix(s.Name, ".monitor") || s.Properties["device.class"] == "monitor" {
|
||||
continue
|
||||
}
|
||||
d := Device{ID: s.Name, Label: s.Description, State: s.State, Muted: s.Mute, Default: s.Name == out.Default}
|
||||
if bus, ok := s.Properties["device.bus"].(string); ok {
|
||||
d.Bus = bus
|
||||
}
|
||||
for _, ch := range s.Volume { // first channel is representative
|
||||
if m, ok := ch.(map[string]any); ok {
|
||||
if p, ok := m["value_percent"].(string); ok {
|
||||
d.Volume = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Devices = append(out.Devices, d)
|
||||
}
|
||||
// Cards whose active profile is "off" but which offer an input profile.
|
||||
if b, err := runPactl("-f", "json", "list", "cards"); err == nil {
|
||||
var cards []struct {
|
||||
Name string `json:"name"`
|
||||
ActiveProfile string `json:"active_profile"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
Profiles map[string]struct {
|
||||
Sources int `json:"sources"`
|
||||
Available bool `json:"available"`
|
||||
} `json:"profiles"`
|
||||
}
|
||||
if json.Unmarshal(b, &cards) == nil {
|
||||
for _, c := range cards {
|
||||
if c.ActiveProfile != "off" {
|
||||
continue
|
||||
}
|
||||
for _, p := range c.Profiles {
|
||||
if p.Sources > 0 && p.Available {
|
||||
label, _ := c.Properties["device.description"].(string)
|
||||
if label == "" {
|
||||
label = c.Name
|
||||
}
|
||||
out.OffCards = append(out.OffCards, label)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameDevices mirrors Jitsi's compareAvailableMediaDevices: emit only when something a user would
|
||||
// notice changed (a mic came, went, was muted, or the default moved), not on every state flicker.
|
||||
func sameDevices(a, b DeviceList) bool {
|
||||
if a.Default != b.Default || len(a.Devices) != len(b.Devices) || a.Error != b.Error || len(a.OffCards) != len(b.OffCards) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Devices {
|
||||
x, y := a.Devices[i], b.Devices[i]
|
||||
if x.ID != y.ID || x.Label != y.Label || x.Muted != y.Muted || x.Volume != y.Volume {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// deviceWatcher keeps the current list and pushes a "devices" event on every real change. It listens
|
||||
// to `pactl subscribe` (the native 'devicechange') and, like Jitsi, also polls as a fallback in case
|
||||
// the subscription dies.
|
||||
type deviceWatcher struct {
|
||||
mu sync.Mutex
|
||||
last DeviceList
|
||||
hub *hub
|
||||
logs *ring
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) current() DeviceList {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.last
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) refresh(reason string) {
|
||||
now := listDevices()
|
||||
w.mu.Lock()
|
||||
changed := !sameDevices(w.last, now)
|
||||
w.last = now
|
||||
w.mu.Unlock()
|
||||
if changed {
|
||||
w.logs.add("microphones (%s): %d found, default=%s %s", reason, len(now.Devices), now.Default, now.Error)
|
||||
w.hub.send(event{"type": "devices", "devices": now})
|
||||
}
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) run() {
|
||||
w.refresh("startup")
|
||||
// Poll fallback (Jitsi polls enumerateDevices when devicechange is unavailable).
|
||||
go func() {
|
||||
for range time.Tick(5 * time.Second) {
|
||||
w.refresh("poll")
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
cmd := exec.Command("pactl", "subscribe")
|
||||
cmd.Env = audioEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err == nil && cmd.Start() == nil {
|
||||
sc := bufio.NewScanner(out)
|
||||
var debounce *time.Timer
|
||||
for sc.Scan() {
|
||||
l := sc.Text()
|
||||
if strings.Contains(l, " card ") || (strings.Contains(l, " source ") && !strings.Contains(l, "source-output")) {
|
||||
w.logs.add("sound-server event: %s", l)
|
||||
}
|
||||
if strings.Contains(l, " source ") || strings.Contains(l, " card ") || strings.Contains(l, " server ") {
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
debounce = time.AfterFunc(300*time.Millisecond, func() { w.refresh("hot-plug") })
|
||||
}
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
time.Sleep(3 * time.Second) // sound server restarted or unreachable: retry
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// micTest records ~3 s from one device WITHOUT transcribing and says, in plain words, whether the mic
|
||||
// works — Rudder's version of Jitsi's pre-join audio preview. Level events stream to the plugin while
|
||||
// it runs so the meter moves.
|
||||
func (e *engine) micTest(device string) event {
|
||||
cmds := recorderCmds(device)
|
||||
var cmd *exec.Cmd
|
||||
for _, c := range cmds {
|
||||
if _, err := exec.LookPath(c[0]); err == nil {
|
||||
cmd = exec.Command(c[0], c[1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if cmd == nil {
|
||||
return event{"ok": false, "verdict": "No recorder found (need parec, pw-record or arecord)."}
|
||||
}
|
||||
cmd.Env = audioEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
var errBuf strings.Builder
|
||||
cmd.Stderr = &errBuf
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil || cmd.Start() != nil {
|
||||
return event{"ok": false, "verdict": "Could not start the recorder."}
|
||||
}
|
||||
defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
|
||||
|
||||
frame := make([]byte, 3200) // 100 ms
|
||||
peak, frames, zeroFrames := 0, 0, 0
|
||||
var sumSq float64
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := io.ReadFull(out, frame)
|
||||
if err != nil {
|
||||
why := strings.TrimSpace(errBuf.String())
|
||||
e.logs.add("mic test on %q failed after %d frames: %v | %s", device, frames, err, why)
|
||||
return event{"ok": false, "verdict": "The microphone produced no audio. " + why, "recorder": cmd.Path}
|
||||
}
|
||||
fp := 0
|
||||
for i := 0; i+1 < n; i += 2 {
|
||||
v := int(int16(uint16(frame[i]) | uint16(frame[i+1])<<8))
|
||||
sumSq += float64(v * v)
|
||||
if v < 0 {
|
||||
v = -v
|
||||
}
|
||||
if v > fp {
|
||||
fp = v
|
||||
}
|
||||
}
|
||||
if fp == 0 {
|
||||
zeroFrames++
|
||||
}
|
||||
if fp > peak {
|
||||
peak = fp
|
||||
}
|
||||
frames++
|
||||
e.hub.send(event{"type": "level", "peak": float64(fp) / 32768, "test": true})
|
||||
}
|
||||
rms := 0.0
|
||||
if frames > 0 {
|
||||
rms = math.Sqrt(sumSq/float64(frames*1600)) / 32768
|
||||
}
|
||||
res := event{"ok": true, "peak": float64(peak) / 32768, "rms": rms, "frames": frames}
|
||||
switch {
|
||||
case zeroFrames == frames:
|
||||
res["ok"] = false
|
||||
res["verdict"] = "Pure silence — the mic is connected but sends nothing. It may be muted, or a Bluetooth headset's mic link may have dropped (try reconnecting the headset or pick another mic)."
|
||||
case peak < 330: // below about -40 dBFS
|
||||
res["verdict"] = "Very quiet — Rudder can hear the mic, but barely. Speak closer or raise its input volume."
|
||||
default:
|
||||
res["verdict"] = "Working — Rudder can hear you."
|
||||
}
|
||||
e.logs.add("mic test on %q: peak=%d rms=%.4f frames=%d verdict=%s", device, peak, rms, frames, res["verdict"])
|
||||
return res
|
||||
}
|
||||
|
||||
// ─── Optional Bluetooth headset reset ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Some Bluetooth headsets drop the mic side of the HFP link (exact-zero audio while everything looks
|
||||
// connected; the kernel logs `hci0: corrupted SCO packet`). Re-opening the recorder does not revive
|
||||
// it; a full reset (disconnect → restart PipeWire/WirePlumber → reconnect) can. That is machine-specific,
|
||||
// so Rudder only runs one if RUDDER_BT_RESET names an executable that does it (exit 0 = headset works
|
||||
// again). Rate-limited: never more than once per 20 s.
|
||||
func btResetPath() string {
|
||||
p := os.Getenv("RUDDER_BT_RESET")
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() && st.Mode()&0o111 != 0 {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
resetMu sync.Mutex
|
||||
lastReset time.Time
|
||||
)
|
||||
|
||||
// runHeadsetReset runs the RUDDER_BT_RESET command and reports whether it said the headset works again.
|
||||
func runHeadsetReset(logs *ring) (ok bool, ran bool) {
|
||||
path := btResetPath()
|
||||
if path == "" {
|
||||
return false, false
|
||||
}
|
||||
resetMu.Lock()
|
||||
if time.Since(lastReset) < 20*time.Second {
|
||||
resetMu.Unlock()
|
||||
logs.add("headset reset skipped: last one was %.0fs ago (rate limit 20s)", time.Since(lastReset).Seconds())
|
||||
return false, false
|
||||
}
|
||||
lastReset = time.Now()
|
||||
resetMu.Unlock()
|
||||
|
||||
logs.add("running %s (headset reset + CVSD)", path)
|
||||
cmd := exec.Command(path)
|
||||
cmd.Env = audioEnv()
|
||||
done := make(chan struct{})
|
||||
var out []byte
|
||||
var err error
|
||||
go func() { out, err = cmd.CombinedOutput(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
logs.add("headset reset timed out after 60s")
|
||||
return false, true
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if l != "" {
|
||||
logs.add("[headset reset] %s", l)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logs.add("headset reset failed: %v", err)
|
||||
return false, true
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
|
||||
// ─── Bluetooth voice-profile helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// btCard maps a Bluetooth mic (bluez_input.<MAC>.<n>, or "" = the default input) to its card.
|
||||
func btCard(device string) string {
|
||||
if device == "" {
|
||||
if b, err := runPactl("get-default-source"); err == nil {
|
||||
device = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(device, "bluez_input.") {
|
||||
return ""
|
||||
}
|
||||
mac := strings.TrimPrefix(device, "bluez_input.")
|
||||
if i := strings.LastIndex(mac, "."); i > 0 {
|
||||
mac = mac[:i]
|
||||
}
|
||||
return "bluez_card." + mac
|
||||
}
|
||||
|
||||
func currentProfile(card string) string {
|
||||
if card == "" {
|
||||
return "(not bluetooth)"
|
||||
}
|
||||
b, err := runPactl("-f", "json", "list", "cards")
|
||||
if err != nil {
|
||||
return "?"
|
||||
}
|
||||
var cards []struct {
|
||||
Name string `json:"name"`
|
||||
ActiveProfile string `json:"active_profile"`
|
||||
}
|
||||
if json.Unmarshal(b, &cards) != nil {
|
||||
return "?"
|
||||
}
|
||||
for _, c := range cards {
|
||||
if c.Name == card {
|
||||
return c.ActiveProfile
|
||||
}
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// switchProfile forces a fresh voice link: CVSD if the card is on anything else, otherwise a bounce
|
||||
// through mSBC and back to CVSD (a same-profile set is a no-op and would not renegotiate the link).
|
||||
func switchProfile(card string, logs *ring) error {
|
||||
before := currentProfile(card)
|
||||
target := "headset-head-unit-cvsd"
|
||||
if before == target {
|
||||
if _, err := runPactl("set-card-profile", card, "headset-head-unit-msbc"); err != nil {
|
||||
logs.add("profile bounce via msbc failed: %v", err)
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
if out, err := runPactl("set-card-profile", card, target); err != nil {
|
||||
return fmt.Errorf("pactl set-card-profile %s %s: %v %s", card, target, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond) // let the new SCO link and the source come up
|
||||
logs.add("voice profile %s → %s", before, currentProfile(card))
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchKernelBluetooth copies kernel Bluetooth messages into the log while a session runs
|
||||
// (`journalctl -k` is readable without root on this system).
|
||||
func watchKernelBluetooth(ctx context.Context, logs *ring) {
|
||||
cmd := exec.CommandContext(ctx, "journalctl", "-k", "-f", "-n", "0", "-o", "cat")
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil || cmd.Start() != nil {
|
||||
return
|
||||
}
|
||||
sc := bufio.NewScanner(out)
|
||||
for sc.Scan() {
|
||||
if l := sc.Text(); strings.Contains(strings.ToLower(l), "bluetooth") {
|
||||
logs.add("kernel: %s", l)
|
||||
}
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
5
backend/rudder-engine/go.mod
Normal file
5
backend/rudder-engine/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module dx-maps/backend/rudder-engine
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
2
backend/rudder-engine/go.sum
Normal file
2
backend/rudder-engine/go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
797
backend/rudder-engine/main.go
Normal file
797
backend/rudder-engine/main.go
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
// rudder-engine — the local speech backend for the rudder-voice plugin.
|
||||
//
|
||||
// WHY THIS EXISTS: rudder-voice is a DESKTOP-target plugin. Speech-to-text runs on the user's own
|
||||
// machine, never in the browser and never on a server. In dev, `bun run dev` starts this process and
|
||||
// Vite forwards /api/rudder/* to it; in the desktop app, Tauri runs it as a sidecar. Same binary,
|
||||
// same API, so "works in the Plugin Studio" means "works on desktop".
|
||||
//
|
||||
// browser / webview ──HTTP+SSE──► rudder-engine (127.0.0.1:RUDDER_PORT)
|
||||
// ├─ recorder: parec → pw-record → arecord (mic open ONLY while recording)
|
||||
// └─ WebSocket ──► nemo-speech serve (127.0.0.1:RUDDER_NEMO_PORT)
|
||||
// NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR GGUF
|
||||
//
|
||||
// NO TELEMETRY, NO NETWORK: the runtime is NVIDIA NeMo-Speech.cpp (ggml). ONNX-based runtimes were
|
||||
// REJECTED: measured 2026-09-26, the tested ONNX GenAI runtime looked up a vendor telemetry host even
|
||||
// with telemetry "disabled". NeMo-Speech.cpp was traced with strace: zero sockets, zero DNS.
|
||||
// Its only network path is an optional model download that shells out to `curl` when the model is not
|
||||
// a local file — so this engine REFUSES to start unless RUDDER_MODEL is an existing local file.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ─── Config (env, with local defaults) ──────────────────────────────────────────────────────────
|
||||
|
||||
type config struct {
|
||||
Port string // this engine's HTTP port
|
||||
NemoPort string // the warm NeMo-Speech server's port (loopback only)
|
||||
NemoBin string // path to the nemo-speech executable
|
||||
Model string // local GGUF path — MUST exist (see the no-network note above)
|
||||
Language string // default language code
|
||||
EndpointMs int // silence that ends an utterance
|
||||
}
|
||||
|
||||
func envOr(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
home, _ := os.UserHomeDir()
|
||||
base := filepath.Join(home, ".local", "share", "rudder")
|
||||
return config{
|
||||
Port: envOr("RUDDER_PORT", "5193"),
|
||||
NemoPort: envOr("RUDDER_NEMO_PORT", "5194"),
|
||||
NemoBin: envOr("RUDDER_NEMO_BIN", filepath.Join(base, "bin", "nemo-speech-0.1.0-linux-x86_64-cpu", "bin", "nemo-speech")),
|
||||
Model: envOr("RUDDER_MODEL", filepath.Join(base, "models", "nemotron-3.5-asr-streaming-0.6b.q8_0.gguf")),
|
||||
Language: envOr("RUDDER_LANG", "en-US"),
|
||||
EndpointMs: 800,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log ring: every line is also served at /api/rudder/log so the Debug panel can copy it ──────
|
||||
|
||||
type ring struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
}
|
||||
|
||||
// ★ PRIVACY: the log is what people paste to an agent (🐞 Debug → copy report), so it must never carry
|
||||
// anything that identifies them. Every line is redacted as it is written: device MAC addresses (inside
|
||||
// Bluetooth device names like bluez_input.AA_BB_…) and the home folder (which contains the username).
|
||||
// Transcribed words are never logged at all.
|
||||
var (
|
||||
macRe = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:_]){5}[0-9a-f]{2}\b`)
|
||||
homeDir = func() string { h, _ := os.UserHomeDir(); return h }()
|
||||
)
|
||||
|
||||
func redact(s string) string {
|
||||
s = macRe.ReplaceAllString(s, "XX:XX:XX:XX:XX:XX")
|
||||
if homeDir != "" && homeDir != "/" {
|
||||
s = strings.ReplaceAll(s, homeDir, "~")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (r *ring) add(format string, a ...any) {
|
||||
line := redact(time.Now().Format("15:04:05.000") + " " + fmt.Sprintf(format, a...))
|
||||
log.Println(line)
|
||||
r.mu.Lock()
|
||||
r.lines = append(r.lines, line)
|
||||
if len(r.lines) > 3000 {
|
||||
r.lines = r.lines[len(r.lines)-3000:]
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *ring) text() string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return strings.Join(r.lines, "\n")
|
||||
}
|
||||
|
||||
// ─── Event hub: Server-Sent Events to every open plugin ─────────────────────────────────────────
|
||||
|
||||
type event map[string]any
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan []byte]struct{}
|
||||
}
|
||||
|
||||
func (h *hub) subscribe() chan []byte {
|
||||
c := make(chan []byte, 64)
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *hub) unsubscribe(c chan []byte) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *hub) send(e event) {
|
||||
b, _ := json.Marshal(e)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c <- b:
|
||||
default: // a slow client drops an event rather than stalling the audio path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Engine: supervises nemo-speech serve and one recording session at a time ───────────────────
|
||||
|
||||
type engine struct {
|
||||
cfg config
|
||||
logs *ring
|
||||
hub *hub
|
||||
|
||||
mu sync.Mutex
|
||||
state string // starting | ready | error
|
||||
stateErr string
|
||||
nemo *exec.Cmd
|
||||
recording bool
|
||||
recorder string
|
||||
language string
|
||||
stopSession func()
|
||||
device string
|
||||
devs *deviceWatcher
|
||||
nemoKey string
|
||||
}
|
||||
|
||||
func (e *engine) setState(s, errText string) {
|
||||
e.mu.Lock()
|
||||
e.state, e.stateErr = s, errText
|
||||
e.mu.Unlock()
|
||||
e.hub.send(event{"type": "status", "engine": s, "error": errText})
|
||||
}
|
||||
|
||||
func (e *engine) status() event {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return event{
|
||||
"engine": e.state, "error": e.stateErr, "recording": e.recording, "recorder": e.recorder,
|
||||
"language": e.language, "defaultLanguage": e.cfg.Language, "device": e.device,
|
||||
"runtime": "NVIDIA NeMo-Speech.cpp (ggml, CPU)", "model": filepath.Base(e.cfg.Model),
|
||||
}
|
||||
}
|
||||
|
||||
// startNemo launches the warm speech server. The model loads and warms up once (~20 s on CPU),
|
||||
// so starting and stopping a recording is instant afterwards.
|
||||
func (e *engine) startNemo() {
|
||||
if st, err := os.Stat(e.cfg.Model); err != nil || st.IsDir() {
|
||||
e.setState("error", "model file not found: "+e.cfg.Model+" (refusing to start: a non-local model would make the runtime download it)")
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(e.cfg.NemoBin); err != nil {
|
||||
e.setState("error", "nemo-speech runtime not found: "+e.cfg.NemoBin)
|
||||
return
|
||||
}
|
||||
e.setState("starting", "")
|
||||
// A fresh random key each start: the speech server answers only this engine, not any other local
|
||||
// program or web page that finds its port.
|
||||
cmd := exec.Command(e.cfg.NemoBin, "serve",
|
||||
"--host", "127.0.0.1", "--port", e.cfg.NemoPort,
|
||||
"--asr-model", e.cfg.Model, "--device", "cpu", "--no-ui", "--api-key", e.nemoKey)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} // dies with us
|
||||
out, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = cmd.Stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
e.setState("error", "could not start nemo-speech: "+err.Error())
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.nemo = cmd
|
||||
e.mu.Unlock()
|
||||
e.logs.add("nemo-speech serve started (pid %d) on 127.0.0.1:%s", cmd.Process.Pid, e.cfg.NemoPort)
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := out.Read(buf)
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(buf[:n])), "\n") {
|
||||
if l != "" {
|
||||
e.logs.add("[nemo] %s", l)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
e.setState("error", fmt.Sprintf("nemo-speech exited: %v", err))
|
||||
}()
|
||||
// Ready = the model list answers.
|
||||
go func() {
|
||||
url := "http://127.0.0.1:" + e.cfg.NemoPort + "/v1/models"
|
||||
for i := 0; i < 600; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+e.nemoKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
e.logs.add("nemo-speech ready after %.1fs", float64(i+1)*0.5)
|
||||
e.setState("ready", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
s := e.state
|
||||
e.mu.Unlock()
|
||||
if s == "error" {
|
||||
return
|
||||
}
|
||||
}
|
||||
e.setState("error", "nemo-speech did not become ready within 5 minutes")
|
||||
}()
|
||||
}
|
||||
|
||||
// recorderCmds: raw s16le 16 kHz mono PCM on stdout. parec first — on PipeWire/Bluetooth desktops it
|
||||
// talks to the sound server directly (the old Rudder's hardest-won lesson: PortAudio/ALSA-only
|
||||
// capture stalls on Bluetooth headsets).
|
||||
// RUDDER_RECORDER overrides the list with one space-separated command (e.g. a test that replays a
|
||||
// PCM file), so the engine can be tested end to end without a microphone.
|
||||
// device is a sound-server source name from /api/rudder/devices; empty = the system default input.
|
||||
func recorderCmds(device string) [][]string {
|
||||
if v := os.Getenv("RUDDER_RECORDER"); v != "" {
|
||||
return [][]string{strings.Fields(v)}
|
||||
}
|
||||
parec := []string{"parec", "--format=s16le", "--rate=16000", "--channels=1", "--raw", "--latency-msec=100"}
|
||||
pw := []string{"pw-record", "--format=s16", "--rate=16000", "--channels=1"}
|
||||
if device != "" {
|
||||
parec = append(parec, "--device="+device)
|
||||
pw = append(pw, "--target="+device)
|
||||
}
|
||||
pw = append(pw, "-")
|
||||
cmds := [][]string{parec, pw}
|
||||
if device == "" { // arecord can't address a sound-server source by name
|
||||
cmds = append(cmds, []string{"arecord", "-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"})
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (e *engine) start(lang, device string) error {
|
||||
e.mu.Lock()
|
||||
if e.state != "ready" {
|
||||
e.mu.Unlock()
|
||||
return fmt.Errorf("engine is %s — wait for it to be ready", e.state)
|
||||
}
|
||||
if e.recording {
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if lang == "" {
|
||||
lang = e.cfg.Language
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
ws, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:"+e.cfg.NemoPort+"/v1/audio/transcriptions/realtime",
|
||||
http.Header{"Authorization": {"Bearer " + e.nemoKey}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to speech server: %w", err)
|
||||
}
|
||||
upd, _ := json.Marshal(event{"type": "session.update", "session": event{
|
||||
"sample_rate": 16000, "language": lang, "automatic_punctuation": true, "endpointing_ms": e.cfg.EndpointMs,
|
||||
}})
|
||||
if err := ws.WriteMessage(websocket.TextMessage, upd); err != nil {
|
||||
ws.Close()
|
||||
return fmt.Errorf("session.update: %w", err)
|
||||
}
|
||||
|
||||
// Bluetooth headsets start on CVSD, not mSBC. Measured 2026-09-27 on a Bluetooth headset: on mSBC the
|
||||
// mic link died 3-5 s into EVERY session that had speech (kernel: corrupted SCO packets); after
|
||||
// switching to CVSD it ran 9.5 minutes without a drop. CVSD is 8 kHz, and Nemotron's transcript of an
|
||||
// 8 kHz copy of the test clip differed by one word. RUDDER_KEEP_MSBC=1 opts out.
|
||||
if card := btCard(device); card != "" && os.Getenv("RUDDER_KEEP_MSBC") == "" {
|
||||
if p := currentProfile(card); p == "headset-head-unit-msbc" {
|
||||
e.logs.add("bluetooth headset on mSBC — switching to CVSD before recording (stable voice link)")
|
||||
e.hub.send(event{"type": "recovering", "message": "Switching your Bluetooth headset to its stable voice mode…"})
|
||||
if err := switchProfile(card, e.logs); err != nil {
|
||||
e.logs.add("pre-switch to CVSD failed: %v", err)
|
||||
} else {
|
||||
e.hub.send(event{"type": "recovered", "message": "Headset is in its stable voice mode — go ahead and talk."})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
first, err := spawnRecorder(device, e.logs)
|
||||
if err != nil {
|
||||
ws.Close()
|
||||
return err
|
||||
}
|
||||
recName := first.name
|
||||
var recMu sync.Mutex
|
||||
cur := first // the live recorder; replaced when a dead mic link is recovered
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var writeMu sync.Mutex
|
||||
done := make(chan struct{})
|
||||
|
||||
e.mu.Lock()
|
||||
e.recording, e.recorder, e.language, e.device = true, recName, lang, device
|
||||
e.stopSession = func() {
|
||||
cancel()
|
||||
recMu.Lock()
|
||||
cur.kill()
|
||||
recMu.Unlock()
|
||||
writeMu.Lock()
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte(`{"type":"input_audio_buffer.commit"}`))
|
||||
writeMu.Unlock()
|
||||
select { // let the final words arrive
|
||||
case <-done:
|
||||
case <-time.After(4 * time.Second):
|
||||
}
|
||||
ws.Close()
|
||||
}
|
||||
e.mu.Unlock()
|
||||
e.logs.add("recording started: recorder=%s device=%q language=%s profile=%s", recName, device, lang, currentProfile(btCard(device)))
|
||||
go watchKernelBluetooth(ctx, e.logs)
|
||||
e.hub.send(event{"type": "recording", "recording": true, "recorder": recName, "language": lang, "device": device})
|
||||
for _, d := range e.devs.current().Devices {
|
||||
if (d.ID == device || (device == "" && d.Default)) && d.Muted {
|
||||
e.hub.send(event{"type": "warning", "message": "Your microphone \"" + d.Label + "\" is muted in the system sound settings — unmute it or nothing will be heard."})
|
||||
}
|
||||
}
|
||||
|
||||
// Audio pump: 100 ms frames → speech server, plus a level meter and dead-mic RECOVERY.
|
||||
go func() {
|
||||
frame := make([]byte, 3200)
|
||||
var silentSince time.Time
|
||||
recoveries := 0
|
||||
var recovering atomic.Bool
|
||||
var resetting atomic.Bool
|
||||
tracePeak, lastTrace := 0, time.Now()
|
||||
var healthySince time.Time // a mic that stays healthy 30 s earns back its 3 reconnects
|
||||
gaveUp := false
|
||||
lastLevel := time.Time{}
|
||||
for {
|
||||
recMu.Lock()
|
||||
r := cur
|
||||
recMu.Unlock()
|
||||
if _, err := io.ReadFull(r.out, frame); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
recMu.Lock()
|
||||
swapped := cur != r
|
||||
recMu.Unlock()
|
||||
if swapped { // we killed it ourselves during a recovery: read from the new one
|
||||
continue
|
||||
}
|
||||
// A headset reset restarts PipeWire, which kills parec mid-read. That is expected:
|
||||
// wait for the reset to hand over a fresh recorder instead of ending the session.
|
||||
if resetting.Load() {
|
||||
for resetting.Load() && ctx.Err() == nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// The recorder died on its own (bad device, sound server unreachable, device unplugged).
|
||||
// End the session so the UI stops showing "Listening", and say WHY in plain words.
|
||||
_ = r.cmd.Wait()
|
||||
why := strings.TrimSpace(r.errBuf.String())
|
||||
e.logs.add("recorder %s ended unexpectedly: %v | stderr: %s", r.name, err, why)
|
||||
msg := "The microphone stopped delivering audio."
|
||||
switch {
|
||||
case strings.Contains(why, "Connection refused") || strings.Contains(why, "pa_context_connect"):
|
||||
msg = "Can't reach the computer's sound system (PipeWire/PulseAudio). Is it running?"
|
||||
case strings.Contains(why, "No such entity") || strings.Contains(why, "not found"):
|
||||
msg = "That microphone isn't available any more — it may have been unplugged or disconnected. Pick another one."
|
||||
}
|
||||
if why != "" {
|
||||
msg += " (" + r.name + ": " + why + ")"
|
||||
}
|
||||
e.hub.send(event{"type": "error", "message": msg})
|
||||
go e.stop()
|
||||
return
|
||||
}
|
||||
peak := 0
|
||||
for i := 0; i+1 < len(frame); i += 2 {
|
||||
v := int(int16(binary.LittleEndian.Uint16(frame[i:])))
|
||||
if v < 0 {
|
||||
v = -v
|
||||
}
|
||||
if v > peak {
|
||||
peak = v
|
||||
}
|
||||
}
|
||||
// ★ DEAD MIC LINK. Exact-zero samples are not quiet speech (a live mic always has a noise
|
||||
// floor). Measured 2026-09-27 on a Bluetooth headset (HFP, mSBC): four words
|
||||
// transcribed, then literal zeros while parec stayed "healthy" — the old Rudder's BUG 1,
|
||||
// the headset losing the mic side of the link. Re-opening the source restarts the audio
|
||||
// stream; the speech session and the transcript carry on untouched.
|
||||
if peak == 0 {
|
||||
if silentSince.IsZero() {
|
||||
silentSince = time.Now()
|
||||
} else if !gaveUp && !resetting.Load() && time.Since(silentSince) > 1500*time.Millisecond {
|
||||
recoveries++
|
||||
switch {
|
||||
case recoveries == 1:
|
||||
// Step 1: reopen the recorder (cheap; fixes a stalled stream).
|
||||
e.logs.add("mic link dead (exact zeros 1.5s) — step 1: reopening the microphone")
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone went silent — reconnecting it…"})
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
recMu.Lock()
|
||||
old := cur
|
||||
cur = next
|
||||
recMu.Unlock()
|
||||
old.kill()
|
||||
recovering.Store(true)
|
||||
}
|
||||
case recoveries == 2 && btCard(device) != "":
|
||||
// Step 2: force a NEW Bluetooth voice link by switching the headset's voice profile to
|
||||
// CVSD (the old Rudder's proven-stabler choice). Unlike a detect-first reset script this does not
|
||||
// "detect first", so it can't decide a dead link is fine.
|
||||
card := btCard(device)
|
||||
e.logs.add("mic still dead — step 2: forcing a new voice link: %s → headset-head-unit-cvsd", card)
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone still silent — switching your Bluetooth headset to its more stable voice mode…"})
|
||||
resetting.Store(true)
|
||||
go func() {
|
||||
defer resetting.Store(false)
|
||||
if err := switchProfile(card, e.logs); err != nil {
|
||||
e.hub.send(event{"type": "warning", "message": "Couldn't switch the headset's voice mode: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
recMu.Lock()
|
||||
old := cur
|
||||
cur = next
|
||||
recMu.Unlock()
|
||||
old.kill()
|
||||
recovering.Store(true)
|
||||
}
|
||||
}()
|
||||
case recoveries == 3 && btResetPath() != "":
|
||||
// Step 3: full headset reset (disconnect → restart PipeWire → reconnect).
|
||||
e.logs.add("mic still dead — step 3: full headset reset via RUDDER_BT_RESET")
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Still silent — fully resetting your Bluetooth headset (about 15 seconds)…"})
|
||||
resetting.Store(true)
|
||||
go func() {
|
||||
defer resetting.Store(false)
|
||||
ok, ran := runHeadsetReset(e.logs)
|
||||
if !ran {
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
e.hub.send(event{"type": "warning", "message": "The headset reset didn't bring the mic back — is the headset on and in range?"})
|
||||
}
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
recMu.Lock()
|
||||
old := cur
|
||||
cur = next
|
||||
recMu.Unlock()
|
||||
old.kill()
|
||||
recovering.Store(true)
|
||||
}
|
||||
}()
|
||||
default:
|
||||
gaveUp = true
|
||||
e.logs.add("mic still dead after reopen + headset resets — giving up for this session")
|
||||
msg := "Your microphone keeps going silent. This is the Bluetooth headset dropping its mic link (the kernel reports corrupted voice packets), not Rudder. Try a wired or built-in mic, or turn off the headset's noise cancellation / adaptive audio in its app."
|
||||
if btResetPath() == "" {
|
||||
msg += " (No headset-reset command is configured — see RUDDER_BT_RESET.)"
|
||||
}
|
||||
e.hub.send(event{"type": "warning", "message": msg})
|
||||
}
|
||||
silentSince = time.Now()
|
||||
}
|
||||
} else {
|
||||
if recovering.Load() {
|
||||
recovering.Store(false)
|
||||
healthySince = time.Now()
|
||||
e.logs.add("mic recovered after %d reconnect(s)", recoveries)
|
||||
e.hub.send(event{"type": "recovered", "message": "Microphone is back — keep talking."})
|
||||
}
|
||||
if recoveries > 0 && !healthySince.IsZero() && time.Since(healthySince) > 30*time.Second {
|
||||
recoveries, gaveUp, healthySince = 0, false, time.Time{}
|
||||
}
|
||||
silentSince = time.Time{}
|
||||
}
|
||||
// Flight recorder: the mic level twice a second, so a drop can be lined up against kernel and
|
||||
// sound-server events in the same log (/api/rudder/log).
|
||||
tracePeak = max(tracePeak, peak)
|
||||
if time.Since(lastTrace) >= 500*time.Millisecond {
|
||||
e.logs.add("trace level peak=%d", tracePeak)
|
||||
tracePeak, lastTrace = 0, time.Now()
|
||||
}
|
||||
if time.Since(lastLevel) > 150*time.Millisecond {
|
||||
lastLevel = time.Now()
|
||||
e.hub.send(event{"type": "level", "peak": float64(peak) / 32768})
|
||||
}
|
||||
writeMu.Lock()
|
||||
err := ws.WriteMessage(websocket.BinaryMessage, frame)
|
||||
writeMu.Unlock()
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
e.hub.send(event{"type": "error", "message": "speech server connection lost: " + err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Result reader: map the server's events onto the plugin's small vocabulary.
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(msg, &m) != nil {
|
||||
continue
|
||||
}
|
||||
t, _ := m["type"].(string)
|
||||
text := firstString(m, "transcript", "delta", "text")
|
||||
switch {
|
||||
case strings.HasSuffix(t, "input_audio_transcription.delta"):
|
||||
// Deltas APPEND to the utterance in progress; the server sends many empty ones.
|
||||
if text != "" {
|
||||
e.hub.send(event{"type": "partial", "text": text})
|
||||
}
|
||||
case strings.HasSuffix(t, "input_audio_transcription.completed"):
|
||||
e.hub.send(event{"type": "final", "text": text, "at": time.Now().Format(time.RFC3339)})
|
||||
case t == "error":
|
||||
e.logs.add("speech server error: %s", string(msg))
|
||||
e.hub.send(event{"type": "error", "message": string(msg)})
|
||||
case t == "input_audio_buffer.committed":
|
||||
return
|
||||
default:
|
||||
e.logs.add("speech event: %s", t)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// recorderProc is one running recorder process.
|
||||
type recorderProc struct {
|
||||
cmd *exec.Cmd
|
||||
out io.ReadCloser
|
||||
name string
|
||||
errBuf *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *recorderProc) kill() {
|
||||
if r != nil && r.cmd != nil && r.cmd.Process != nil {
|
||||
_ = r.cmd.Process.Kill()
|
||||
go func() { _ = r.cmd.Wait() }()
|
||||
}
|
||||
}
|
||||
|
||||
// spawnRecorder starts the first recorder that works for this device.
|
||||
func spawnRecorder(device string, logs *ring) (*recorderProc, error) {
|
||||
for _, c := range recorderCmds(device) {
|
||||
if _, err := exec.LookPath(c[0]); err != nil {
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
cmd.Env = audioEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
errBuf := &bytes.Buffer{}
|
||||
cmd.Stderr = errBuf
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
logs.add("recorder %s failed to start: %v", c[0], err)
|
||||
continue
|
||||
}
|
||||
return &recorderProc{cmd: cmd, out: out, name: c[0], errBuf: errBuf}, nil
|
||||
}
|
||||
return nil, errors.New("no microphone recorder found (need parec, pw-record or arecord)")
|
||||
}
|
||||
|
||||
func firstString(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s, ok := m[k].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if item, ok := m["item"].(map[string]any); ok {
|
||||
return firstString(item, keys...)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *engine) stop() {
|
||||
e.mu.Lock()
|
||||
stop := e.stopSession
|
||||
e.stopSession = nil
|
||||
was := e.recording
|
||||
e.recording = false
|
||||
e.mu.Unlock()
|
||||
if !was || stop == nil {
|
||||
return
|
||||
}
|
||||
stop()
|
||||
e.logs.add("recording stopped")
|
||||
e.hub.send(event{"type": "recording", "recording": false})
|
||||
}
|
||||
|
||||
func (e *engine) shutdown() {
|
||||
e.stop()
|
||||
e.mu.Lock()
|
||||
n := e.nemo
|
||||
e.mu.Unlock()
|
||||
if n != nil && n.Process != nil {
|
||||
_ = n.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP API ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// guard: two protections for a server that streams what you say.
|
||||
// - Host check (DNS rebinding): a web page on any domain can make the browser resolve its own hostname
|
||||
// to 127.0.0.1 and then read this API as "same-origin". Only requests addressed to 127.0.0.1 or
|
||||
// localhost are served.
|
||||
// - CORS: only the desktop webview's origin (tauri://localhost, or http://tauri.localhost on Windows)
|
||||
// may read responses cross-origin. The Plugin Studio needs none: it reaches /api/rudder through the
|
||||
// dev server's same-origin proxy. RUDDER_ALLOW_ORIGIN adds one more exact origin (tests).
|
||||
func guard(next http.Handler) http.Handler {
|
||||
extra := os.Getenv("RUDDER_ALLOW_ORIGIN")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if i := strings.LastIndex(host, ":"); i >= 0 && !strings.HasSuffix(host, "]") {
|
||||
host = host[:i]
|
||||
}
|
||||
if host != "127.0.0.1" && host != "localhost" && host != "[::1]" {
|
||||
http.Error(w, "forbidden host", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
o := r.Header.Get("Origin")
|
||||
if o == "tauri://localhost" || o == "http://tauri.localhost" || (extra != "" && o == extra) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", o)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
logs := &ring{}
|
||||
h := &hub{clients: map[chan []byte]struct{}{}}
|
||||
key := make([]byte, 24)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
log.Fatal("no randomness for the speech-server key: ", err)
|
||||
}
|
||||
e := &engine{cfg: cfg, logs: logs, hub: h, state: "starting", devs: &deviceWatcher{hub: h, logs: logs}, nemoKey: hex.EncodeToString(key)}
|
||||
e.devs.run()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/rudder/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, e.status())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/start", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, 405, event{"error": "POST only"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Language string `json:"language"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if err := e.start(body.Language, body.Device); err != nil {
|
||||
e.logs.add("start failed: %v", err)
|
||||
writeJSON(w, 409, event{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, e.status())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/stop", func(w http.ResponseWriter, r *http.Request) {
|
||||
e.stop()
|
||||
writeJSON(w, 200, e.status())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
e.devs.refresh("request")
|
||||
writeJSON(w, 200, e.devs.current())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Device string `json:"device"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
writeJSON(w, 200, e.micTest(body.Device))
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/log", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(w, logs.text())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
c := e.hub.subscribe()
|
||||
defer e.hub.unsubscribe(c)
|
||||
first, _ := json.Marshal(event{"type": "status", "status": e.status()})
|
||||
fmt.Fprintf(w, "data: %s\n\n", first)
|
||||
devs, _ := json.Marshal(event{"type": "devices", "devices": e.devs.current()})
|
||||
fmt.Fprintf(w, "data: %s\n\n", devs)
|
||||
fl.Flush()
|
||||
ping := time.NewTicker(15 * time.Second)
|
||||
defer ping.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case b := <-c:
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
fl.Flush()
|
||||
case <-ping.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
srv := &http.Server{Addr: "127.0.0.1:" + cfg.Port, Handler: guard(mux)}
|
||||
logs.add("rudder-engine listening on http://127.0.0.1:%s (runtime=%s model=%s)", cfg.Port, cfg.NemoBin, cfg.Model)
|
||||
e.startNemo()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sig
|
||||
e.shutdown()
|
||||
os.Exit(0)
|
||||
}()
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
e.shutdown()
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
59
backend/rudder-engine/setup-runtime.sh
Executable file
59
backend/rudder-engine/setup-runtime.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# setup-runtime.sh — fetch Rudder's speech runtime + model, verified by SHA-256, then build the engine.
|
||||
#
|
||||
# Rudder (plugin-designs/rudder-voice) transcribes on THIS computer. It needs two things that are too big
|
||||
# for git: NVIDIA's NeMo-Speech.cpp runtime (~4 MB, CPU build) and the Nemotron 3.5 ASR Streaming GGUF
|
||||
# (~742 MB). Both are pinned by version and checksum; a mismatch aborts instead of running an unverified
|
||||
# file. After this, `bun run dev` starts the engine and Rudder appears in the Plugin Studio.
|
||||
#
|
||||
# bash backend/rudder-engine/setup-runtime.sh # installs into ~/.local/share/rudder
|
||||
# RUDDER_HOME=/some/dir bash backend/rudder-engine/setup-runtime.sh
|
||||
#
|
||||
# Linux x86_64 only for now (the runtime ships other platforms; add them when a desktop build needs them).
|
||||
set -euo pipefail
|
||||
|
||||
RUDDER_HOME="${RUDDER_HOME:-$HOME/.local/share/rudder}"
|
||||
RT_VER="0.1.0"
|
||||
RT_NAME="nemo-speech-${RT_VER}-linux-x86_64-cpu"
|
||||
RT_URL="https://github.com/NVIDIA/NeMo-Speech.cpp/releases/download/v${RT_VER}/${RT_NAME}.tar.gz"
|
||||
RT_SHA="0f74131d631ad2c694cf0ec53490866bb6461147959589a69fb6fc231944065b"
|
||||
# NVIDIA re-converted the GGUF on 2026-09-10 (embedded tokenizer); this revision is the one Rudder is tested with.
|
||||
MODEL_REV="ea30d66debe3"
|
||||
MODEL_FILE="nemotron-3.5-asr-streaming-0.6b.q8_0.gguf"
|
||||
MODEL_URL="https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b/resolve/${MODEL_REV}/${MODEL_FILE}"
|
||||
MODEL_SHA="3fc991d3badad7277c11030a7519832cddaf2057aafed6d4b25147e953a070b1"
|
||||
|
||||
say() { printf '\033[1;34m→\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || die "This script currently supports Linux x86_64 only."
|
||||
command -v curl >/dev/null || die "curl is required."
|
||||
command -v sha256sum >/dev/null || die "sha256sum is required."
|
||||
|
||||
fetch() { # url dest sha
|
||||
local url="$1" dest="$2" sha="$3"
|
||||
if [ -f "$dest" ] && [ "$(sha256sum "$dest" | cut -d' ' -f1)" = "$sha" ]; then
|
||||
say "already have $(basename "$dest") (checksum OK)"; return
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --retry 3 -C - -o "$dest.part" "$url"
|
||||
[ "$(sha256sum "$dest.part" | cut -d' ' -f1)" = "$sha" ] || { rm -f "$dest.part"; die "checksum mismatch for $(basename "$dest") — refusing to use it"; }
|
||||
mv "$dest.part" "$dest"
|
||||
}
|
||||
|
||||
mkdir -p "$RUDDER_HOME/bin" "$RUDDER_HOME/models"
|
||||
fetch "$RT_URL" "$RUDDER_HOME/bin/$RT_NAME.tar.gz" "$RT_SHA"
|
||||
tar -xzf "$RUDDER_HOME/bin/$RT_NAME.tar.gz" -C "$RUDDER_HOME/bin"
|
||||
fetch "$MODEL_URL" "$RUDDER_HOME/models/$MODEL_FILE" "$MODEL_SHA"
|
||||
|
||||
if command -v go >/dev/null; then
|
||||
say "building backend/bin/rudder-engine"
|
||||
(cd "$(dirname "$0")" && go build -o ../bin/rudder-engine .)
|
||||
else
|
||||
printf '! Go not found — install Go, then: cd backend/rudder-engine && go build -o ../bin/rudder-engine .\n'
|
||||
fi
|
||||
|
||||
for tool in parec pactl; do command -v "$tool" >/dev/null || printf '! %s not found — install pipewire-pulse or pulseaudio-utils for microphone capture.\n' "$tool"; done
|
||||
|
||||
say "done. Runtime: $RUDDER_HOME/bin/$RT_NAME Model: $RUDDER_HOME/models/$MODEL_FILE"
|
||||
say "run \`bun run dev\` and open Rudder (rudder-voice) in the Plugin Studio."
|
||||
61
internal/backend-dev-plugin.mjs
Normal file
61
internal/backend-dev-plugin.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* internal/backend-dev-plugin.mjs — `bun run dev` starts the local plugin backends.
|
||||
*
|
||||
* WHY: some plugins ship to DESKTOP with a local backend (rudder-voice → backend/bin/rudder-engine,
|
||||
* run as a Tauri sidecar). In the Plugin Studio the same backend must be running, or the plugin can't
|
||||
* be tested in the browser. This plugin starts each built backend with the dev server, forwards its
|
||||
* /api/<name>/ routes, and stops it when the dev server stops. A backend that isn't built is skipped
|
||||
* with a hint: plugins without a backend never notice this file.
|
||||
*
|
||||
* DEV ONLY (`apply: 'serve'`). A production build never starts or bundles a backend.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// name → how to run it. `route` is proxied to `port` on 127.0.0.1.
|
||||
export const DEV_BACKENDS = [
|
||||
{
|
||||
name: 'rudder-engine',
|
||||
bin: 'backend/bin/rudder-engine',
|
||||
build: 'cd backend/rudder-engine && go build -o ../bin/rudder-engine .',
|
||||
route: '/api/rudder',
|
||||
port: Number(process.env.RUDDER_PORT || 5193),
|
||||
env: { RUDDER_PORT: String(process.env.RUDDER_PORT || 5193), RUDDER_NEMO_PORT: String(process.env.RUDDER_NEMO_PORT || 5194) },
|
||||
},
|
||||
]
|
||||
|
||||
// The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered.
|
||||
export function devBackendProxy() {
|
||||
return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false }]))
|
||||
}
|
||||
|
||||
export function backendDevPlugin(root) {
|
||||
const children = []
|
||||
return {
|
||||
name: 'dx-backend-dev',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
for (const b of DEV_BACKENDS) {
|
||||
const bin = resolve(root, b.bin)
|
||||
if (!existsSync(bin)) {
|
||||
server.config.logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`)
|
||||
continue
|
||||
}
|
||||
const child = spawn(bin, [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const tag = `[${b.name}]`
|
||||
const out = (d) => String(d).trim().split('\n').forEach((l) => l && server.config.logger.info(`${tag} ${l}`))
|
||||
child.stdout.on('data', out)
|
||||
child.stderr.on('data', out)
|
||||
child.on('exit', (code) => server.config.logger.warn(`${tag} exited (${code})`))
|
||||
children.push(child)
|
||||
server.config.logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`)
|
||||
}
|
||||
const stop = () => children.forEach((c) => { try { c.kill('SIGTERM') } catch {} })
|
||||
server.httpServer?.on('close', stop)
|
||||
process.once('exit', stop)
|
||||
process.once('SIGINT', () => { stop(); process.exit(0) })
|
||||
process.once('SIGTERM', () => { stop(); process.exit(0) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "الوصول بلغتهم، حسب استخدام الإنترنت في البلد",
|
||||
"aria": "رسوم بيانية",
|
||||
"bandCaveat": "استخدام الإنترنت رقم البلد (البنك الدولي)، وليس رقم المجموعة أبدًا.",
|
||||
"gapSub": "أكبر 8 من المعروضة حسب عدد السكان. انقر إحداها لفتحها.",
|
||||
"gapTake": "هذه الـ{k} وحدها تضم {people} شخص.",
|
||||
"gapTitle": "أكبر المجموعات العرقية التي لا شيء لها بلغتها",
|
||||
"groups": "المجموعات",
|
||||
"groupsN": "{n} مجموعة",
|
||||
"measure": "العدّ",
|
||||
"messagingTop": "تطبيق المراسلة الأول، حسب البلدان ضمن النطاق",
|
||||
"notMeasured": "غير مقيس",
|
||||
"ntSub": "لغات المجموعات المعروضة التي لم يُعثر لها على عهد جديد، حسب عدد متحدثيها. الأحمر: لا شيء؛ الأصفر: شيء ما لكن بلا عهد جديد.",
|
||||
"ntTake": "{n} لغة هنا لم يُعثر لها على عهد جديد، يتحدثها {people} شخص.",
|
||||
"ntTitle": "أي اللغات أحوج إلى العهد الجديد؟",
|
||||
"people": "الأشخاص",
|
||||
"peopleN": "{n} شخص",
|
||||
"q1sub": "عمود لكل مستوى وصول، للمجموعات العرقية المعروضة.",
|
||||
"q1take": "{n} من {total} مجموعة عرقية معروضة لا شيء لها بلغتها.",
|
||||
"q1title": "ماذا لديهم بلغتهم؟",
|
||||
"q2sub": "المجموعات العرقية التي لا شيء لها بلغتها، حسب نسبة الاتصال بالإنترنت في بلدها.",
|
||||
"q2take": "{online} من الـ{n} التي لا شيء لها تعيش في بلدان يستخدم فيها 60% أو أكثر الإنترنت.",
|
||||
"q2title": "هل يمكن للإنترنت إيصاله إلى من لا شيء لهم؟",
|
||||
"socialTop": "منصة التواصل الأولى، حسب البلدان ضمن النطاق",
|
||||
"unitCountries": "بلدان",
|
||||
"unitGroups": "مجموعة عرقية",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} من {total} مجموعة عرقية",
|
||||
"empty": "لا توجد مجموعة عرقية تطابق عوامل التصفية هذه.",
|
||||
"exported": "تم تصدير {n} صف (نُسخت ونُزّلت).",
|
||||
"openCapped": "يفتح أول {n} من المحدد.",
|
||||
"openInGospel": "فتح في موارد الإنجيل",
|
||||
"selectRow": "اختيار {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Zugang in eigener Sprache, nach Internetnutzung im Land",
|
||||
"aria": "Diagramme",
|
||||
"bandCaveat": "Die Internetnutzung ist die Zahl des Landes (Weltbank), nie die der Gruppe.",
|
||||
"gapSub": "Die 8 größten der angezeigten, nach Bevölkerung. Klicken Sie eine an, um sie zu öffnen.",
|
||||
"gapTake": "Allein diese {k} sind {people} Menschen.",
|
||||
"gapTitle": "Die größten Volksgruppen ohne etwas in ihrer eigenen Sprache",
|
||||
"groups": "Gruppen",
|
||||
"groupsN": "{n} Gruppen",
|
||||
"measure": "Zählen",
|
||||
"messagingTop": "Top-Messenger nach Land im Auswahlbereich",
|
||||
"notMeasured": "Nicht gemessen",
|
||||
"ntSub": "Sprachen der angezeigten Gruppen ohne gefundenes Neues Testament, nach Zahl der Sprecher. Rot: gar nichts; Gelb: etwas, aber kein Neues Testament.",
|
||||
"ntTake": "{n} Sprachen hier haben kein gefundenes Neues Testament, gesprochen von {people} Menschen.",
|
||||
"ntTitle": "Welche Sprachen brauchen das Neue Testament am meisten?",
|
||||
"people": "Menschen",
|
||||
"peopleN": "{n} Menschen",
|
||||
"q1sub": "Ein Balken je Zugangsstufe, für die angezeigten Volksgruppen.",
|
||||
"q1take": "{n} von {total} angezeigten Volksgruppen haben nichts in ihrer eigenen Sprache.",
|
||||
"q1title": "Was haben sie in ihrer eigenen Sprache?",
|
||||
"q2sub": "Volksgruppen ohne etwas in ihrer Sprache, danach, wie viel ihres Landes online ist.",
|
||||
"q2take": "{online} der {n} ohne etwas leben in Ländern, in denen 60 % oder mehr das Internet nutzen.",
|
||||
"q2title": "Könnte das Internet es zu denen ohne etwas bringen?",
|
||||
"socialTop": "Top-Social-Plattform nach Land im Auswahlbereich",
|
||||
"unitCountries": "Länder",
|
||||
"unitGroups": "Volksgruppen",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} von {total} Volksgruppen",
|
||||
"empty": "Keine Volksgruppe entspricht diesen Filtern.",
|
||||
"exported": "{n} Zeilen exportiert (kopiert und heruntergeladen).",
|
||||
"openCapped": "Öffnet die ersten {n} ausgewählten.",
|
||||
"openInGospel": "In Evangeliumsressourcen öffnen",
|
||||
"selectRow": "{name} auswählen"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Own-language access, by country internet use",
|
||||
"aria": "Charts",
|
||||
"bandCaveat": "Internet use is the country's figure (World Bank), never the group's.",
|
||||
"gapSub": "The 8 biggest of those shown, by population. Click one to open it.",
|
||||
"gapTake": "These {k} alone are {people} people.",
|
||||
"gapTitle": "The largest people groups with nothing in their own language",
|
||||
"groups": "Groups",
|
||||
"groupsN": "{n} groups",
|
||||
"measure": "Count",
|
||||
"messagingTop": "Top messaging app, by country in scope",
|
||||
"notMeasured": "Not measured",
|
||||
"ntSub": "Languages of the groups shown with no New Testament found, by how many people speak them. Red: nothing at all; yellow: something, but no New Testament.",
|
||||
"ntTake": "{n} languages here have no New Testament found, spoken by {people} people.",
|
||||
"ntTitle": "Which languages most need the New Testament?",
|
||||
"people": "People",
|
||||
"peopleN": "{n} people",
|
||||
"q1sub": "One bar per level of access, for the people groups shown.",
|
||||
"q1take": "{n} of {total} people groups shown have nothing found in their own language.",
|
||||
"q1title": "What do they have in their own language?",
|
||||
"q2sub": "People groups with nothing in their own language, by how much of their country is online.",
|
||||
"q2take": "{online} of the {n} with nothing live in countries where 60% or more use the internet.",
|
||||
"q2title": "Could the internet carry it to those with nothing?",
|
||||
"socialTop": "Top social platform, by country in scope",
|
||||
"unitCountries": "countries",
|
||||
"unitGroups": "people groups",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} of {total} people groups",
|
||||
"empty": "No people groups match these filters.",
|
||||
"exported": "{n} rows exported (copied and downloaded).",
|
||||
"openCapped": "Opens the first {n} selected.",
|
||||
"openInGospel": "Open in Gospel Resources",
|
||||
"selectRow": "Select {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Acceso en su idioma, según el uso de internet del país",
|
||||
"aria": "Gráficos",
|
||||
"bandCaveat": "El uso de internet es la cifra del país (Banco Mundial), nunca la del grupo.",
|
||||
"gapSub": "Los 8 más grandes de los mostrados, por población. Haga clic en uno para abrirlo.",
|
||||
"gapTake": "Solo estos {k} son {people} personas.",
|
||||
"gapTitle": "Los grupos étnicos más grandes sin nada en su propio idioma",
|
||||
"groups": "Grupos",
|
||||
"groupsN": "{n} grupos",
|
||||
"measure": "Contar",
|
||||
"messagingTop": "App de mensajería principal, por país en la selección",
|
||||
"notMeasured": "No medido",
|
||||
"ntSub": "Idiomas de los grupos mostrados sin Nuevo Testamento encontrado, según cuántas personas los hablan. Rojo: nada; amarillo: algo, pero sin Nuevo Testamento.",
|
||||
"ntTake": "{n} idiomas aquí no tienen Nuevo Testamento encontrado, hablados por {people} personas.",
|
||||
"ntTitle": "¿Qué idiomas necesitan más el Nuevo Testamento?",
|
||||
"people": "Personas",
|
||||
"peopleN": "{n} personas",
|
||||
"q1sub": "Una barra por nivel de acceso, para los grupos étnicos mostrados.",
|
||||
"q1take": "{n} de {total} grupos étnicos mostrados no tienen nada en su propio idioma.",
|
||||
"q1title": "¿Qué tienen en su propio idioma?",
|
||||
"q2sub": "Grupos étnicos sin nada en su idioma, según cuánto de su país está en línea.",
|
||||
"q2take": "{online} de los {n} sin nada viven en países donde el 60 % o más usa internet.",
|
||||
"q2title": "¿Podría internet llevarlo a quienes no tienen nada?",
|
||||
"socialTop": "Red social principal, por país en la selección",
|
||||
"unitCountries": "países",
|
||||
"unitGroups": "grupos étnicos",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} de {total} grupos étnicos",
|
||||
"empty": "Ningún grupo étnico coincide con estos filtros.",
|
||||
"exported": "{n} filas exportadas (copiadas y descargadas).",
|
||||
"openCapped": "Abre los primeros {n} seleccionados.",
|
||||
"openInGospel": "Abrir en Recursos del Evangelio",
|
||||
"selectRow": "Seleccionar {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Accès dans leur langue, selon l’usage d’internet du pays",
|
||||
"aria": "Graphiques",
|
||||
"bandCaveat": "L’usage d’internet est le chiffre du pays (Banque mondiale), jamais celui du groupe.",
|
||||
"gapSub": "Les 8 plus grands de ceux affichés, par population. Cliquez pour en ouvrir un.",
|
||||
"gapTake": "Ces {k} seuls représentent {people} personnes.",
|
||||
"gapTitle": "Les plus grands groupes ethniques sans rien dans leur propre langue",
|
||||
"groups": "Groupes",
|
||||
"groupsN": "{n} groupes",
|
||||
"measure": "Compter",
|
||||
"messagingTop": "Messagerie principale, par pays sélectionné",
|
||||
"notMeasured": "Non mesuré",
|
||||
"ntSub": "Langues des groupes affichés sans Nouveau Testament trouvé, selon le nombre de locuteurs. Rouge : rien du tout ; jaune : quelque chose, mais pas de Nouveau Testament.",
|
||||
"ntTake": "{n} langues ici n’ont pas de Nouveau Testament trouvé, parlées par {people} personnes.",
|
||||
"ntTitle": "Quelles langues ont le plus besoin du Nouveau Testament ?",
|
||||
"people": "Personnes",
|
||||
"peopleN": "{n} personnes",
|
||||
"q1sub": "Une barre par niveau d’accès, pour les groupes ethniques affichés.",
|
||||
"q1take": "{n} des {total} groupes ethniques affichés n’ont rien dans leur propre langue.",
|
||||
"q1title": "Qu’ont-ils dans leur propre langue ?",
|
||||
"q2sub": "Groupes ethniques sans rien dans leur langue, selon la part de leur pays en ligne.",
|
||||
"q2take": "{online} des {n} sans rien vivent dans des pays où 60 % ou plus utilisent internet.",
|
||||
"q2title": "Internet pourrait-il le porter à ceux qui n’ont rien ?",
|
||||
"socialTop": "Réseau social principal, par pays sélectionné",
|
||||
"unitCountries": "pays",
|
||||
"unitGroups": "groupes ethniques",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} sur {total} groupes ethniques",
|
||||
"empty": "Aucun groupe ethnique ne correspond à ces filtres.",
|
||||
"exported": "{n} lignes exportées (copiées et téléchargées).",
|
||||
"openCapped": "Ouvre les {n} premiers sélectionnés.",
|
||||
"openInGospel": "Ouvrir dans Ressources de l’Évangile",
|
||||
"selectRow": "Sélectionner {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "अपनी भाषा में पहुँच, देश के इंटरनेट उपयोग के अनुसार",
|
||||
"aria": "चार्ट",
|
||||
"bandCaveat": "इंटरनेट उपयोग देश का आंकड़ा है (विश्व बैंक), समूह का कभी नहीं।",
|
||||
"gapSub": "दिखाए गए में से जनसंख्या के अनुसार 8 सबसे बड़े। खोलने के लिए किसी पर क्लिक करें।",
|
||||
"gapTake": "केवल ये {k} ही {people} लोग हैं।",
|
||||
"gapTitle": "अपनी भाषा में कुछ भी न रखने वाले सबसे बड़े जन-समूह",
|
||||
"groups": "समूह",
|
||||
"groupsN": "{n} समूह",
|
||||
"measure": "गिनती",
|
||||
"messagingTop": "प्रमुख मैसेजिंग ऐप, दायरे के देशों के अनुसार",
|
||||
"notMeasured": "मापा नहीं गया",
|
||||
"ntSub": "दिखाए गए समूहों की वे भाषाएँ जिनमें नया नियम नहीं मिला, बोलने वालों की संख्या के अनुसार। लाल: कुछ नहीं; पीला: कुछ है, पर नया नियम नहीं।",
|
||||
"ntTake": "यहाँ {n} भाषाओं में नया नियम नहीं मिला, जिन्हें {people} लोग बोलते हैं।",
|
||||
"ntTitle": "किन भाषाओं को नए नियम की सबसे अधिक ज़रूरत है?",
|
||||
"people": "लोग",
|
||||
"peopleN": "{n} लोग",
|
||||
"q1sub": "दिखाए गए जन-समूहों के लिए, पहुँच के हर स्तर पर एक पट्टी।",
|
||||
"q1take": "दिखाए गए {total} जन-समूहों में से {n} की अपनी भाषा में कुछ नहीं मिला।",
|
||||
"q1title": "उनकी अपनी भाषा में क्या है?",
|
||||
"q2sub": "जिन जन-समूहों की भाषा में कुछ नहीं, उनके देश का कितना हिस्सा ऑनलाइन है उसके अनुसार।",
|
||||
"q2take": "जिन {n} के पास कुछ नहीं, उनमें से {online} ऐसे देशों में रहते हैं जहाँ 60% या अधिक लोग इंटरनेट का उपयोग करते हैं।",
|
||||
"q2title": "क्या इंटरनेट इसे उन तक पहुँचा सकता है जिनके पास कुछ नहीं है?",
|
||||
"socialTop": "प्रमुख सोशल प्लेटफ़ॉर्म, दायरे के देशों के अनुसार",
|
||||
"unitCountries": "देश",
|
||||
"unitGroups": "जन-समूह",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{total} में से {n} जन-समूह",
|
||||
"empty": "इन फ़िल्टरों से कोई जन-समूह मेल नहीं खाता।",
|
||||
"exported": "{n} पंक्तियाँ निर्यात हुईं (कॉपी और डाउनलोड)।",
|
||||
"openCapped": "पहले {n} चयनित खोलता है।",
|
||||
"openInGospel": "सुसमाचार संसाधनों में खोलें",
|
||||
"selectRow": "{name} चुनें"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Accesso nella propria lingua, per uso di internet del paese",
|
||||
"aria": "Grafici",
|
||||
"bandCaveat": "L’uso di internet è il dato del paese (Banca Mondiale), mai quello del gruppo.",
|
||||
"gapSub": "Gli 8 più grandi tra quelli mostrati, per popolazione. Clicca per aprirne uno.",
|
||||
"gapTake": "Solo questi {k} sono {people} persone.",
|
||||
"gapTitle": "I gruppi etnici più grandi senza niente nella propria lingua",
|
||||
"groups": "Gruppi",
|
||||
"groupsN": "{n} gruppi",
|
||||
"measure": "Conta",
|
||||
"messagingTop": "App di messaggistica principale, per paese selezionato",
|
||||
"notMeasured": "Non misurato",
|
||||
"ntSub": "Lingue dei gruppi mostrati senza Nuovo Testamento trovato, per numero di parlanti. Rosso: niente; giallo: qualcosa, ma nessun Nuovo Testamento.",
|
||||
"ntTake": "{n} lingue qui non hanno un Nuovo Testamento trovato, parlate da {people} persone.",
|
||||
"ntTitle": "Quali lingue hanno più bisogno del Nuovo Testamento?",
|
||||
"people": "Persone",
|
||||
"peopleN": "{n} persone",
|
||||
"q1sub": "Una barra per livello di accesso, per i gruppi etnici mostrati.",
|
||||
"q1take": "{n} dei {total} gruppi etnici mostrati non hanno niente nella propria lingua.",
|
||||
"q1title": "Che cosa hanno nella propria lingua?",
|
||||
"q2sub": "Gruppi etnici senza niente nella loro lingua, secondo quanto del loro paese è online.",
|
||||
"q2take": "{online} dei {n} senza niente vivono in paesi dove il 60 % o più usa internet.",
|
||||
"q2title": "Internet potrebbe portarlo a chi non ha niente?",
|
||||
"socialTop": "Piattaforma social principale, per paese selezionato",
|
||||
"unitCountries": "paesi",
|
||||
"unitGroups": "gruppi etnici",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} di {total} gruppi etnici",
|
||||
"empty": "Nessun gruppo etnico corrisponde a questi filtri.",
|
||||
"exported": "{n} righe esportate (copiate e scaricate).",
|
||||
"openCapped": "Apre i primi {n} selezionati.",
|
||||
"openInGospel": "Apri in Risorse del Vangelo",
|
||||
"selectRow": "Seleziona {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Acesso na própria língua, por uso de internet do país",
|
||||
"aria": "Gráficos",
|
||||
"bandCaveat": "O uso de internet é o número do país (Banco Mundial), nunca o do grupo.",
|
||||
"gapSub": "Os 8 maiores dos exibidos, por população. Clique num para abri-lo.",
|
||||
"gapTake": "Só estes {k} somam {people} pessoas.",
|
||||
"gapTitle": "Os maiores povos sem nada na própria língua",
|
||||
"groups": "Grupos",
|
||||
"groupsN": "{n} grupos",
|
||||
"measure": "Contar",
|
||||
"messagingTop": "App de mensagens principal, por país no recorte",
|
||||
"notMeasured": "Não medido",
|
||||
"ntSub": "Línguas dos grupos exibidos sem Novo Testamento encontrado, por quantas pessoas as falam. Vermelho: nada; amarelo: algo, mas sem Novo Testamento.",
|
||||
"ntTake": "{n} línguas aqui não têm Novo Testamento encontrado, faladas por {people} pessoas.",
|
||||
"ntTitle": "Quais línguas mais precisam do Novo Testamento?",
|
||||
"people": "Pessoas",
|
||||
"peopleN": "{n} pessoas",
|
||||
"q1sub": "Uma barra por nível de acesso, para os povos exibidos.",
|
||||
"q1take": "{n} de {total} povos exibidos não têm nada na própria língua.",
|
||||
"q1title": "O que eles têm na própria língua?",
|
||||
"q2sub": "Povos sem nada na própria língua, pelo quanto do país está online.",
|
||||
"q2take": "{online} dos {n} sem nada vivem em países onde 60 % ou mais usam a internet.",
|
||||
"q2title": "A internet poderia levar isso a quem não tem nada?",
|
||||
"socialTop": "Rede social principal, por país no recorte",
|
||||
"unitCountries": "países",
|
||||
"unitGroups": "povos",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} de {total} povos",
|
||||
"empty": "Nenhum povo corresponde a estes filtros.",
|
||||
"exported": "{n} linhas exportadas (copiadas e baixadas).",
|
||||
"openCapped": "Abre os primeiros {n} selecionados.",
|
||||
"openInGospel": "Abrir em Recursos do Evangelho",
|
||||
"selectRow": "Selecionar {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Acces în propria limbă, după folosirea internetului în țară",
|
||||
"aria": "Grafice",
|
||||
"bandCaveat": "Folosirea internetului este cifra țării (Banca Mondială), niciodată a grupului.",
|
||||
"gapSub": "Cele mai mari 8 dintre cele afișate, după populație. Faceți clic pentru a deschide unul.",
|
||||
"gapTake": "Doar aceste {k} înseamnă {people} oameni.",
|
||||
"gapTitle": "Cele mai mari grupuri etnice fără nimic în propria limbă",
|
||||
"groups": "Grupuri",
|
||||
"groupsN": "{n} grupuri",
|
||||
"measure": "Numără",
|
||||
"messagingTop": "Aplicația de mesagerie principală, pe țări selectate",
|
||||
"notMeasured": "Nemăsurat",
|
||||
"ntSub": "Limbile grupurilor afișate fără Noul Testament găsit, după numărul vorbitorilor. Roșu: nimic; galben: ceva, dar fără Noul Testament.",
|
||||
"ntTake": "{n} limbi de aici nu au Noul Testament găsit, vorbite de {people} oameni.",
|
||||
"ntTitle": "Ce limbi au cea mai mare nevoie de Noul Testament?",
|
||||
"people": "Oameni",
|
||||
"peopleN": "{n} oameni",
|
||||
"q1sub": "O bară pentru fiecare nivel de acces, pentru grupurile etnice afișate.",
|
||||
"q1take": "{n} din {total} grupuri etnice afișate nu au nimic în propria limbă.",
|
||||
"q1title": "Ce au în propria lor limbă?",
|
||||
"q2sub": "Grupuri etnice fără nimic în limba lor, după cât din țara lor este online.",
|
||||
"q2take": "{online} din cele {n} fără nimic trăiesc în țări unde 60 % sau mai mult folosesc internetul.",
|
||||
"q2title": "Ar putea internetul să-l ducă celor care nu au nimic?",
|
||||
"socialTop": "Platforma socială principală, pe țări selectate",
|
||||
"unitCountries": "țări",
|
||||
"unitGroups": "grupuri etnice",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} din {total} grupuri etnice",
|
||||
"empty": "Niciun grup etnic nu corespunde acestor filtre.",
|
||||
"exported": "{n} rânduri exportate (copiate și descărcate).",
|
||||
"openCapped": "Deschide primele {n} selectate.",
|
||||
"openInGospel": "Deschide în Resurse ale Evangheliei",
|
||||
"selectRow": "Selectează {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Доступ на родном языке по уровню интернета в стране",
|
||||
"aria": "Диаграммы",
|
||||
"bandCaveat": "Использование интернета — показатель страны (Всемирный банк), а не группы.",
|
||||
"gapSub": "8 крупнейших из показанных, по численности. Нажмите, чтобы открыть.",
|
||||
"gapTake": "Только эти {k} — это {people} человек.",
|
||||
"gapTitle": "Крупнейшие народы, у которых нет ничего на родном языке",
|
||||
"groups": "Группы",
|
||||
"groupsN": "{n} групп",
|
||||
"measure": "Считать",
|
||||
"messagingTop": "Главный мессенджер по странам в выборке",
|
||||
"notMeasured": "Не измерено",
|
||||
"ntSub": "Языки показанных групп без найденного Нового Завета, по числу говорящих. Красный: ничего; жёлтый: что-то есть, но нет Нового Завета.",
|
||||
"ntTake": "Языков здесь без найденного Нового Завета: {n}; на них говорят {people} человек.",
|
||||
"ntTitle": "Каким языкам больше всего нужен Новый Завет?",
|
||||
"people": "Люди",
|
||||
"peopleN": "{n} чел.",
|
||||
"q1sub": "Одна полоса на уровень доступа, для показанных народов.",
|
||||
"q1take": "У {n} из {total} показанных народов нет ничего на родном языке.",
|
||||
"q1title": "Что у них есть на родном языке?",
|
||||
"q2sub": "Народы, у которых нет ничего на их языке, по доле их страны в сети.",
|
||||
"q2take": "{online} из {n} без ничего живут в странах, где интернетом пользуются 60 % и более.",
|
||||
"q2title": "Может ли интернет донести это до тех, у кого ничего нет?",
|
||||
"socialTop": "Главная соцсеть по странам в выборке",
|
||||
"unitCountries": "стран",
|
||||
"unitGroups": "народов",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{n} из {total} народов",
|
||||
"empty": "Ни один народ не подходит под эти фильтры.",
|
||||
"exported": "Экспортировано строк: {n} (скопировано и скачано).",
|
||||
"openCapped": "Открывает первые {n} выбранных.",
|
||||
"openInGospel": "Открыть в «Евангельских ресурсах»",
|
||||
"selectRow": "Выбрать: {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "母语资源情况,按国家互联网使用率",
|
||||
"aria": "图表",
|
||||
"bandCaveat": "互联网使用率是国家的数字(世界银行),不是该族群的数字。",
|
||||
"gapSub": "所显示族群中人口最多的 8 个。点击即可打开。",
|
||||
"gapTake": "仅这 {k} 个就有 {people} 人。",
|
||||
"gapTitle": "母语中一无所有的最大族群",
|
||||
"groups": "族群",
|
||||
"groupsN": "{n} 个族群",
|
||||
"measure": "统计",
|
||||
"messagingTop": "各国(当前范围)最主要的通讯应用",
|
||||
"notMeasured": "未统计",
|
||||
"ntSub": "所显示族群中未找到新约的语言,按使用人数排列。红色:一无所有;黄色:有一些,但没有新约。",
|
||||
"ntTake": "这里有 {n} 种语言未找到新约,使用人数为 {people}。",
|
||||
"ntTitle": "哪些语言最需要新约?",
|
||||
"people": "人数",
|
||||
"peopleN": "{n} 人",
|
||||
"q1sub": "每个访问级别一条,针对所显示的族群。",
|
||||
"q1take": "所显示的 {total} 个族群中,有 {n} 个在母语中一无所有。",
|
||||
"q1title": "他们的母语中有什么?",
|
||||
"q2sub": "母语中一无所有的族群,按其国家的上网比例。",
|
||||
"q2take": "在 {n} 个一无所有的族群中,有 {online} 个生活在 60% 或以上人口使用互联网的国家。",
|
||||
"q2title": "互联网能把它带给一无所有的人吗?",
|
||||
"socialTop": "各国(当前范围)最主要的社交平台",
|
||||
"unitCountries": "个国家",
|
||||
"unitGroups": "个族群",
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
"count": "{total} 个族群中的 {n} 个",
|
||||
"empty": "没有符合这些筛选条件的族群。",
|
||||
"exported": "已导出 {n} 行(已复制并下载)。",
|
||||
"openCapped": "打开前 {n} 个所选项。",
|
||||
"openInGospel": "在福音资源中打开",
|
||||
"selectRow": "选择 {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
/**
|
||||
* pplr-coverage.vue — Country choropleth: avg # of locales filled per PG in that country.
|
||||
*
|
||||
|
|
@ -56,7 +57,7 @@ onMounted(async () => {
|
|||
if (!map.value) {
|
||||
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
|
||||
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
|
||||
status.value = t('messages.mapUnavailable')
|
||||
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
|
||||
return
|
||||
}
|
||||
map.value.on('load', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn, setProjectionFor } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
/**
|
||||
* pplr-language-families.vue — PG pins colored by PPLR ethnolinguistic family.
|
||||
*
|
||||
|
|
@ -432,7 +433,7 @@ onMounted(async () => {
|
|||
if (!map.value) {
|
||||
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
|
||||
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
|
||||
status.value = t('messages.mapUnavailable')
|
||||
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
|
||||
return
|
||||
}
|
||||
if (map.value) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { addCountryPolygons, addPGPoints, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
|
||||
import LoadingGlobe from '../components/LoadingGlobe.vue'
|
||||
|
|
@ -40,7 +41,7 @@ onMounted(async () => {
|
|||
if (!map.value) {
|
||||
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
|
||||
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
|
||||
status.value = t('messages.mapUnavailable')
|
||||
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
|
||||
return
|
||||
}
|
||||
map.value.on('load', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup>
|
||||
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn, setProjectionFor } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
import { religionFamilyLabel } from '@map/colors/religion.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
/**
|
||||
* pplr-religion.vue — PG pins colored by primary religion.
|
||||
*
|
||||
|
|
@ -137,6 +139,12 @@ const RELIGION_GROUPS = [
|
|||
{ key: 'other', label: 'Other / Unknown', color: '#4b5563' }, // dark gray
|
||||
]
|
||||
|
||||
// Shown in the reader's language through the shared religion catalogue; the key and the filter stay
|
||||
// English. "Other / Unknown" is one bucket here, so it is named from both catalogue entries.
|
||||
const groupLabel = (g) => (g.key === 'other'
|
||||
? `${religionFamilyLabel('Other')} / ${religionFamilyLabel('Unknown')}`
|
||||
: religionFamilyLabel(g.label))
|
||||
|
||||
const COLOR_EXPR = [
|
||||
'match', ['get', 'rg'],
|
||||
...RELIGION_GROUPS.flatMap(g => [g.key, g.color]),
|
||||
|
|
@ -147,7 +155,7 @@ const COLOR_EXPR = [
|
|||
const religionCounts = ref({})
|
||||
const religionLegendItems = computed(() => RELIGION_GROUPS.map(g => ({
|
||||
key: g.key,
|
||||
label: g.label,
|
||||
label: groupLabel(g),
|
||||
color: g.color,
|
||||
count: religionCounts.value[g.key] || 0,
|
||||
filter: ['==', ['get', 'rg'], g.key],
|
||||
|
|
@ -166,7 +174,7 @@ function onLegendSelect(node) {
|
|||
const religionLocalIndex = computed(() => {
|
||||
const out = RELIGION_GROUPS.map((g, i) => ({
|
||||
id: `rel:${g.key}`,
|
||||
label: g.label,
|
||||
label: groupLabel(g),
|
||||
color: g.color,
|
||||
kind: 'religion',
|
||||
depth: 0,
|
||||
|
|
@ -250,7 +258,7 @@ onMounted(async () => {
|
|||
if (!map.value) {
|
||||
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
|
||||
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
|
||||
status.value = t('messages.mapUnavailable')
|
||||
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
|
||||
return
|
||||
}
|
||||
if (map.value) {
|
||||
|
|
@ -279,7 +287,7 @@ onMounted(async () => {
|
|||
religionCounts.value = counts
|
||||
const dominant = RELIGION_GROUPS.reduce((a, b) => (counts[a.key] || 0) > (counts[b.key] || 0) ? a : b)
|
||||
// Per user 2026-05-02: drop the "Ready" prefix — it was unclear and chewed up width.
|
||||
status.value = t('pplr.status.readyReligion', { pins: pgData.value.features.length.toLocaleString(), religion: dominant.label, count: (counts[dominant.key]||0).toLocaleString() })
|
||||
status.value = t('pplr.status.readyReligion', { pins: pgData.value.features.length.toLocaleString(), religion: groupLabel(dominant), count: (counts[dominant.key]||0).toLocaleString() })
|
||||
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
|
||||
await whenGeojsonDrawn(map.value)
|
||||
dataReady.value = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import { dataUrl, glFor, exposeMap, setProjectionFor } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
/**
|
||||
* pplr-rop-tree.vue — ROP hierarchy: Affinity Bloc → Cluster → People Group → PGIC.
|
||||
*
|
||||
|
|
@ -399,7 +400,7 @@ onMounted(async () => {
|
|||
if (!map.value) {
|
||||
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
|
||||
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
|
||||
status.value = t('messages.mapUnavailable')
|
||||
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS, escHtml } from '../utils/pplrMap.js'
|
||||
import LoadingGlobe from '../components/LoadingGlobe.vue'
|
||||
|
|
@ -48,7 +49,7 @@ onMounted(async () => {
|
|||
if (!map.value) {
|
||||
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
|
||||
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
|
||||
status.value = t('messages.mapUnavailable')
|
||||
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
|
||||
return
|
||||
}
|
||||
map.value.on('load', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
/**
|
||||
* pplr-translations.vue — PG name in all 17 locales.
|
||||
*
|
||||
|
|
@ -62,7 +63,7 @@ onMounted(async () => {
|
|||
if (!map.value) {
|
||||
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
|
||||
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
|
||||
status.value = t('messages.mapUnavailable')
|
||||
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
|
||||
return
|
||||
}
|
||||
if (map.value) {
|
||||
|
|
|
|||
55
plugin-designs/rudder-voice/README.md
Normal file
55
plugin-designs/rudder-voice/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# rudder-voice — Rudder, private voice-to-text (desktop target)
|
||||
|
||||
**Rudder is a plugin that isn't a map.** It shows that the same dx-maps pattern — one folder in
|
||||
`plugin-designs/`, developed live in the Plugin Studio — can ship a **desktop app with its own local
|
||||
backend**, not only embeddable maps. It is also a genuinely useful tool for the people who build here:
|
||||
talk to your agents instead of typing, with nothing leaving your computer.
|
||||
|
||||
- **Streaming.** Words appear in the text box while you speak; talk for as long as you like.
|
||||
- **Type and talk at once.** Type, paste links or code anywhere in the box; everything you say is
|
||||
added to the end. While you're typing, Rudder holds its words and adds them when you pause.
|
||||
- **CPU only.** No graphics card. About 1 GB of memory; keeps up with normal speech on a laptop CPU.
|
||||
- **Offline, no telemetry.** NVIDIA Nemotron 3.5 ASR Streaming (0.6B) run by NVIDIA's open-source
|
||||
NeMo-Speech.cpp (ggml). Traced with `strace` while transcribing: zero sockets, zero DNS lookups.
|
||||
- **Microphones like a video-call app.** Pick the input, hot-plug updates, a 3-second mic test, and
|
||||
plain-language errors (muted, disconnected, dead Bluetooth mic link — which it tries to recover).
|
||||
|
||||
## Run it in the Plugin Studio
|
||||
|
||||
```bash
|
||||
bash backend/rudder-engine/setup-runtime.sh # once: runtime + model (verified by SHA-256) + engine build
|
||||
bun run dev # starts the engine too; open "rudder-voice" in the studio
|
||||
```
|
||||
|
||||
Linux with PipeWire or PulseAudio (`parec`, `pactl`). The engine listens on `127.0.0.1` only.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
<rudder-voice> (this folder) ──HTTP + SSE /api/rudder──► backend/rudder-engine (Go)
|
||||
├─ parec / pw-record (mic, open only while recording)
|
||||
└─ WebSocket ► nemo-speech serve (127.0.0.1, model stays loaded)
|
||||
```
|
||||
|
||||
`bun run dev` starts `backend/bin/rudder-engine` through `internal/backend-dev-plugin.mjs` and forwards
|
||||
`/api/rudder` to it, so the plugin calls the same API in the browser as it will inside the desktop app,
|
||||
where Tauri runs the engine as a sidecar and passes its address in `profile-config` (`apiBase`).
|
||||
|
||||
`rudder-voice.meta.json` declares `"targets": ["desktop"]`: on a plain website there is no local engine,
|
||||
so this plugin is not meant for web embeds.
|
||||
|
||||
## Settings (environment variables)
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `RUDDER_PORT` | `5193` | engine HTTP port (loopback) |
|
||||
| `RUDDER_NEMO_PORT` | `5194` | speech server port (loopback) |
|
||||
| `RUDDER_MODEL` / `RUDDER_NEMO_BIN` | `~/.local/share/rudder/...` | model and runtime paths; the engine refuses a model that isn't a local file |
|
||||
| `RUDDER_LANG` | `en-US` | default language (the model supports ~30) |
|
||||
| `RUDDER_KEEP_MSBC` | unset | keep Bluetooth headsets on mSBC instead of switching them to the more stable CVSD |
|
||||
| `RUDDER_BT_RESET` | unset | optional command that fully resets a Bluetooth headset, used when its mic link dies |
|
||||
|
||||
## Not yet
|
||||
|
||||
- Desktop build (`desktop:build`) with the engine as a Tauri sidecar.
|
||||
- A saved word-correction table (PocketBase) for technical terms the model mishears.
|
||||
32
plugin-designs/rudder-voice/index.html
Normal file
32
plugin-designs/rudder-voice/index.html
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · rudder-voice staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { background: #0b0f14; color: #e5e7eb; min-height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
|
||||
.wrap { max-width: 960px; margin: 0 auto; padding: 28px 20px 48px; }
|
||||
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.sub { color: #9ca3af; font-size: 13px; margin: 6px 0 24px; max-width: 820px; }
|
||||
.sub code { color: #c4b5fd; }
|
||||
.host { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
|
||||
rudder-voice { display: block; width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>rudder-voice · staging</h1>
|
||||
<p class="sub">Desktop-target plugin: private, offline, streaming voice-to-text. The speech model runs in the
|
||||
local backend <code>backend/rudder-engine</code> (NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR), started by
|
||||
<code>bun run dev</code> and reached at <code>/api/rudder</code>.</p>
|
||||
<div class="host">
|
||||
<rudder-voice></rudder-voice>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
plugin-designs/rudder-voice/index.js
Normal file
23
plugin-designs/rudder-voice/index.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* rudder-voice / index.js — Bundle entry (Capability-1 single element, DESKTOP target).
|
||||
*
|
||||
* <rudder-voice> — private, offline, streaming voice-to-text. The UI is this bundle; the speech model
|
||||
* runs in the local backend (backend/rudder-engine), started by `bun run dev` in the Plugin Studio and
|
||||
* by Tauri as a sidecar in the desktop app. See rudder-voice.vue for the contract.
|
||||
*
|
||||
* Desktop-only: rudder-voice.meta.json declares "targets": ["desktop"]. On a plain website there is no
|
||||
* local engine, so the element shows "Engine offline" instead of pretending to work.
|
||||
*
|
||||
* Usage (desktop shell / Plugin Studio):
|
||||
* <rudder-voice profile-config='{"apiBase":"http://127.0.0.1:5193/api/rudder"}'></rudder-voice>
|
||||
*/
|
||||
import { defineCustomElement } from 'vue'
|
||||
import RudderVoice from './rudder-voice.vue'
|
||||
|
||||
const RudderVoiceElement = defineCustomElement(RudderVoice)
|
||||
|
||||
if (typeof customElements !== 'undefined') {
|
||||
if (!customElements.get('rudder-voice')) customElements.define('rudder-voice', RudderVoiceElement)
|
||||
}
|
||||
|
||||
export default RudderVoiceElement
|
||||
7
plugin-designs/rudder-voice/rudder-voice.meta.json
Normal file
7
plugin-designs/rudder-voice/rudder-voice.meta.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"_comment": "Rudder — private, offline, streaming voice-to-text. DESKTOP target: the speech model runs in the local backend (backend/rudder-engine: NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR GGUF), started by `bun run dev` and run by Tauri as a sidecar. Not for public web embeds: a website has no local engine.",
|
||||
"type": "single",
|
||||
"label": "Rudder — voice to text (desktop)",
|
||||
"targets": ["desktop"],
|
||||
"backend": "backend/rudder-engine"
|
||||
}
|
||||
650
plugin-designs/rudder-voice/rudder-voice.vue
Normal file
650
plugin-designs/rudder-voice/rudder-voice.vue
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
<script setup>
|
||||
/**
|
||||
* rudder-voice.vue — Rudder: private, offline, streaming voice-to-text.
|
||||
*
|
||||
* @element rudder-voice
|
||||
* @description A DESKTOP-target plugin (rudder-voice.meta.json "targets": ["desktop"]). The speech
|
||||
* model runs in the local backend (backend/rudder-engine → NVIDIA NeMo-Speech.cpp + Nemotron 3.5
|
||||
* ASR), never in the browser and never on a server. In the Plugin Studio, `bun run dev` starts that
|
||||
* backend and forwards /api/rudder; in the desktop app, Tauri runs it as a sidecar and passes its
|
||||
* address in `profile-config` {"apiBase": "http://127.0.0.1:<port>/api/rudder"}.
|
||||
*
|
||||
* Everything is agent-interfaceable (the old Rudder's prime directive): the 🐞 panel copies the
|
||||
* engine log + status with a header telling an agent what to do with it.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref, nextTick } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({ profileConfig: { type: String, default: '' } })
|
||||
|
||||
const cfg = computed(() => {
|
||||
try { return props.profileConfig ? JSON.parse(props.profileConfig) : {} } catch { return {} }
|
||||
})
|
||||
const apiBase = computed(() => (cfg.value.apiBase || '/api/rudder').replace(/\/$/, ''))
|
||||
|
||||
const LANGUAGES = [
|
||||
['en-US', 'English (US)'], ['en-GB', 'English (UK)'], ['es-US', 'Spanish (US)'], ['es-ES', 'Spanish (Spain)'],
|
||||
['fr-FR', 'French'], ['de-DE', 'German'], ['it-IT', 'Italian'], ['pt-BR', 'Portuguese (Brazil)'],
|
||||
['nl-NL', 'Dutch'], ['ru-RU', 'Russian'], ['uk-UA', 'Ukrainian'], ['tr-TR', 'Turkish'], ['ar-AR', 'Arabic'],
|
||||
['hi-IN', 'Hindi'], ['ja-JP', 'Japanese'], ['ko-KR', 'Korean'], ['vi-VN', 'Vietnamese'], ['zh-CN', 'Mandarin'],
|
||||
]
|
||||
|
||||
const STORE_KEY = 'rudder-voice:v1'
|
||||
function load() {
|
||||
try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}') } catch { return {} }
|
||||
}
|
||||
function save() {
|
||||
try { localStorage.setItem(STORE_KEY, JSON.stringify({ language: language.value, device: device.value, draft: draft.value })) } catch {}
|
||||
}
|
||||
const saved = load()
|
||||
|
||||
const engine = ref('connecting') // connecting | starting | ready | error | offline
|
||||
const engineError = ref('')
|
||||
const runtime = ref('')
|
||||
const model = ref('')
|
||||
const recording = ref(false)
|
||||
const busy = ref(false)
|
||||
const language = ref(saved.language || cfg.value.language || 'en-US')
|
||||
// The draft: a text area you type and paste into; every finished utterance is APPENDED to its end.
|
||||
// (v1 kept a list of lines — migrate it so nothing already dictated is lost.)
|
||||
const draft = ref(typeof saved.draft === 'string' ? saved.draft
|
||||
: Array.isArray(saved.lines) ? saved.lines.map((l) => l.text).join(' ') : '')
|
||||
const partial = ref('') // the utterance being heard right now (streamed into the box's live tail)
|
||||
const box = ref(null)
|
||||
const level = ref(0)
|
||||
const notice = ref('')
|
||||
const noticeKind = ref('info')
|
||||
const copied = ref('')
|
||||
const showDebug = ref(false)
|
||||
const showInfo = ref(false)
|
||||
const clientLog = ref([])
|
||||
|
||||
// ─── Microphones (the Jitsi device-selection pattern, served natively by the engine) ───
|
||||
const devices = ref([]) // [{id,label,bus,default,muted,volume,state}]
|
||||
const offCards = ref([]) // sound cards with a mic that are switched off (e.g. the laptop's own)
|
||||
const deviceError = ref('')
|
||||
const device = ref(saved.device || '') // '' = follow the system default input
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null) // {ok, verdict, peak}
|
||||
|
||||
const BUS_ICON = { bluetooth: '🎧', usb: '🔌', pci: '💻' }
|
||||
function deviceLabel(d) {
|
||||
const bits = [(BUS_ICON[d.bus] || '🎙') + ' ' + d.label]
|
||||
if (d.muted) bits.push('(muted)')
|
||||
else if (d.volume) bits.push('· ' + d.volume)
|
||||
return bits.join(' ')
|
||||
}
|
||||
const defaultDevice = computed(() => devices.value.find((d) => d.default))
|
||||
const chosen = computed(() => devices.value.find((d) => d.id === device.value) || (device.value ? null : defaultDevice.value))
|
||||
|
||||
function applyDevices(list) {
|
||||
if (!list) return
|
||||
devices.value = list.devices || []
|
||||
offCards.value = list.offCards || []
|
||||
deviceError.value = list.error || ''
|
||||
// A saved choice that is gone (headset switched off) falls back to the default, and says so.
|
||||
if (device.value && !devices.value.some((d) => d.id === device.value)) {
|
||||
if (!recording.value) {
|
||||
say('Your chosen microphone is no longer connected — using the system default instead.', 'warn')
|
||||
device.value = ''
|
||||
save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDevices() {
|
||||
try { applyDevices(await (await fetch(apiBase.value + '/devices')).json()) } catch (err) { logc('devices fetch failed: ' + err) }
|
||||
}
|
||||
|
||||
async function testMic() {
|
||||
if (testing.value || recording.value) return
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
testResult.value = await post('/test', { device: device.value })
|
||||
} catch (err) {
|
||||
testResult.value = { ok: false, verdict: String(err.message || err) }
|
||||
} finally {
|
||||
testing.value = false
|
||||
level.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
let es = null
|
||||
let retry = null
|
||||
|
||||
function logc(msg) {
|
||||
clientLog.value.push(new Date().toISOString().slice(11, 23) + ' ' + msg)
|
||||
if (clientLog.value.length > 200) clientLog.value.splice(0, clientLog.value.length - 200)
|
||||
}
|
||||
|
||||
function say(msg, kind = 'info') { notice.value = msg; noticeKind.value = kind }
|
||||
|
||||
// ─── Writing into the box ────────────────────────────────────────────────────────────────────────
|
||||
// Every word streams straight into the END of the box while you keep typing, pasting and editing
|
||||
// anywhere. The sentence in progress lives in a "live tail" — the last `live.text.length` characters
|
||||
// of the draft — and each new word rewrites only that tail.
|
||||
//
|
||||
// ★ TYPING WHILE TALKING (bug fixed 2026-09-27): once you type into or after the tail it is yours, and
|
||||
// Rudder must continue with ONLY the words it hasn't written yet. v1 re-wrote the whole sentence-so-far
|
||||
// after every keystroke, so each key press duplicated the utterance. Now `written` counts the words of
|
||||
// this utterance already in the box, and the finished (punctuated) sentence is lined up word-by-word
|
||||
// against them, so nothing is ever written twice.
|
||||
let live = null // { start, text, shown } — the tail Rudder may still rewrite; shown = words in it
|
||||
let written = 0 // words of the current utterance committed to the box before the current tail
|
||||
|
||||
// The tail is still Rudder's while the box ENDS with it — wherever it now starts. Typing or pasting
|
||||
// anywhere above it shifts its position, and that must not count as touching it (bug 2026-09-27: a
|
||||
// position-based check released the tail on every keystroke above it, cutting "speaking" to "spe").
|
||||
function liveIntact() {
|
||||
if (!live || !draft.value.endsWith(live.text)) return false
|
||||
live.start = draft.value.length - live.text.length
|
||||
return true
|
||||
}
|
||||
|
||||
// Change only the end of the draft, keeping the person's caret/selection and scroll where they were.
|
||||
function writeEnd(next) {
|
||||
const el = box.value
|
||||
const focused = el && el.getRootNode().activeElement === el
|
||||
const selStart = el ? el.selectionStart : 0
|
||||
const selEnd = el ? el.selectionEnd : 0
|
||||
const atBottom = !el || el.scrollHeight - el.scrollTop - el.clientHeight < 32
|
||||
draft.value = next
|
||||
save()
|
||||
nextTick(() => {
|
||||
if (!el) return
|
||||
if (focused) el.setSelectionRange(Math.min(selStart, next.length), Math.min(selEnd, next.length))
|
||||
if (atBottom || !focused) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
const sepFor = (text) => (!text || /\s$/.test(text) ? '' : ' ')
|
||||
const wordsOf = (text) => (text || '').trim().split(/\s+/).filter(Boolean)
|
||||
|
||||
// The person typed into or after the tail, so it's theirs now. Count its words as written — except a
|
||||
// HALF-FINISHED last word: the model streams words in pieces ("for" … "tnight"), and typing between the
|
||||
// pieces used to freeze "for" and lose the rest (measured 2026-09-27: "for a for [typed]"). If the word
|
||||
// has since grown, the fragment is removed (it sits just before the person's text; their caret is
|
||||
// shifted so it stays put relative to what they typed) and the whole word is written after it.
|
||||
const norm = (x) => (x || '').toLowerCase().replace(/[^\p{L}\p{N}']/gu, '')
|
||||
function releaseTail(utteranceWords) {
|
||||
const shownWords = wordsOf(live.text)
|
||||
const lastShown = shownWords[shownWords.length - 1] || ''
|
||||
const nowWord = utteranceWords[written + live.shown - 1]
|
||||
const grew = live.shown > 0 && nowWord !== undefined && norm(nowWord) !== norm(lastShown) && norm(nowWord).startsWith(norm(lastShown))
|
||||
// Find the tail by its text: typing above it may have moved it since it was written.
|
||||
const found = draft.value.lastIndexOf(live.text)
|
||||
if (found >= 0) live.start = found
|
||||
const tailEnd = live.start + live.text.length
|
||||
const prefixOk = found >= 0
|
||||
if (grew && prefixOk && lastShown) {
|
||||
let fragStart = tailEnd - lastShown.length
|
||||
if (fragStart > live.start && draft.value[fragStart - 1] === ' ') fragStart--
|
||||
const cut = tailEnd - fragStart
|
||||
const el = box.value
|
||||
const focused = el && el.getRootNode().activeElement === el
|
||||
const shift = (i) => (i >= tailEnd ? i - cut : i > fragStart ? fragStart : i)
|
||||
const a = el ? shift(el.selectionStart) : 0
|
||||
const b = el ? shift(el.selectionEnd) : 0
|
||||
draft.value = draft.value.slice(0, fragStart) + draft.value.slice(tailEnd)
|
||||
if (focused) nextTick(() => el.setSelectionRange(a, b))
|
||||
written += live.shown - 1
|
||||
} else {
|
||||
written += live.shown
|
||||
}
|
||||
live = null
|
||||
}
|
||||
|
||||
// Write `words` as the live tail: rewrite it if it's still ours, otherwise open a new one at the end.
|
||||
function writeTail(words, utterance) {
|
||||
const t = words.join(' ')
|
||||
if (liveIntact()) {
|
||||
const lead = live.text.match(/^\s*/)[0]
|
||||
live.text = t ? lead + t : ''
|
||||
live.shown = words.length
|
||||
writeEnd(draft.value.slice(0, live.start) + live.text)
|
||||
return
|
||||
}
|
||||
if (live) releaseTail(utterance) // the person took over the old tail: keep it
|
||||
if (!t) return
|
||||
live = { start: draft.value.length, text: sepFor(draft.value) + t, shown: words.length }
|
||||
writeEnd(draft.value + live.text)
|
||||
}
|
||||
|
||||
// ─── Hold the stream while the person is typing ─────────────────────────────────────────────────
|
||||
// Typing and streaming both write at the end of the box; a word landing between two keystrokes splices
|
||||
// into what is being typed (measured 2026-09-27: "he'[typed]ll"). So while keys are being pressed the
|
||||
// stream is buffered, and flushed — in order, nothing dropped — 700 ms after the last keystroke.
|
||||
const TYPING_HOLD_MS = 700
|
||||
let lastTypedAt = 0
|
||||
let pendingPartial = null
|
||||
const pendingFinals = []
|
||||
let flushTimer = null
|
||||
function markTyping() { lastTypedAt = Date.now() }
|
||||
function onUserInput() {
|
||||
markTyping()
|
||||
save()
|
||||
}
|
||||
function typingNow() { return Date.now() - lastTypedAt < TYPING_HOLD_MS }
|
||||
function scheduleFlush() {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = setTimeout(() => {
|
||||
if (typingNow()) return scheduleFlush()
|
||||
const finals = pendingFinals.splice(0)
|
||||
const p = pendingPartial
|
||||
pendingPartial = null
|
||||
for (const f of finals) finishUtterance(f)
|
||||
if (p !== null) streamPartial(p)
|
||||
}, TYPING_HOLD_MS)
|
||||
}
|
||||
function onPartial(full) {
|
||||
if (typingNow() || pendingFinals.length) { pendingPartial = full; scheduleFlush(); return }
|
||||
streamPartial(full)
|
||||
}
|
||||
function onFinal(text) {
|
||||
if (typingNow() || pendingFinals.length) { pendingFinals.push(text); pendingPartial = null; scheduleFlush(); return }
|
||||
finishUtterance(text)
|
||||
}
|
||||
|
||||
// A word arrived: show the words of this utterance that aren't in the box yet.
|
||||
function streamPartial(full) {
|
||||
const w = wordsOf(full)
|
||||
if (live && !liveIntact()) releaseTail(w)
|
||||
writeTail(w.slice(written), w)
|
||||
}
|
||||
|
||||
// The sentence is finished: the same span of words, now punctuated and capitalised.
|
||||
function finishUtterance(text) {
|
||||
const w = wordsOf(text)
|
||||
if (live && !liveIntact()) releaseTail(w)
|
||||
writeTail(w.slice(written), w)
|
||||
live = null
|
||||
written = 0
|
||||
}
|
||||
|
||||
function applyStatus(s) {
|
||||
if (!s) return
|
||||
if (s.engine) engine.value = s.engine
|
||||
engineError.value = s.error || ''
|
||||
if (s.runtime) runtime.value = s.runtime
|
||||
if (s.model) model.value = s.model
|
||||
if (typeof s.recording === 'boolean') recording.value = s.recording
|
||||
}
|
||||
|
||||
function connect() {
|
||||
clearTimeout(retry)
|
||||
try { es?.close() } catch {}
|
||||
es = new window.EventSource(apiBase.value + '/events')
|
||||
es.onopen = () => logc('events connected')
|
||||
es.onmessage = (m) => {
|
||||
let e
|
||||
try { e = JSON.parse(m.data) } catch { return }
|
||||
switch (e.type) {
|
||||
case 'status':
|
||||
applyStatus(e.status || e)
|
||||
if (e.engine === 'error') say(e.error || 'Speech engine error', 'error')
|
||||
if (e.engine === 'ready' && noticeKind.value !== 'error') say('')
|
||||
break
|
||||
case 'recording':
|
||||
recording.value = !!e.recording
|
||||
if (!e.recording) { level.value = 0; flushPartial() }
|
||||
break
|
||||
case 'partial':
|
||||
partial.value += e.text
|
||||
onPartial(partial.value)
|
||||
break
|
||||
case 'final':
|
||||
partial.value = ''
|
||||
onFinal(e.text)
|
||||
break
|
||||
case 'recovering':
|
||||
say(e.message, 'warn')
|
||||
break
|
||||
case 'recovered':
|
||||
say(e.message, 'ok')
|
||||
setTimeout(() => { if (noticeKind.value === 'ok') say('') }, 4000)
|
||||
break
|
||||
case 'devices':
|
||||
applyDevices(e.devices)
|
||||
break
|
||||
case 'level':
|
||||
level.value = Math.min(1, (e.peak || 0) * 3)
|
||||
break
|
||||
case 'warning':
|
||||
say(e.message, 'warn')
|
||||
break
|
||||
case 'error':
|
||||
say(e.message, 'error')
|
||||
logc('engine error: ' + e.message)
|
||||
break
|
||||
}
|
||||
}
|
||||
es.onerror = () => {
|
||||
engine.value = 'offline'
|
||||
recording.value = false
|
||||
logc('events disconnected; retrying in 3s')
|
||||
try { es.close() } catch {}
|
||||
retry = setTimeout(connect, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Recording stopped mid-sentence: the streamed words are already in the box, so just let go of them.
|
||||
function flushPartial() {
|
||||
clearTimeout(flushTimer)
|
||||
const finals = pendingFinals.splice(0)
|
||||
const p = pendingPartial
|
||||
pendingPartial = null
|
||||
for (const f of finals) finishUtterance(f)
|
||||
if (p !== null) streamPartial(p)
|
||||
partial.value = ''
|
||||
live = null
|
||||
written = 0
|
||||
written = 0
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const r = await fetch(apiBase.value + path, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}),
|
||||
})
|
||||
const j = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`)
|
||||
return j
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (recording.value) {
|
||||
await post('/stop')
|
||||
} else {
|
||||
say('')
|
||||
testResult.value = null
|
||||
applyStatus(await post('/start', { language: language.value, device: device.value }))
|
||||
}
|
||||
} catch (err) {
|
||||
say(String(err.message || err), 'error')
|
||||
logc('toggle failed: ' + err)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const words = computed(() => (draft.value.trim() ? draft.value.trim().split(/\s+/).length : 0))
|
||||
|
||||
async function copy(text, what) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text; document.body.appendChild(ta); ta.select()
|
||||
try { document.execCommand('copy') } catch {}
|
||||
ta.remove()
|
||||
}
|
||||
copied.value = what
|
||||
setTimeout(() => { if (copied.value === what) copied.value = '' }, 1500)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (draft.value.trim() && !window.confirm('Clear the whole text box? This cannot be undone.')) return
|
||||
draft.value = ''
|
||||
partial.value = ''
|
||||
live = null
|
||||
save()
|
||||
}
|
||||
|
||||
// ★ PRIVACY: the report is meant to be pasted to an agent, so it describes the setup without
|
||||
// identifying the person: no browser fingerprint (user-agent), no device names ("Anna's AirPods"), no
|
||||
// device IDs (they embed the Bluetooth MAC address), and never the text in the box — only its length.
|
||||
// The engine log it includes is already redacted at the source (MACs, home folder).
|
||||
const MAC_RE = /\b([0-9a-f]{2}[:_]){5}[0-9a-f]{2}\b/gi
|
||||
function privateDevices() {
|
||||
return devices.value.map((d, i) => ({ mic: 'mic ' + (i + 1), bus: d.bus, default: d.default, muted: d.muted, volume: d.volume, state: d.state }))
|
||||
}
|
||||
function browserFamily() {
|
||||
const ua = navigator.userAgent
|
||||
return /firefox/i.test(ua) ? 'Firefox' : /edg\//i.test(ua) ? 'Edge' : /chrom/i.test(ua) ? 'Chromium-based' : /safari/i.test(ua) ? 'Safari' : 'other'
|
||||
}
|
||||
|
||||
async function copyDebug() {
|
||||
let engineLog = '(unreachable)'
|
||||
let status = '(unreachable)'
|
||||
try { engineLog = await (await fetch(apiBase.value + '/log')).text() } catch {}
|
||||
try { status = JSON.stringify(await (await fetch(apiBase.value + '/status')).json(), null, 2) } catch {}
|
||||
const text = [
|
||||
'// RUDDER DEBUG REPORT — for a coding agent.',
|
||||
'// Read the engine status and log below, find why transcription failed or misbehaved, and propose a fix',
|
||||
'// in backend/rudder-engine/main.go or plugin-designs/rudder-voice/rudder-voice.vue. Do not guess:',
|
||||
'// quote the log lines that prove the cause.',
|
||||
'',
|
||||
'== status ==', status,
|
||||
'== plugin ==', JSON.stringify({ apiBase: apiBase.value, draftChars: draft.value.length, engine: engine.value, recording: recording.value, language: language.value, device: device.value ? 'chosen mic' : '(system default)', browser: browserFamily() }, null, 2),
|
||||
'== microphones ==', JSON.stringify({ devices: privateDevices(), offCards: offCards.value.length, error: deviceError.value, lastTest: testResult.value }, null, 2),
|
||||
'== client log ==', clientLog.value.join('\n'),
|
||||
'== engine log ==', engineLog,
|
||||
].join('\n').replace(MAC_RE, 'XX:XX:XX:XX:XX:XX')
|
||||
copy(text, 'debug')
|
||||
}
|
||||
|
||||
function onKey(ev) {
|
||||
// Ctrl+Shift+Space toggles recording without reaching for the mouse.
|
||||
if (ev.ctrlKey && ev.shiftKey && ev.code === 'Space') { ev.preventDefault(); toggle() }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect()
|
||||
refreshDevices()
|
||||
window.addEventListener('keydown', onKey)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(retry)
|
||||
try { es?.close() } catch {}
|
||||
window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
|
||||
const statusText = computed(() => ({
|
||||
connecting: 'Connecting…',
|
||||
starting: 'Loading speech model… (about 20 s the first time)',
|
||||
ready: recording.value ? 'Listening' : 'Ready',
|
||||
error: 'Engine error',
|
||||
offline: 'Engine offline',
|
||||
}[engine.value] || engine.value))
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rudder" :class="{ rec: recording }">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<span class="logo" aria-hidden="true">⎈</span>
|
||||
<div>
|
||||
<h1>Rudder</h1>
|
||||
<p class="sub">Voice-to-text for vibe coders · private · runs on this computer · no internet</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="info-btn" :aria-expanded="showInfo" title="What is Rudder?" aria-label="What is Rudder?" @click="showInfo = !showInfo">ⓘ</button>
|
||||
<div class="status" :data-state="engine" role="status">
|
||||
<span class="dot" aria-hidden="true"></span>{{ statusText }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section v-if="showInfo" class="info">
|
||||
<h2>What is Rudder?</h2>
|
||||
<p>A voice-to-text tool for vibe coders: talk to your agents instead of typing to them. Paste links, code
|
||||
and notes into the box while you talk; everything you say is added to the end, ready to copy into a chat.</p>
|
||||
<ul>
|
||||
<li><b>Streaming.</b> Words appear while you speak, and you can keep talking for as long as you like —
|
||||
an hour is fine. There is no time limit and nothing to wait for at the end.</li>
|
||||
<li><b>Runs on your CPU.</b> No graphics card needed. The model uses about 1 GB of memory and keeps up with
|
||||
normal speech on an ordinary laptop processor.</li>
|
||||
<li><b>Private and offline.</b> Your voice never leaves this computer. No account, no cloud, no telemetry —
|
||||
checked with a network tracer: zero connections while transcribing.</li>
|
||||
<li><b>The model.</b> NVIDIA Nemotron 3.5 ASR Streaming (0.6B), run by NVIDIA's open-source
|
||||
NeMo-Speech.cpp. It adds punctuation and capital letters by itself.</li>
|
||||
<li><b>Why it lives in dx-maps.</b> Rudder is a plugin like the maps: built in the Plugin Studio, tested in the
|
||||
browser, and shipped as a desktop app with its own local backend — proof that one dx-maps codebase can
|
||||
build desktop apps, not only maps.</li>
|
||||
</ul>
|
||||
<p class="fine">Bluetooth headsets: Rudder switches them to their stable voice mode (CVSD), because the
|
||||
higher-quality mode (mSBC) kept dropping the mic after a few words.</p>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<button
|
||||
class="mic"
|
||||
:class="{ on: recording }"
|
||||
:disabled="busy || engine !== 'ready'"
|
||||
:aria-pressed="recording"
|
||||
:aria-label="recording ? 'Stop recording' : 'Start recording'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="mic-icon" aria-hidden="true">{{ recording ? '■' : '●' }}</span>
|
||||
{{ recording ? 'Stop' : 'Record' }}
|
||||
</button>
|
||||
<div class="meter" aria-hidden="true"><div class="fill" :style="{ width: (level * 100).toFixed(0) + '%' }"></div></div>
|
||||
<label class="lang">
|
||||
<span>Language</span>
|
||||
<select v-model="language" :disabled="recording" @change="save">
|
||||
<option v-for="[code, name] in LANGUAGES" :key="code" :value="code">{{ name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="mics">
|
||||
<label class="mic-pick">
|
||||
<span>Microphone</span>
|
||||
<select v-model="device" :disabled="recording || testing" @change="save(); testResult = null">
|
||||
<option value="">System default{{ defaultDevice ? ' — ' + defaultDevice.label : '' }}</option>
|
||||
<option v-for="d in devices" :key="d.id" :value="d.id">{{ deviceLabel(d) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="small" :disabled="testing || recording || engine === 'offline'" @click="testMic">
|
||||
{{ testing ? 'Testing… speak now' : 'Test mic' }}
|
||||
</button>
|
||||
<button class="small ghost" title="Look for microphones again" aria-label="Look for microphones again" :disabled="recording" @click="refreshDevices">↻</button>
|
||||
</section>
|
||||
<p v-if="testResult" class="notice" :data-kind="testResult.ok ? (testResult.peak < 0.01 ? 'warn' : 'ok') : 'error'">
|
||||
{{ testResult.ok ? '✓ ' : '✗ ' }}{{ testResult.verdict }}
|
||||
</p>
|
||||
<p v-if="chosen && chosen.muted" class="notice" data-kind="warn">“{{ chosen.label }}” is muted in your system sound settings — Rudder won't hear anything until it's unmuted.</p>
|
||||
<p v-if="deviceError" class="notice" data-kind="error">{{ deviceError }}</p>
|
||||
<p v-else-if="!devices.length && engine !== 'offline'" class="notice" data-kind="warn">No microphone found. Connect a headset or turn on your computer's microphone in Sound settings.</p>
|
||||
<p v-if="offCards.length" class="hint">Also available but switched off in Sound settings: {{ offCards.join(', ') }}. Turn it on there to use it here.</p>
|
||||
|
||||
<p v-if="notice" class="notice" :data-kind="noticeKind">{{ notice }}</p>
|
||||
<p v-else-if="engine === 'offline'" class="notice" data-kind="warn">
|
||||
The local speech engine isn't reachable at <code>{{ apiBase }}</code>. In the Plugin Studio it starts with
|
||||
<code>bun run dev</code> once built: <code>cd backend/rudder-engine && go build -o ../bin/rudder-engine .</code>
|
||||
</p>
|
||||
|
||||
<section class="compose">
|
||||
<textarea
|
||||
ref="box"
|
||||
v-model="draft"
|
||||
class="draft"
|
||||
spellcheck="true"
|
||||
aria-label="Your text. Type or paste here; everything Rudder hears is added to the end."
|
||||
placeholder="Type or paste here — and press Record (Ctrl+Shift+Space) to talk. Your words stream into the end of this box as you speak."
|
||||
@input="onUserInput"
|
||||
@keydown="markTyping"
|
||||
></textarea>
|
||||
<p class="hearing" aria-live="polite">
|
||||
<template v-if="recording"><span class="tag">listening</span> words appear at the end of the box as you speak — keep typing or pasting anywhere</template>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer class="bottom">
|
||||
<button :disabled="!draft.trim()" @click="copy(draft, 'all')">{{ copied === 'all' ? 'Copied ✓' : 'Copy all' }}</button>
|
||||
<button :disabled="!draft.trim() || recording" @click="clearAll">Clear</button>
|
||||
<span class="count">{{ words }} {{ words === 1 ? 'word' : 'words' }}</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="ghost" :aria-expanded="showDebug" @click="showDebug = !showDebug">🐞 Debug</button>
|
||||
</footer>
|
||||
|
||||
<section v-if="showDebug" class="debug">
|
||||
<dl>
|
||||
<dt>API</dt><dd><code>{{ apiBase }}</code></dd>
|
||||
<dt>Runtime</dt><dd>{{ runtime || '—' }}</dd>
|
||||
<dt>Model</dt><dd>{{ model || '—' }}</dd>
|
||||
<dt>Engine</dt><dd>{{ engine }}<span v-if="engineError"> — {{ engineError }}</span></dd>
|
||||
</dl>
|
||||
<button @click="copyDebug">{{ copied === 'debug' ? 'Copied ✓' : 'Copy debug report for an agent' }}</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:host { display: block; }
|
||||
.rudder {
|
||||
--bg: #0b0f14; --panel: #111823; --line: #1f2937; --text: #e5e7eb; --muted: #9ca3af;
|
||||
--accent: #38bdf8; --rec: #ef4444; --ok: #22c55e; --warn: #f59e0b;
|
||||
box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; min-height: 480px; height: 100%;
|
||||
padding: 18px; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-size: 15px;
|
||||
}
|
||||
.rudder *, .rudder *::before, .rudder *::after { box-sizing: border-box; }
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.logo { font-size: 30px; color: var(--accent); line-height: 1; }
|
||||
h1 { margin: 0; font-size: 20px; letter-spacing: -0.01em; }
|
||||
.sub { margin: 2px 0 0; color: var(--muted); font-size: 12.5px; }
|
||||
.status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--line);
|
||||
border-radius: 999px; font-size: 13px; color: var(--muted); background: var(--panel); }
|
||||
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||
.status[data-state='ready'] .dot { background: var(--ok); }
|
||||
.status[data-state='starting'] .dot, .status[data-state='connecting'] .dot { background: var(--warn); animation: pulse 1.2s infinite; }
|
||||
.status[data-state='error'] .dot, .status[data-state='offline'] .dot { background: var(--rec); }
|
||||
.rec .status .dot { background: var(--rec); animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
.controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.mic { display: inline-flex; align-items: center; gap: 10px; padding: 12px 22px; border-radius: 999px;
|
||||
border: 1px solid var(--line); background: var(--accent); color: #04121c; font-weight: 700; font-size: 16px; cursor: pointer; }
|
||||
.mic.on { background: var(--rec); color: #fff; }
|
||||
.mic:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.mic:focus-visible, button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.mic-icon { font-size: 14px; }
|
||||
.meter { flex: 1 1 140px; min-width: 100px; height: 8px; border-radius: 4px; background: var(--panel); border: 1px solid var(--line); overflow: hidden; }
|
||||
.meter .fill { height: 100%; background: linear-gradient(90deg, var(--ok), var(--warn), var(--rec)); transition: width .12s linear; }
|
||||
.lang { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
|
||||
select { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font-size: 14px; }
|
||||
.notice { margin: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); font-size: 13.5px; line-height: 1.45; }
|
||||
.notice[data-kind='error'] { border-color: #7f1d1d; background: #1f0f12; }
|
||||
.notice[data-kind='warn'] { border-color: #78350f; background: #1c140a; }
|
||||
.notice[data-kind='ok'] { border-color: #14532d; background: #0b1a11; }
|
||||
.mics { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.mic-pick { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; flex: 1 1 260px; }
|
||||
.mic-pick select { flex: 1; min-width: 0; }
|
||||
.small { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 12px; cursor: pointer; font-size: 13.5px; }
|
||||
.small:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.small.ghost { background: transparent; }
|
||||
.hint { margin: 0; color: var(--muted); font-size: 12.5px; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; color: #c4b5fd; overflow-wrap: anywhere; }
|
||||
.compose { flex: 1 1 auto; display: flex; flex-direction: column; gap: 6px; min-height: 260px; }
|
||||
.draft { flex: 1 1 auto; width: 100%; min-height: 240px; resize: vertical; padding: 14px; border: 1px solid var(--line);
|
||||
border-radius: 12px; background: var(--panel); color: var(--text); font: inherit; font-size: 16px; line-height: 1.6; }
|
||||
.draft:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.draft::placeholder { color: #6b7280; }
|
||||
.hearing { margin: 0; min-height: 1.5em; color: var(--accent); font-size: 14px; overflow-wrap: anywhere; }
|
||||
.hearing .tag { display: inline-block; margin-right: 6px; padding: 1px 7px; border-radius: 999px; background: #0c2a3a;
|
||||
color: var(--accent); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.count { color: var(--muted); font-size: 12.5px; }
|
||||
.bottom { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.bottom button, .debug button { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; cursor: pointer; font-size: 14px; }
|
||||
.bottom button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.bottom .ghost { background: transparent; }
|
||||
.spacer { flex: 1; }
|
||||
.info-btn { margin-left: auto; background: transparent; border: 1px solid var(--line); color: var(--muted); border-radius: 999px;
|
||||
width: 30px; height: 30px; font-size: 16px; cursor: pointer; }
|
||||
.info-btn:hover, .info-btn[aria-expanded='true'] { color: var(--accent); border-color: var(--accent); }
|
||||
.info { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); padding: 14px 16px; font-size: 14px; line-height: 1.55; }
|
||||
.info h2 { margin: 0 0 6px; font-size: 16px; }
|
||||
.info p { margin: 0 0 8px; }
|
||||
.info ul { margin: 0 0 8px; padding-left: 18px; }
|
||||
.info li { margin-bottom: 6px; }
|
||||
.info .fine { color: var(--muted); font-size: 12.5px; margin: 0; }
|
||||
.debug { border: 1px dashed var(--line); border-radius: 10px; padding: 12px; font-size: 13px; }
|
||||
.debug dl { display: grid; grid-template-columns: 90px 1fr; gap: 6px 10px; margin: 0 0 10px; }
|
||||
.debug dt { color: var(--muted); }
|
||||
.debug dd { margin: 0; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
|
||||
|
||||
- **`dx-maps/`** — the output folder: 24 plugin bundles and 36 pages, self-contained.
|
||||
- **`dx-maps/`** — the output folder: 25 plugin bundles and 37 pages, self-contained.
|
||||
Copy this one folder to any web host (or open it from disk) and it works.
|
||||
- `dx-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
|
||||
- `dx-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 24,
|
||||
"profileCount": 36,
|
||||
"bundleCount": 25,
|
||||
"profileCount": 37,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life"
|
||||
},
|
||||
|
|
@ -604,7 +604,7 @@
|
|||
"bundle": "gospel-internet",
|
||||
"scriptUrl": "./gospel-internet/gospel-internet.js",
|
||||
"output": "dx-maps/gospel-internet/gospel-internet.js",
|
||||
"integrity": "sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA",
|
||||
"integrity": "sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ",
|
||||
"elements": [
|
||||
"gospel-internet-board"
|
||||
],
|
||||
|
|
@ -623,7 +623,7 @@
|
|||
"scriptUrl": "./gospel-internet/gospel-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -995,7 +995,7 @@
|
|||
"bundle": "pplr-data-maps",
|
||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
|
||||
"integrity": "sha384-n9fmIn0WHLw6UyWSoAAHEuYd6Ue44wkzO9s2U7qYehuroGx3VIyRtQ8T9FOXiqMQ",
|
||||
"integrity": "sha384-6j1lL/M+zY5gZpb0nNq17QXIjTDTpPAU7PhTkEM3ciAFkkC/RwyArOrQXi0I2ZrR",
|
||||
"elements": [
|
||||
"pplr-data-maps"
|
||||
],
|
||||
|
|
@ -1013,7 +1013,7 @@
|
|||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-n9fmIn0WHLw6UyWSoAAHEuYd6Ue44wkzO9s2U7qYehuroGx3VIyRtQ8T9FOXiqMQ\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-6j1lL/M+zY5gZpb0nNq17QXIjTDTpPAU7PhTkEM3ciAFkkC/RwyArOrQXi0I2ZrR\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||
},
|
||||
"bundle": "pplr-data-maps"
|
||||
}
|
||||
|
|
@ -1181,6 +1181,33 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "rudder-voice",
|
||||
"scriptUrl": "./rudder-voice/rudder-voice.js",
|
||||
"output": "dx-maps/rudder-voice/rudder-voice.js",
|
||||
"integrity": "sha384-ygcWKoVAImcBol0tmjxbdFMmgZpzITjrPt97BqNUPVXacNvhhoeiCgqiPHbSJVC9",
|
||||
"elements": [
|
||||
"rudder-voice"
|
||||
],
|
||||
"stage": "experimental",
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "rudder-voice",
|
||||
"stage": "experimental",
|
||||
"file": "rudder-voice.vue",
|
||||
"description": "A DESKTOP-target plugin (rudder-voice.meta.json \"targets\": [\"desktop\"]). The speech",
|
||||
"element": "rudder-voice",
|
||||
"type": "single",
|
||||
"isMap": false,
|
||||
"iframeUrl": "./rudder-voice/rudder-voice/index.html",
|
||||
"scriptUrl": "./rudder-voice/rudder-voice.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./rudder-voice/rudder-voice/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./rudder-voice/rudder-voice.js\" integrity=\"sha384-ygcWKoVAImcBol0tmjxbdFMmgZpzITjrPt97BqNUPVXacNvhhoeiCgqiPHbSJVC9\" crossorigin=\"anonymous\"></script>\n<rudder-voice profile-config='{\"profile\":\"rudder-voice\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></rudder-voice>"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "stack-demo",
|
||||
"scriptUrl": "./stack-demo/stack-demo.js",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../gospel-internet.js" integrity="sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA" crossorigin="anonymous"></script>
|
||||
<script src="../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 24,
|
||||
"profileCount": 36,
|
||||
"bundleCount": 25,
|
||||
"profileCount": 37,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life"
|
||||
},
|
||||
|
|
@ -604,7 +604,7 @@
|
|||
"bundle": "gospel-internet",
|
||||
"scriptUrl": "./gospel-internet/gospel-internet.js",
|
||||
"output": "dx-maps/gospel-internet/gospel-internet.js",
|
||||
"integrity": "sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA",
|
||||
"integrity": "sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ",
|
||||
"elements": [
|
||||
"gospel-internet-board"
|
||||
],
|
||||
|
|
@ -623,7 +623,7 @@
|
|||
"scriptUrl": "./gospel-internet/gospel-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-zCHljWransOIik0oBQyKH6JZ6L4HbhQl0XVX/PJk1oXMPrpsyc03W1OjlcdhojbA\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-HwebV+2UvBEgMdspcEDgjNPo/dn4sI9B5eLGgPBC6+bTbDBibbkhPY7LU/OQ23IZ\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -995,7 +995,7 @@
|
|||
"bundle": "pplr-data-maps",
|
||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
|
||||
"integrity": "sha384-n9fmIn0WHLw6UyWSoAAHEuYd6Ue44wkzO9s2U7qYehuroGx3VIyRtQ8T9FOXiqMQ",
|
||||
"integrity": "sha384-6j1lL/M+zY5gZpb0nNq17QXIjTDTpPAU7PhTkEM3ciAFkkC/RwyArOrQXi0I2ZrR",
|
||||
"elements": [
|
||||
"pplr-data-maps"
|
||||
],
|
||||
|
|
@ -1013,7 +1013,7 @@
|
|||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-n9fmIn0WHLw6UyWSoAAHEuYd6Ue44wkzO9s2U7qYehuroGx3VIyRtQ8T9FOXiqMQ\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-6j1lL/M+zY5gZpb0nNq17QXIjTDTpPAU7PhTkEM3ciAFkkC/RwyArOrQXi0I2ZrR\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||
},
|
||||
"bundle": "pplr-data-maps"
|
||||
}
|
||||
|
|
@ -1181,6 +1181,33 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "rudder-voice",
|
||||
"scriptUrl": "./rudder-voice/rudder-voice.js",
|
||||
"output": "dx-maps/rudder-voice/rudder-voice.js",
|
||||
"integrity": "sha384-ygcWKoVAImcBol0tmjxbdFMmgZpzITjrPt97BqNUPVXacNvhhoeiCgqiPHbSJVC9",
|
||||
"elements": [
|
||||
"rudder-voice"
|
||||
],
|
||||
"stage": "experimental",
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "rudder-voice",
|
||||
"stage": "experimental",
|
||||
"file": "rudder-voice.vue",
|
||||
"description": "A DESKTOP-target plugin (rudder-voice.meta.json \"targets\": [\"desktop\"]). The speech",
|
||||
"element": "rudder-voice",
|
||||
"type": "single",
|
||||
"isMap": false,
|
||||
"iframeUrl": "./rudder-voice/rudder-voice/index.html",
|
||||
"scriptUrl": "./rudder-voice/rudder-voice.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./rudder-voice/rudder-voice/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./rudder-voice/rudder-voice.js\" integrity=\"sha384-ygcWKoVAImcBol0tmjxbdFMmgZpzITjrPt97BqNUPVXacNvhhoeiCgqiPHbSJVC9\" crossorigin=\"anonymous\"></script>\n<rudder-voice profile-config='{\"profile\":\"rudder-voice\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></rudder-voice>"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "stack-demo",
|
||||
"scriptUrl": "./stack-demo/stack-demo.js",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <pplr-data-maps> custom element. -->
|
||||
<script src="../pplr-data-maps.js" integrity="sha384-n9fmIn0WHLw6UyWSoAAHEuYd6Ue44wkzO9s2U7qYehuroGx3VIyRtQ8T9FOXiqMQ" crossorigin="anonymous"></script>
|
||||
<script src="../pplr-data-maps.js" integrity="sha384-6j1lL/M+zY5gZpb0nNq17QXIjTDTpPAU7PhTkEM3ciAFkkC/RwyArOrQXi0I2ZrR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
23
public/dx-maps/rudder-voice/rudder-voice.js
Normal file
23
public/dx-maps/rudder-voice/rudder-voice.js
Normal file
File diff suppressed because one or more lines are too long
214
public/dx-maps/rudder-voice/rudder-voice/index.html
Normal file
214
public/dx-maps/rudder-voice/rudder-voice/index.html
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
|
||||
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
|
||||
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
|
||||
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
|
||||
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
|
||||
<script>window.MAP_OWNS_PAGE = true;</script>
|
||||
<title>rudder-voice · rudder-voice</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, rudder-voice { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <rudder-voice> custom element. -->
|
||||
<script src="../rudder-voice.js" integrity="sha384-ygcWKoVAImcBol0tmjxbdFMmgZpzITjrPt97BqNUPVXacNvhhoeiCgqiPHbSJVC9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"rudder-voice","dataSource":"pray-tools","instanceId":"rudder-voice-rudder-voice","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
var TOKEN_FREE = true;
|
||||
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
|
||||
// rather than this page's profile-config. With no baked token (the public copy), it must get
|
||||
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
|
||||
var NESTS_MAPS = false;
|
||||
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||
var BAKED_TOKEN = "";
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
|
||||
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||
mounted element does not cover shows it through, because every layer above body is
|
||||
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
|
||||
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
|
||||
function done() {
|
||||
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||
document.documentElement.style.background = '#f3f3f1';
|
||||
document.body.style.background = '#f3f3f1';
|
||||
}
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
|
||||
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("rudder-voice");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
|
||||
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
|
||||
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins.
|
||||
// 'auto' = Mapbox when a token is available (a rung above, a host-set window.MAP_TOKEN, or a token
|
||||
// endpoint to ask), otherwise MapLibre, keyless: no token prompt, no request to Mapbox. The
|
||||
// element decides the same way from the token this page hands it, so the two cannot disagree.
|
||||
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
|
||||
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
|
||||
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
|
||||
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
|
||||
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
|
||||
if (TOKEN_FREE) {
|
||||
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
|
||||
mount('');
|
||||
// A token-free composite with no token anywhere: its content renders, but its nested maps
|
||||
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
|
||||
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
|
||||
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'status';
|
||||
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
|
||||
document.body.appendChild(bar);
|
||||
status = bar;
|
||||
askToken();
|
||||
var goBtn = document.getElementById('tkGo');
|
||||
if (goBtn) goBtn.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (v) { location.reload(); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
|
||||
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
|
||||
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -24,6 +24,7 @@ import vue from '@vitejs/plugin-vue'
|
|||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
import { autoSpaDevPlugin } from './internal/plugin-gallery/dev-plugin.mjs'
|
||||
import { stripShadowCssCommentsPlugin } from './internal/vite-plugin-strip-shadow-css-comments.mjs'
|
||||
import { backendDevPlugin, devBackendProxy } from './internal/backend-dev-plugin.mjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { readdirSync, statSync, existsSync } from 'node:fs'
|
||||
|
|
@ -118,7 +119,9 @@ export default defineConfig(({ command, mode }) => {
|
|||
// The dev server listens on THIS MACHINE ONLY. A reachable one serves the source tree, node_modules
|
||||
// and the token baked into /; a remote viewer reaches it through an ssh tunnel (ssh -L), never a public
|
||||
// port. `--host` on the command line still overrides this for a deliberate LAN session.
|
||||
server: { host: '127.0.0.1' },
|
||||
// Local plugin backends (backend/, e.g. rudder-engine) are started by backendDevPlugin and reached
|
||||
// through these same-origin routes, so a plugin calls /api/<name>/… in dev exactly as on desktop.
|
||||
server: { host: '127.0.0.1', proxy: devBackendProxy() },
|
||||
define: {
|
||||
// ★ NO 'unsafe-eval' (CRITIC-4, 2026-09-26). vue-i18n's default message compiler builds each message
|
||||
// with `new Function`, so a partner with a strict Content-Security-Policy saw every translated string
|
||||
|
|
@ -159,6 +162,7 @@ export default defineConfig(({ command, mode }) => {
|
|||
vue({ customElement: true }),
|
||||
cssInjectedByJsPlugin(),
|
||||
autoSpaDevPlugin(env.VITE_MAPBOX_TOKEN || process.env.VITE_MAPBOX_TOKEN || ''),
|
||||
backendDevPlugin(__dirname),
|
||||
],
|
||||
resolve: {
|
||||
alias: { '@map': resolve(__dirname, 'library') },
|
||||
|
|
|
|||
Loading…
Reference in a new issue