Merge main after the list-fix landing

package.json and check-universal-props.mjs both conflicted only because
main already carries the work: studio landed check:square-map-controls and
my LOC-ENUS refinement, so main's copies are the right ones and were taken
whole.

I broke package.json first, trying to dedupe programmatically: my rule
matched on indentation + key name, which removed a key that legitimately
appears in BOTH the scripts object and the //scripts-adjacent docs object.
Restored from origin/main rather than hand-repaired. 286 scripts, no
duplicate keys, and every entry of mine present -- they all went out with
commits that have landed.
This commit is contained in:
Doxa Maps 2026-09-27 22:29:20 +00:00
commit 6e60c3d7db
896 changed files with 29888 additions and 51997 deletions

3
.gitignore vendored
View file

@ -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/

100
README.md
View file

@ -124,6 +124,56 @@ and graduates to a named tab when it's ready.
---
## Who it's for
### Vibe coders — who dx-maps was built for
If you build with AI agents, this repo is made for you:
- **One repo, many plugins.** Each plugin is one folder in `plugin-designs/` with its own `src/`,
and they all share one library, so your agents can build many plugins side by side.
- **See every design live.** `bun run dev` opens the Plugin Studio: every plugin in one place,
updating the moment you save.
- **Speak instead of typing.** Rudder, an offline voice-to-text tool, sits as a corner widget in the
Studio: look at a plugin, say what you see, and hand the text to your agent. Nothing leaves your
computer (Linux today).
- **One plugin, several outputs.** The same plugin ships as a web embed or a mobile app, and a plugin
can target the desktop: Rudder is the desktop example, running in the Studio today with its
desktop packaging next.
- **Build together.** [Run it in the cloud](#run-it-in-the-cloud) gives a whole team one shared dev
server.
### The people doing the mission
**Each map answers a real task for someone doing the mission.** Open it in the Research Gallery, or
embed just the one you need.
| You are… | Open | It answers |
|---|---|---|
| a **translator** (or an AI translation pipeline) | Gospel Resources (map, list, data tables) | which people groups have no Scripture, film or audio in their language, and which bridge language already reaches them |
| **media to movements** | Cities with Internet · Gospel × Internet | where unreached people groups live with internet access, country by country and down to province where data exists |
| a **catalyst** or mobilizer | Big Picture · Simple Research · Regions | filter the 2,107 groups by region, religion, language and engagement; **Export CSV** or **Export this selection**; open a group's **Full Profile** |
| **praying** | Prayer Progress · UPG-100 List | where prayer is covered and where it is still needed; print a **Prayer List**, **Poster Map** or **Card Deck** for a list of 100 |
| a **teacher** or presenter | the Research Gallery | every map in one app, in 11 languages, ready to show |
<details>
<summary><b>For AI agents</b></summary>
- **Every map, and how to embed it:** `public/dx-maps/manifest.json` lists each plugin with its
`embed.iframe` and `embed.script` snippet (with its integrity hash).
- **The data behind the maps:** `public/dx-maps/assets/tables/*.json` (gospel access, internet
access, bridge languages, resource links…), `assets/people-groups/*.json` (points and slim
records) and `assets/people-groups/lists/` (the membership of each list).
- **Live people-group data (public, no key):** `https://pray.doxa.life/api/people-groups/list`
and `…/detail/{slug}`, the same endpoints the published maps call.
- **Switches on any map page:** `?lang=` (11 languages), `?provider=maplibre|mapbox`,
`?list=` (a project list such as `700`), `?tk=` (a Mapbox token). Embeds take the same keys in
`profile-config`. Contract: [documentation/reference-universal-props.md](./documentation/reference-universal-props.md).
</details>
---
## The 5 plugin capabilities
A capability is **how much a plugin nests**, from one element (1) to an app of apps (5). Every
@ -438,6 +488,38 @@ bun run tauri:build # builds the app — needs Rust, the Tauri CLI and webvie
---
## Security controls
**Bundle integrity.** Every bundle ships with a SHA-384 hash in `public/dx-maps/manifest.json`
(`bundles[].integrity`), and every copy-paste script snippet (`profiles[].embed.script`) already
uses it:
```html
<script src="https://YOUR_HOST/dx-maps/doxa-simple-map/doxa-simple-map.js"
integrity="sha384-…" crossorigin="anonymous"></script>
```
If the file on the host is changed by anyone, even by one byte, the browser refuses to run it.
`crossorigin="anonymous"` is required for the check to work across sites, so the host must send
`Access-Control-Allow-Origin: *`. Copy the snippet again after each release, because the hash changes
with the bundle. `bun run check:sri` keeps the manifest and the built pages in step.
<details>
<summary><b>The backend in the cloud container (v1, experimental)</b></summary>
- **Locked by default.** A new PocketBase collection starts with admin-only rules; you open it
deliberately, collection by collection.
- **Dashboards hidden.** The production instance exposes only its `/api/`; its admin dashboard is
blocked at the proxy. The development dashboard is open only to the admin group.
- **No server-side code.** PocketBase's JavaScript hooks are switched off in both instances.
- **Not built yet:** per-plugin locks between plugin authors (who may edit which plugin's backend)
and isolated edge functions. Until they exist, everyone with dev access is trusted with every
plugin's backend.
</details>
---
## Languages
**Every map speaks 11 languages** — add `?lang=<code>` to a map's page address, or, when a map is embedded in
@ -616,6 +698,14 @@ The studio is a plugin like any other: `plugin-designs/plugin-studio/plugin-stud
| `bun run gates:pre` · `bun run gates:post` | The checks that keep `main` working |
| `bun run shots` | Retake these screenshots (`ONLY=<name>` for one) |
### Run it in the cloud
**One shared dev server for the whole team, no laptop setup.** A container clones the repo, follows
`main`, and hot-reloads every change an agent lands, so everyone sees the Plugin Studio update in
their browser within seconds, with no rebuild. Add OIDC settings and it puts a "Sign in with *your
provider*" page in front of everything, open only to the groups you allow. Recipe and settings:
[deployment/cloud/](./deployment/cloud/README.md).
---
## Repository layout
@ -663,3 +753,13 @@ doxa-maps/
> **Maps should be open source, run everywhere, and multiply.** One build makes each map a
> self-contained file for your site, your partners' sites and a phone. The Plugin Studio is where
> you compose them into something bigger.
---
## Acknowledgements
dx-maps stands on the work of open-source communities: [Vue](https://vuejs.org), [Vite](https://vite.dev),
[MapLibre GL JS](https://maplibre.org), [Pinia](https://pinia.vuejs.org), [Vue I18n](https://vue-i18n.intlify.dev),
[Capacitor](https://capacitorjs.com), [Tauri](https://tauri.app), [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy),
[NeMo-Speech.cpp](https://github.com/NVIDIA/NeMo-Speech.cpp) with NVIDIA's open [Nemotron speech model](https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b), and the map data of
[OpenStreetMap](https://www.openstreetmap.org/copyright) contributors served by [OpenFreeMap](https://openfreemap.org). Thank you.

View file

@ -0,0 +1,13 @@
# PocketBase — the backend for plugins that need one
The cloud container ([deployment/cloud](../../deployment/cloud/README.md)) runs two PocketBase
instances: **dev** (behind the team sign-in, for building) and **prod** (its `/api/` public, for
published plugins). Both apply the migrations in [`pb_migrations/`](./pb_migrations/) on start, so
**the schema lives in git** and publishing never copies data from dev to prod.
A plugin reads its backend address at runtime (`profile-config` `apiBase`, or
`window.MAP_API_BASE` set by the published build's `runtime-config.js`), so moving the backend never
needs a rebuild.
v1 limits: collection rules are the only access control (lock collections by default); no per-plugin
isolation between plugin authors yet.

View 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()
}

View file

@ -0,0 +1,5 @@
module dx-maps/backend/rudder-engine
go 1.22.2
require github.com/gorilla/websocket v1.5.3

View 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=

View file

@ -0,0 +1,907 @@
// 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()
// RUDDER_HOME is where setup-runtime.sh installed the runtime and model (a server keeps it on a volume).
base := envOr("RUDDER_HOME", 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{
// A browser session belongs to one reader; everyone else sees only that the engine is in use.
"engine": e.state, "error": e.stateErr, "recording": e.recording && !browserAudio(), "inUse": 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),
"audio": map[bool]string{true: "browser", false: "local"}[browserAudio()],
}
}
// 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
}
// browserAudio: RUDDER_AUDIO=browser — the engine runs on a server (the cloud dev server), where there is
// no microphone of the reader's to record. The page captures its own mic and streams 16 kHz s16le PCM to
// /api/rudder/stream; that stream feeds the same pump and speech session as a local recorder.
func browserAudio() bool { return os.Getenv("RUDDER_AUDIO") == "browser" }
func (e *engine) start(lang, device string) error { return e.startWith(lang, device, nil, nil) }
// startWith starts a session. src, when set, is the audio source (a browser stream); nil spawns a local recorder.
func (e *engine) startWith(lang, device string, src *recorderProc, sink func(event)) error {
browser := src != nil
// A session's events go to ITS reader (a browser session's own socket), captured here, so a session
// still draining its last words after Stop never writes into the next reader's session.
emit := func(ev event) {
if sink != nil {
sink(ev)
return
}
e.hub.send(ev)
}
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()
if browser {
return errors.New("busy — someone else is recording on this server; try again when they stop")
}
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); !browser && 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)")
emit(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 {
emit(event{"type": "recovered", "message": "Headset is in its stable voice mode — go ahead and talk."})
}
}
}
first := src
if first == nil {
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()
// Let the final words arrive. A shared server's CPU can run behind real time (measured: a loaded
// 8-core box finished an 11 s clip ~15 s after it ended), so a browser session waits longer.
wait := 4 * time.Second
if browser {
wait = 30 * time.Second
}
select {
case <-done:
case <-time.After(wait):
}
ws.Close()
emit(event{"type": "recording", "recording": false})
}
e.mu.Unlock()
if browser {
e.logs.add("recording started: recorder=browser language=%s", lang)
} else {
e.logs.add("recording started: recorder=%s device=%q language=%s profile=%s", recName, device, lang, currentProfile(btCard(device)))
go watchKernelBluetooth(ctx, e.logs)
}
emit(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 {
emit(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
}
if browser {
e.logs.add("browser audio stream ended: %v", err)
emit(event{"type": "error", "message": "The browser stopped sending microphone audio (the page was closed or lost its connection)."})
go e.stop()
return
}
// 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 + ")"
}
emit(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 && !browser {
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")
emit(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)
emit(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 {
emit(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")
emit(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 {
emit(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.)"
}
emit(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)
emit(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()
emit(event{"type": "level", "peak": float64(peak) / 32768})
}
writeMu.Lock()
err := ws.WriteMessage(websocket.BinaryMessage, frame)
writeMu.Unlock()
if err != nil {
if ctx.Err() == nil {
emit(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 != "" {
emit(event{"type": "partial", "text": text})
}
case strings.HasSuffix(t, "input_audio_transcription.completed"):
emit(event{"type": "final", "text": text, "at": time.Now().Format(time.RFC3339)})
case t == "error":
e.logs.add("speech server error: %s", string(msg))
emit(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
closer io.Closer // a browser source: closing it ends the stream
}
func (r *recorderProc) kill() {
if r != nil && r.closer != nil {
_ = r.closer.Close()
}
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
}
e.logs.add("recording stopping")
stop() // sends "recording false" to the session's own reader once its last words are in
}
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).
//
// allowedHost: RUDDER_ALLOW_HOST (comma-separated) names the public host of a server deployment, whose own
// sign-in (oauth2-proxy) sits in front of the dev server that proxies /api/rudder here. Unset = loopback only.
func allowedHost(host string) bool {
for _, h := range strings.Split(os.Getenv("RUDDER_ALLOW_HOST"), ",") {
if h = strings.TrimSpace(h); h != "" && h == host {
return true
}
}
return false
}
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]" && !allowedHost(host) {
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 browserAudio() {
writeJSON(w, 409, event{"error": "this engine takes the browser's microphone: stream to /api/rudder/stream"})
return
}
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())
})
// /api/rudder/stream (RUDDER_AUDIO=browser): a WebSocket of binary 16 kHz mono s16le PCM frames from the
// page's microphone. Opening it starts a session, closing it stops one; the text comes back on /events.
// The upgrader's default origin check (Origin must match Host) keeps other sites out.
upgrader := websocket.Upgrader{ReadBufferSize: 8192}
mux.HandleFunc("/api/rudder/stream", func(w http.ResponseWriter, r *http.Request) {
if !browserAudio() {
writeJSON(w, 409, event{"error": "this engine records a local microphone (RUDDER_AUDIO is not browser)"})
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
pr, pw := io.Pipe()
src := &recorderProc{out: pr, name: "browser", errBuf: &bytes.Buffer{}, closer: pr}
var wmu sync.Mutex
sink := func(ev event) {
b, _ := json.Marshal(ev)
wmu.Lock()
_ = conn.WriteMessage(websocket.TextMessage, b)
wmu.Unlock()
}
if err := e.startWith(r.URL.Query().Get("language"), "", src, sink); err != nil {
e.logs.add("stream start failed: %v", err)
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(4409, err.Error()))
return
}
for {
t, b, err := conn.ReadMessage()
if err != nil {
break
}
if t != websocket.BinaryMessage {
// {"type":"stop"}: end the session but keep the socket open, so the last words (sent after the
// speech server's commit) still reach this reader; the page closes the socket after "recording false".
if bytes.Contains(b, []byte(`"stop"`)) {
go e.stop()
}
continue
}
if _, err := pw.Write(b); err != nil {
break // the session stopped (its pipe was closed)
}
}
e.stop()
})
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)
}
}

View 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."

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,23 @@
{
"_what": "Evangelical share of the DOXA people groups' POPULATION, for the packet's life-calendar page.",
"_method": "Each group's published EvangelicalLevel BAND is taken at its midpoint (0 for 'No Known Evangelicals'), weighted by the group's Population, and summed. peoplegroups.org publishes a band, never a number, so a percentage requires choosing a value inside each band; the midpoint is stated on the page itself.",
"_join": "IMB CSV ROP3Code + ISOAlpha3 against doxa-groups-snapshot.json rop3 + country — the same join the Vision Card uses (build-lists.mjs:13).",
"sources": {
"imb": "data/people-groups/people_groups.csv",
"doxa": "https://pray.doxa.life/api/people-groups/list",
"fetched": "2026-09-25"
},
"groupsInList": 2107,
"groupsMatched": 2091,
"population": 209079159,
"evangelicalPopulation": 1282178,
"pctEvangelical": 0.6133,
"bands": {
"No Known Evangelicals": 1444,
"Less than 2%": 638,
"2% or Greater but Less than 5%": 6,
"5% or Greater but Less than 10%": 1,
"10% or Greater but Less than 15%": 1,
"50% or Greater but Less than 75%": 1
}
}

View file

@ -0,0 +1,50 @@
{
"_what": "Provenance for the Mark 5:1-20 passage on packet page 8 (prayer point 4), per locale.",
"_rule": "Scripture is NEVER machine-translated and never reproduced from memory. Each text is a published PUBLIC-DOMAIN translation fetched from a citable source that ships licence metadata, stored with the translation's NAME. dx-shepherd END-DXS-MK5GO, 2026-09-27.",
"_verified": "Verse numbering checked against the English (World English Bible): all five extract exactly verses 1-20, verse 1 matches 'they came to the other side of the sea, into the country of the Gadarenes' and verse 20 ends at 'Decapolis ... everyone marveled'.",
"_api": "https://bible.helloao.org/api/<id>/MRK/5.json",
"locales": {
"ar": {
"translationId": "arb_vdv",
"name": "Arabic Van Dyck Bible",
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=arb-vd",
"website": "https://ebible.org/Scriptures/details.php?id=arb-vd",
"sha256": "a7dbde0da2118c438758bca1706ea71c5c2e4cec72eb996463e4c761c9f6bffb",
"verses": "1-20"
},
"ru": {
"translationId": "rus_syn",
"name": "Russian Synodal Bible",
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=russyn",
"website": "https://ebible.org/Scriptures/details.php?id=russyn",
"sha256": "0ae481d3257ba895a57748179fc194012ffbfd500c141f9c3ef042a4db4771c6",
"verses": "1-20"
},
"es": {
"translationId": "spa_r09",
"name": "Reina Valera 1909",
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=spaRV1909",
"website": "https://ebible.org/Scriptures/details.php?id=spaRV1909",
"sha256": "94e154b2e6e56eda1702d9e9f664357a5f2aa82634b551111b0b698d124e97d5",
"verses": "1-20"
},
"de": {
"translationId": "deu_l12",
"name": "German Luther Bible 1912",
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=deu1912",
"website": "https://ebible.org/Scriptures/details.php?id=deu1912",
"sha256": "ab4c66104466c4978b1b2f9dcea944a5921959f5d38e467239e6757399eda6c6",
"verses": "1-20"
},
"fr": {
"translationId": "fra_ost",
"name": "French Ostervald Bible",
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=fra_fob",
"website": "https://ebible.org/Scriptures/details.php?id=fra_fob",
"sha256": "98654be2daa22f07286bd4d835748c144af5801ffbd7190ecc56acc414161a97",
"verses": "1-20"
}
},
"_english": "en uses the World English Bible (public domain), unchanged.",
"_absent": "hi and zh stay ENGLISH and remain in check-packet-strings-parity's DECLARED_ABSENT: PD candidates exist (Hindi 1851 Serampore-lineage; Chinese Union Version 1919) but their licence status varies by jurisdiction and I could not confirm it. pt/it/ro also still English — pt Almeida 1819, it Diodati 1649, ro Cornilescu 1924 are candidates, Cornilescu the least certain."
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,683 @@
{
"meta": {
"title": "One language, how many peoples — the way in, ranked by reach",
"why": "Every other surface answers \"what is the way in for THIS group\". A mobiliser with a budget asks the inverse: if I resource ONE language, how many peoples does it reach? 35 languages are the way in to all 369 groups with nothing of their own.",
"generated": "2026-09-27",
"grain": "one row per (way-in kind, language); a language offered both as a checked bridge and as a computed candidate appears once per kind, because those are different claims",
"groups_with_nothing": 369,
"groups_with_no_way_in": 0,
"a_bridge_is_not_their_own_language": "Every group counted here still has NOTHING in its own language. This table ranks the second-best answer, and the word bridge travels with every row.",
"three_kinds": "CURATED — checked for those groups. CANDIDATE — computed from published CLDR data about what the country speaks, which is a fact about the country and not about these people. SIGNED — SUN, a constructed signed language that has to be learned. Never summed.",
"the_map_link_names_the_peoples": "The link uses ?groups=<slug list>, which filters the map by PEOPLE GROUP, so it shows exactly the groups this row counts. Written first with ?codes=, which filters by language, it drew 55 pins for Mandarin's 36-group row and 57 for Akha's 33 — those extra groups also have nothing but belong to a different row. The map gained the per-group filter rather than the column gaining an excuse.",
"links": "No new link enters the map here. A bridge language's resources are the ones the pin index already carries for that ISO code, already proved by the verification record."
},
"columns": [
{
"title": "Language",
"field": "language",
"type": "string"
},
{
"title": "Code",
"field": "iso639_3",
"type": "string"
},
{
"title": "Kind of way in",
"field": "way_in",
"type": "string"
},
{
"title": "Groups with nothing",
"field": "groups_with_nothing",
"type": "number"
},
{
"title": "People with nothing",
"field": "people_with_nothing",
"type": "number"
},
{
"title": "Countries",
"field": "countries",
"type": "number"
},
{
"title": "Nobody praying",
"field": "nobody_praying",
"type": "number"
},
{
"title": "Largest groups",
"field": "largest",
"type": "string"
},
{
"title": "Scripture in this language",
"field": "scripture_url",
"type": "link"
},
{
"title": "Film in this language",
"field": "film_url",
"type": "link"
},
{
"title": "Training",
"field": "training_url",
"type": "link"
},
{
"title": "Languages they speak",
"field": "their_languages",
"type": "number"
},
{
"title": "Groups the map link shows",
"field": "map_shows_groups",
"type": "number"
},
{
"title": "These peoples on the map",
"field": "map_url",
"type": "link"
}
],
"rows": [
{
"language": "SUN (universal signed language)",
"iso639_3": "",
"way_in": "bridge — a constructed signed language, learned",
"groups_with_nothing": 93,
"people_with_nothing": 17533305,
"countries": 93,
"nobody_praying": 14,
"largest": "Deaf Pakistanis; Deaf Congolese; Deaf Burmese",
"scripture_url": "https://sun.bible/sun/NewTestament",
"film_url": "",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=deaf-afghans,deaf-algerians,deaf-angolans,deaf-armenians,deaf-arubans,deaf-azerbaijanis,deaf-bahrainis,deaf-beninese,deaf-bhutanese,deaf-bissau-guineans,deaf-bosnians,deaf-british-virgin-islanders,deaf-bruneians,deaf-bulgarians,deaf-burmese,deaf-cambodians,deaf-cameroonians,deaf-central-africans,deaf-christmas-islanders,deaf-cocos-islanders,deaf-comorans,deaf-congolese-congo,deaf-congolese-democratic-republic-of-the-congo,deaf-cubans,deaf-curacaoan,deaf-cypriots,deaf-djiboutians,deaf-emiratis,deaf-eritreans,deaf-faroese,deaf-fijians,deaf-french-polynesians,deaf-gabonese,deaf-gambians,deaf-georgians,deaf-gibraltarians,deaf-greeks,deaf-guadeloupeans,deaf-guernsey-islanders,deaf-guineans,deaf-guyanese,deaf-hong-kong,deaf-i-kiribati,deaf-icelanders,deaf-iraqis,deaf-israelis,deaf-ivoirians,deaf-jamaicans,deaf-jersey-islanders,deaf-koreans,deaf-kosovar,deaf-kuwaitis,deaf-laotians,deaf-libyans,deaf-liechtensteiners,deaf-luxembourgers,deaf-macao,deaf-mahorais,deaf-maldivans,deaf-maltese,deaf-marshallese,deaf-mauritians,deaf-micronesians,deaf-monacans,deaf-montserratians,deaf-moroccans,deaf-mozambicans,deaf-new-caledonians,deaf-ni-vanuatu,deaf-norfolk-islanders,deaf-omanis,deaf-pakistanis,deaf-palestinian-arabs,deaf-papua-new-guineans,deaf-qataris,deaf-sahrawis,deaf-saint-barth,deaf-saint-helenians,deaf-sammarinese,deaf-samoan,deaf-santomeans,deaf-saudi-arabians,deaf-solomon-islanders,deaf-somalis,deaf-sri-lankans,deaf-sudanese,deaf-surinamers,deaf-svalbardians,deaf-swazis,deaf-swedes,deaf-tongans,deaf-wallisians,deaf-yemenis",
"their_languages": 69,
"map_shows_groups": 93
},
{
"language": "Arabic, Standard",
"iso639_3": "arb",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 15,
"people_with_nothing": 2366775,
"countries": 7,
"nobody_praying": 2,
"largest": "Hadrami Arabs; Gulf Arabs; Socotran",
"scripture_url": "https://ebible.org/arbnav/",
"film_url": "https://jesusfilm.org/watch/jesus/arabic-modern-standard",
"training_url": "https://zume.training/ar",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=arab-bahraini-sunni,arabic-shihhi,batahira,hadrami-arabs,harasi,hobyot-oman,hobyot-yemen,jerba,jibbali,kumzari,mahra,moroccan-jews,shihuh,siwa-imazighen,socotran",
"their_languages": 13,
"map_shows_groups": 15
},
{
"language": "Chinese, Mandarin",
"iso639_3": "cmn",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 36,
"people_with_nothing": 1562400,
"countries": 1,
"nobody_praying": 26,
"largest": "Huishui Miao; Hongjin Tai; Buryat",
"scripture_url": "https://ebible.org/cmn2006/",
"film_url": "https://jesusfilm.org/watch/jesus/mandarin-taiwan",
"training_url": "https://zume.training/zhcn",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=ai-cham,ainu-china,bit-china,bugan,buryat,cai,eastern-xiangxi-miao,ersu,gao,guaigun,hagei,hongjin-tai,hu,huishui-miao,kucong,lati-china,liang-e,luzu,manyak,minyak,mosuo,naju,namuyi,northern-muji,rao,sanda,sarikoli-tajiks,shixing,tongren-bonan,wenma-zhuang,wunai,wutun,ya,yanghuang,yerong,yunnan-mongol",
"their_languages": 30,
"map_shows_groups": 36
},
{
"language": "Arabic, Sudanese",
"iso639_3": "apd",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 22,
"people_with_nothing": 880000,
"countries": 1,
"nobody_praying": 15,
"largest": "Kimr; Tagale; Dar Fur Daju",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/arabic-sudanese-spoken",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=afitti,binga,dajo,dar-fur-daju,erenga,fungor,kajakaja,kanga,karko,keiga,kimr,kufa-lima,midob,tagale,tagoy,talasa,tingal,tukum,tumale,tumtum,wali,warnang",
"their_languages": 14,
"map_shows_groups": 22
},
{
"language": "Somali",
"iso639_3": "som",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 5,
"people_with_nothing": 508500,
"countries": 1,
"nobody_praying": 1,
"largest": "Garre; Tunni; Jiiddu",
"scripture_url": "https://ebible.org/som/",
"film_url": "https://jesusfilm.org/watch/jesus/somali",
"training_url": "https://zume.training/so",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=dabarre,garre,jiiddu,mushungulu,tunni",
"their_languages": 5,
"map_shows_groups": 5
},
{
"language": "Arabic, Chadian",
"iso639_3": "shu",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 22,
"people_with_nothing": 475825,
"countries": 1,
"nobody_praying": 14,
"largest": "Mubi; Mesmedje; Sungor",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/arabic-chadian-spoken",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=berguid,bernde,bolgo-dugag,bon-gula,boor,fanya,gula,jegu,kajakse,karanga,kendeje,koke,kujarge,majera-chad,marfa,maslam,mesmedje,mogum,mubi,sungor,surbakhal,torom",
"their_languages": 22,
"map_shows_groups": 22
},
{
"language": "French",
"iso639_3": "fra",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 7,
"people_with_nothing": 436050,
"countries": 3,
"nobody_praying": 2,
"largest": "Syenara Senufo; Konkabay; Ndemli",
"scripture_url": "https://ebible.org/fra_fob/",
"film_url": "https://jesusfilm.org/watch/jesus/french",
"training_url": "https://zume.training/fr",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=bangi-me,dek,jina,konkabay,majera-cameroon,ndemli,syenara-senufo",
"their_languages": 7,
"map_shows_groups": 7
},
{
"language": "Indonesian",
"iso639_3": "ind",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 28,
"people_with_nothing": 358825,
"countries": 4,
"nobody_praying": 11,
"largest": "Kedayan; Geser Gorom; Hitu",
"scripture_url": "https://ebible.org/ind/",
"film_url": "https://jesusfilm.org/watch/jesus/indonesian-yesus",
"training_url": "https://zume.training/id",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=abai-sungai,ambelau,asilulu,balaesan,bati,bisayan-tutong,bonerate,cocos-malays,dondo,gane,geser-gorom,gorap,hitu,kedayan,kensiu,kiput,komfana,komodo,lahanan,larike-wakasihu,latu,lola,luhu,mairiri,mendriq,seit-kaitetu,tutung,ujir",
"their_languages": 28,
"map_shows_groups": 28
},
{
"language": "Swahili",
"iso639_3": "swh",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 6,
"people_with_nothing": 356900,
"countries": 3,
"nobody_praying": 0,
"largest": "Sagara; Nubi; Tagbo",
"scripture_url": "https://ebible.org/swh1850/",
"film_url": "https://jesusfilm.org/watch/jesus/swahili-kenya",
"training_url": "https://zume.training/swa",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=kami,ndunga,nubi,sagara,tagbo,yulu-binga",
"their_languages": 6,
"map_shows_groups": 6
},
{
"language": "Akha",
"iso639_3": "ahk",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 33,
"people_with_nothing": 296085,
"countries": 1,
"nobody_praying": 25,
"largest": "Tai Khang; Phunoi; Pong",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/akha",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=bit-laos,bo,chut-laos,halang-doan,hung,katu-adek,katu-dark-kang,katu-hanna,katu-triew,khua,khuen,kuan,laoseng,mlabri,o-du,phongku,phongset,phunoi-laos,pong,pong-phen,pong-poong,pong-puang,pong-seng,sida,sila-laos,tai-he,tai-khang,tai-laan,tai-men,tayten,thai-ae,thai-toum,tong",
"their_languages": 20,
"map_shows_groups": 33
},
{
"language": "Arabic, Libyan",
"iso639_3": "ayl",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 2,
"people_with_nothing": 235500,
"countries": 1,
"nobody_praying": 0,
"largest": "Nefusa Imazighen; Zuara",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/arabic-libyan",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=nefusa-imazighen,zuara",
"their_languages": 1,
"map_shows_groups": 2
},
{
"language": "Russian",
"iso639_3": "rus",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 9,
"people_with_nothing": 116800,
"countries": 1,
"nobody_praying": 1,
"largest": "Andi; Kaitag; Chamalal",
"scripture_url": "https://ebible.org/russyn/",
"film_url": "https://jesusfilm.org/watch/jesus/russian",
"training_url": "https://zume.training/ru",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=akhwakh,andi,bagvalal,botlikh,chamalal,kaitag,karata,khwarshi,tindi",
"their_languages": 9,
"map_shows_groups": 9
},
{
"language": "Tamazight, Central Atlas",
"iso639_3": "tzm",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 2,
"people_with_nothing": 111800,
"countries": 1,
"nobody_praying": 0,
"largest": "Chenoua; Belbali",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/tamazight-central-atlas",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=belbali,chenoua",
"their_languages": 2,
"map_shows_groups": 2
},
{
"language": "Japanese",
"iso639_3": "jpn",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 5,
"people_with_nothing": 111100,
"countries": 1,
"nobody_praying": 0,
"largest": "Yaeyama; Toku-no-shima; Oki-No-Erabu",
"scripture_url": "https://ebible.org/jpn1965/",
"film_url": "https://jesusfilm.org/watch/jesus/japanese",
"training_url": "https://zume.training/ja",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=oki-no-erabu,toku-no-shima,yaeyama,yonaguni,yoron",
"their_languages": 5,
"map_shows_groups": 5
},
{
"language": "Ale",
"iso639_3": "gwd",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 4,
"people_with_nothing": 94900,
"countries": 1,
"nobody_praying": 2,
"largest": "Begi-Mao; Seze; Opuua Shita",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/ale-gawwada",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=begi-mao,opuua-shita,seze,shabo",
"their_languages": 4,
"map_shows_groups": 4
},
{
"language": "Shimaore",
"iso639_3": "swb",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 87000,
"countries": 1,
"nobody_praying": 1,
"largest": "Bushi",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/shimaore",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=bushi",
"their_languages": 1,
"map_shows_groups": 1
},
{
"language": "Tigrinya, Eritrea",
"iso639_3": "tir",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 73500,
"countries": 1,
"nobody_praying": 0,
"largest": "Nara",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/tigrinya-eritrea",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=nara",
"their_languages": 1,
"map_shows_groups": 1
},
{
"language": "Moore",
"iso639_3": "mos",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 2,
"people_with_nothing": 72100,
"countries": 1,
"nobody_praying": 0,
"largest": "Senara Senufo; Khe",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/moore",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=khe,senara-senufo",
"their_languages": 2,
"map_shows_groups": 2
},
{
"language": "Hausa",
"iso639_3": "hau",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 72000,
"countries": 1,
"nobody_praying": 0,
"largest": "Tagdal",
"scripture_url": "https://ebible.org/hausa/",
"film_url": "https://jesusfilm.org/watch/jesus/hausa",
"training_url": "https://zume.training/ha",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=tagdal",
"their_languages": 1,
"map_shows_groups": 1
},
{
"language": "Urdu",
"iso639_3": "urd",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 5,
"people_with_nothing": 71375,
"countries": 1,
"nobody_praying": 0,
"largest": "Bateri; Damel; Kalkoti",
"scripture_url": "https://ebible.org/urd/",
"film_url": "https://jesusfilm.org/watch/jesus/urdu",
"training_url": "https://zume.training/ur",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=bateri,damel,gowro,kalkoti,ushojo",
"their_languages": 5,
"map_shows_groups": 5
},
{
"language": "Bemba",
"iso639_3": "bem",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 69000,
"countries": 1,
"nobody_praying": 0,
"largest": "Totela",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/bemba",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=totela",
"their_languages": 1,
"map_shows_groups": 1
},
{
"language": "Persian, Iranian",
"iso639_3": "pes",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 3,
"people_with_nothing": 61400,
"countries": 1,
"nobody_praying": 1,
"largest": "Northern Tati; Soi; Bashkardi",
"scripture_url": "https://ebible.org/pesopcb/",
"film_url": "https://jesusfilm.org/watch/jesus/farsi-western",
"training_url": "https://zume.training/fa",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=bashkardi,northern-tati,soi",
"their_languages": 3,
"map_shows_groups": 3
},
{
"language": "Hausa",
"iso639_3": "hau",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 6,
"people_with_nothing": 57800,
"countries": 1,
"nobody_praying": 4,
"largest": "Maha; Koro Zuba; Ibuoro",
"scripture_url": "https://ebible.org/hausa/",
"film_url": "https://jesusfilm.org/watch/jesus/hausa",
"training_url": "https://zume.training/ha",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=gwa,ibuoro,jimi,koro-zuba,maha,tsagu",
"their_languages": 6,
"map_shows_groups": 6
},
{
"language": "Vietnamese",
"iso639_3": "vie",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 7,
"people_with_nothing": 55100,
"countries": 1,
"nobody_praying": 1,
"largest": "Tsun-Lao; Lati; Phula",
"scripture_url": "https://ebible.org/vie1934/",
"film_url": "https://jesusfilm.org/watch/jesus/vietnamese",
"training_url": "https://zume.training/vi",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=chut-vietnam,laha,lati-vietnam,phula,pu-peo,sila-vietnam,tsun-lao",
"their_languages": 7,
"map_shows_groups": 7
},
{
"language": "English",
"iso639_3": "eng",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 4,
"people_with_nothing": 44000,
"countries": 2,
"nobody_praying": 0,
"largest": "Wandya; Banda-Mbres; Bari Bai",
"scripture_url": "https://ebible.org/engamp/",
"film_url": "https://jesusfilm.org/watch/jesus/english",
"training_url": "https://zume.training/en",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=banda-mbres,bari-bai,ngalgulgule,wandya",
"their_languages": 4,
"map_shows_groups": 4
},
{
"language": "Portuguese",
"iso639_3": "por",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 28000,
"countries": 1,
"nobody_praying": 0,
"largest": "Nyengo",
"scripture_url": "https://ebible.org/porblt/",
"film_url": "https://jesusfilm.org/watch/jesus/portuguese-portugal",
"training_url": "https://zume.training/pt",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=nyengo",
"their_languages": 1,
"map_shows_groups": 1
},
{
"language": "Dzongkha",
"iso639_3": "dzo",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 6,
"people_with_nothing": 20725,
"countries": 1,
"nobody_praying": 1,
"largest": "Nyenpa; Gongduk; Chali",
"scripture_url": "",
"film_url": "https://jesusfilm.org/watch/jesus/dzongkha",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=brokkat,chali,gongduk,lunape,nyenpa,ole",
"their_languages": 6,
"map_shows_groups": 6
},
{
"language": "Telugu",
"iso639_3": "tel",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 8,
"people_with_nothing": 16460,
"countries": 1,
"nobody_praying": 6,
"largest": "Malai Pandaram; Jadupetia; Koiren",
"scripture_url": "https://ebible.org/tel2010/",
"film_url": "https://jesusfilm.org/watch/jesus/telugu",
"training_url": "https://zume.training/te",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=jadupetia,jarawas,khamba,koiren,malai-pandaram,na,onge,sentinelese",
"their_languages": 8,
"map_shows_groups": 8
},
{
"language": "Thai",
"iso639_3": "tha",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 14000,
"countries": 1,
"nobody_praying": 0,
"largest": "Phunoi",
"scripture_url": "https://ebible.org/thaKJV/",
"film_url": "https://jesusfilm.org/watch/jesus/thai",
"training_url": "https://zume.training/th",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=phunoi-thailand",
"their_languages": 1,
"map_shows_groups": 1
},
{
"language": "Nepali",
"iso639_3": "npi",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 3,
"people_with_nothing": 13800,
"countries": 1,
"nobody_praying": 3,
"largest": "Tseku; Gyasumdoba; Pahari Nagarkotis",
"scripture_url": "https://ebible.org/npi2010/",
"film_url": "https://jesusfilm.org/watch/jesus/nepali",
"training_url": "https://zume.training/ne",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=gyasumdoba,pahari-nagarkotis,tseku",
"their_languages": 3,
"map_shows_groups": 3
},
{
"language": "Hebrew (modern)",
"iso639_3": "heb",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 13000,
"countries": 1,
"nobody_praying": 0,
"largest": "Jew, Kurdistani",
"scripture_url": "https://ebible.org/heb/",
"film_url": "https://jesusfilm.org/watch/jesus/hebrew",
"training_url": "",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=kurdistani-jews",
"their_languages": 1,
"map_shows_groups": 1
},
{
"language": "Burmese",
"iso639_3": "mya",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 5600,
"countries": 1,
"nobody_praying": 1,
"largest": "Danau",
"scripture_url": "https://ebible.org/myajvb/",
"film_url": "https://jesusfilm.org/watch/jesus/burmese-common",
"training_url": "https://zume.training/my",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=danau",
"their_languages": 1,
"map_shows_groups": 1
},
{
"language": "Portuguese",
"iso639_3": "por",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 23,
"people_with_nothing": 4795,
"countries": 1,
"nobody_praying": 16,
"largest": "Waimiri-Atroari; Enawenê-Nawê; Korubo",
"scripture_url": "https://ebible.org/porblt/",
"film_url": "https://jesusfilm.org/watch/jesus/portuguese-portugal",
"training_url": "https://zume.training/pt",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=amundava,arawete,asurini-xingu,ava-canoeiro,enawen-naw,korubo,morerebi,sapanawas,suruwaha,txikao,uncontacted-of-bararati,uncontacted-of-cumina,uncontacted-of-madeirinha,uncontacted-of-mapuera,uncontacted-of-parauari,uncontacted-of-rio-liberdade,uncontacted-of-rio-tapirape,uncontacted-of-serra-do-taquaral,uncontacted-of-teles-pires,uraparaquara,uru-pa-in,waimiri-atroari,zoe",
"their_languages": 14,
"map_shows_groups": 23
},
{
"language": "Spanish",
"iso639_3": "spa",
"way_in": "bridge — checked for these groups",
"groups_with_nothing": 4,
"people_with_nothing": 2475,
"countries": 1,
"nobody_praying": 0,
"largest": "Macaguan; Eastern Tunebo; Cabiyari",
"scripture_url": "https://ebible.org/spabes/",
"film_url": "https://jesusfilm.org/watch/jesus/spanish-latin-american",
"training_url": "https://zume.training/es",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=cabiyari,carabayo,eastern-tunebo,macaguan",
"their_languages": 4,
"map_shows_groups": 4
},
{
"language": "Spanish",
"iso639_3": "spa",
"way_in": "bridge — computed from what the country speaks",
"groups_with_nothing": 1,
"people_with_nothing": 2100,
"countries": 1,
"nobody_praying": 0,
"largest": "Huachipaire",
"scripture_url": "https://ebible.org/spabes/",
"film_url": "https://jesusfilm.org/watch/jesus/spanish-latin-american",
"training_url": "https://zume.training/es",
"still_nothing_of_their_own": true,
"map_url": "../resources-map/index.html?groups=huachipaire",
"their_languages": 1,
"map_shows_groups": 1
}
]
}

1
data/tables/card-00.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-01.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-02.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-03.json Normal file
View file

@ -0,0 +1 @@
{"ani":{"row":["Ani",23.755117,103.52517,"CHN","yix",5100,1,"ani"],"c":{"l":"Axi","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/yix","n":"Axi"}},"country":"People's Republic of China","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/ani?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/yix":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/4983?language=Axi)","at":"2026-09-24T18:32:59.196Z"}},"sun":null},"bhatu-andamani":{"row":["Bhatu (Andamani)",11.623377,92.726483,"IND","hca",400,1,"bhatu-andamani"],"c":{"l":"Andaman Hindi Creole","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/hca","n":"Andaman Hindi Creole"}},"country":"India","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/bhatu-andamani?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/hca":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/19048?language=Andaman%20Hindi%20Creole)","at":"2026-09-24T19:37:20.449Z"}},"sun":null},"kalmyk-oirats":{"row":["Kalmyk-Oirats",48.584897,95.431934,"MNG","xal",189000,2,"kalmyk-oirats"],"c":{"l":"Kalmyk-Oirat","f":"https://jesusfilm.org/watch/jesus/kalmyk-oirat","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/XALIBT","n":"Institute for Bible Translation, Moscow","ln":"Kalmyk-Oirat","m":"audio, text, video","vsc":"36 parts, Эклц to АПОКАЛИПСИС"},"sx":{"u":"https://live.bible.is/bible/XALIBT","n":"Institute for Bible Translation, Moscow","src":"Faith Comes By Hearing","parts":36}},"country":"Mongolia","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/kalmyk-oirats?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/kalmyk-oirat":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/kalmyk-oirat.html)","at":"2026-09-24T12:25:45.542Z"},"https://live.bible.is/bible/XALIBT":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:44.855Z"}},"sun":null},"kasseng":{"row":["Kasseng",15.25616,107.35139,"LAO","tdf",1200,1,"kasseng"],"c":{"l":"Talieng","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/tdf","n":"Talieng"}},"country":"Lao People's Democratic Republic","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/kasseng?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/tdf":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/17109?language=Talieng)","at":"2026-09-24T20:15:38.099Z"}},"sun":null}}

1
data/tables/card-04.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-05.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-06.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-07.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-08.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-09.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-0a.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-0b.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-0c.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-0d.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-0e.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-0f.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-10.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-11.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-12.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-13.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-14.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-15.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-16.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-17.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-18.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-19.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-1a.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-1b.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-1c.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-1d.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-1e.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-1f.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-20.json Normal file
View file

@ -0,0 +1 @@
{"deaf-hong-kong":{"row":["Deaf of Hong Kong",22.27819894,114.16930265,"HKG","hks",7900,0,"deaf-hong-kong"],"c":{"l":"Hong Kong Sign Language","ck":"arclight (14 sign languages, all captured), fcbh (none)","un":"deaf_bible_society, door_international — both key-gated","sg":1},"country":"Hong Kong","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/deaf-hong-kong?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{},"sun":{"u":"https://sun.bible/sun/NewTestament","t":"SUN New Testament"}},"kono":{"row":["Kono",7.7533,-8.8226,"GIN","knu",121000,1,"kono"],"c":{"l":"Kono (Guinea)","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/knu","n":"Kono"}},"country":"Guinea","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/kono?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/knu":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/2530?language=Kono)","at":"2026-09-24T19:37:21.234Z"}},"sun":null},"male":{"row":["Male",5.8713,36.8208,"ETH","mdy",158000,2,"male"],"c":{"l":"Male","s":"https://ebible.org/mdybse/","sv":"Maale NT","sf":0,"ss":"New Testament","f":"https://jesusfilm.org/watch/jesus/male","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/MDYBSE","n":"2007 Bible Society of Ethiopia","ln":"Maale","m":"audio, text, video","vsc":"66 parts, ማዢንቲ ማፃኣፔ to ዮሃኒሴም ፆኦሲ ዻዌ ባኮ"},"cr":{"o":"Bible Society of Ethiopia","y":"2007"}},"country":"Ethiopia","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/male?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://ebible.org/mdybse/":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T12:25:41.839Z"},"https://jesusfilm.org/watch/jesus/male":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/male.html)","at":"2026-09-24T12:25:42.498Z"},"https://live.bible.is/bible/MDYBSE":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:42.572Z"}},"sun":null},"sandhai-muslims":{"row":["Sandhai Muslims",21.523339,70.45605,"IND","kfr",1500,1,"sandhai-muslims"],"c":{"d":[{"s":"Open Bible Stories","u":"https://git.door43.org/OBS-TLF/kfr_obs"}],"l":"Kacchi","f":"https://jesusfilm.org/watch/jesus/kachchhi","o":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)"},"country":"India","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/sandhai-muslims?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/kachchhi":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/kachchhi.html)","at":"2026-09-24T12:25:49.907Z"},"https://git.door43.org/OBS-TLF/kfr_obs":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T12:25:49.526Z"}},"sun":null}}

1
data/tables/card-21.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-22.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-23.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-24.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-25.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-26.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-27.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-28.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-29.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-2a.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-2b.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-2c.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-2d.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-2e.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-2f.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-30.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-31.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-32.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-33.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-34.json Normal file
View file

@ -0,0 +1 @@
{"lati-vietnam":{"row":["Lati",22.80149,104.85041,"VNM","lbt",13000,0,"lati-vietnam"],"c":{"l":"Lachi","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion"},"country":"Vietnam","bridge":null,"bridge_candidate":{"l":"Vietnamese","c":"vie","pct":86,"official":true,"src":"cldr"},"pray_url":"https://pray.doxa.life/lati-vietnam?source=doxalife","_cardRes":{"bridge":null,"candidate":{"iso":"vie","language":"Vietnamese","credit":null,"scripture_url":"https://ebible.org/vie1934/","scripture_title":"Vietnamese Bible 1923","film_url":"https://jesusfilm.org/watch/jesus/vietnamese","film_title":"","zume_url":"https://zume.training/vi"},"alt":null},"verified":{},"sun":null},"nyong":{"row":["Nyong",18.4174,98.9743,"THA","yno",14000,1,"nyong"],"c":{"l":"Yong","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/yno","n":"Yong"}},"country":"Thailand","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/nyong?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/yno":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/18600?language=Yong)","at":"2026-09-24T22:03:36.784Z"}},"sun":null},"saba":{"row":["Saba",11.30721,18.49058,"TCD","saa",3300,1,"saba"],"c":{"l":"Saba","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/saa","n":"Saba"}},"country":"Chad","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/saba?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/saa":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/3108?language=Saba)","at":"2026-09-24T23:45:40.178Z"}},"sun":null},"tswa":{"row":["Tswa",-21.0315,31.4419,"ZWE","tsc",196000,1,"tswa"],"c":{"l":"Tswa","f":"https://jesusfilm.org/watch/jesus/tswa","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/TSCBSM","n":"1972 Bible Society of Mozambique","ln":"Xitshwa","m":"audio, video","vsc":"27 parts, S Mateus to Apocalipse"}},"country":"Zimbabwe","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/tswa?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/tswa":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/tswa.html)","at":"2026-09-24T12:25:44.210Z"},"https://live.bible.is/bible/TSCBSM":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:43.468Z"}},"sun":null}}

1
data/tables/card-35.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-36.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-37.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-38.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-39.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-3a.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-3b.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-3c.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-3d.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-3e.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-3f.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-40.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-41.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-42.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-43.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-44.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-45.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-46.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-47.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-48.json Normal file
View file

@ -0,0 +1 @@
{"bashkardi":{"row":["Bashkardi",25.7053,58.8715,"IRN","bsg",7900,0,"bashkardi"],"c":{"l":"Bashkardi","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion"},"country":"Islamic Republic of Iran","bridge":{"l":"Persian, Iranian","c":"pes","st":"moderate","n":5,"s":true,"f":true},"bridge_candidate":null,"pray_url":"https://pray.doxa.life/bashkardi?source=doxalife","_cardRes":{"bridge":{"iso":"pes","language":"Persian, Iranian","credit":{"o":"Biblica, Inc","y":"2022","u":"https://www.biblica.com/","g":"biblica"},"scripture_url":"https://ebible.org/pesopcb/","scripture_title":"Open Persian Contemporary Bible","film_url":"https://jesusfilm.org/watch/jesus/farsi-western","film_title":"","zume_url":"https://zume.training/fa"},"candidate":null,"alt":null},"verified":{},"sun":null},"bororo":{"row":["Bororo",-16.833333,-56.133333,"BRA","bor",2400,2,"bororo"],"c":{"l":"Borôro","s":"https://ebible.org/bor/","sv":"Borôro: Pao Kurireu Bataru Kurireu; O Novo Testamento (New Testament)","sf":0,"ss":"New Testament","f":"https://jesusfilm.org/watch/jesus/bororo","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/BORWYI","n":"Bororo - Wycliffe Bible Translators, Inc.","ln":"Bororo","m":"audio, text, video","vsc":"27 parts, Mateus to Boe Oto Padure Kejeboe Reo"},"se":{"u":"https://scriptureearth.org/data/bor/","k":["PDF","sab"]},"cr":{"o":"Wycliffe Bible Translators, Inc","y":"2020","u":"https://www.wycliffe.org/","g":"wycliffe"}},"country":"Brazil","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/bororo?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://ebible.org/bor/":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T12:24:57.729Z"},"https://jesusfilm.org/watch/jesus/bororo":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/bororo.html)","at":"2026-09-24T12:24:58.501Z"},"https://live.bible.is/bible/BORWYI":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:24:57.619Z"},"https://scriptureearth.org/data/bor/":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T17:38:04.642Z"}},"sun":null},"damami":{"row":["Damami",26.451475,74.63615,"IND","mtr",5900,2,"damami"],"c":{"d":[{"s":"Open Bible Stories","u":"https://git.door43.org/OBS-TLF/mtr_obs"}],"l":"Mewari","f":"https://jesusfilm.org/watch/jesus/mewari","a":1,"o":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/MTRNLC","n":"New Life Computer Institute","ln":"Mewari","m":"audio, text, video","vsc":"27 parts, मत्ती to परकासितवाक्य"},"sx":{"u":"https://live.bible.is/bible/MTRNLC","n":"New Life Computer Institute","src":"Faith Comes By Hearing","parts":27}},"country":"India","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/damami?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/mewari":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/mewari.html)","at":"2026-09-24T12:25:49.581Z"},"https://live.bible.is/bible/MTRNLC":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:48.240Z"},"https://git.door43.org/OBS-TLF/mtr_obs":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T12:25:48.373Z"}},"sun":null},"pangwal-buddhists":{"row":["Pangwal Buddhists",33.15328,76.49206,"IND","pgg",2100,1,"pangwal-buddhists"],"c":{"l":"Pangwali","f":"https://jesusfilm.org/watch/jesus/pangwali","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/PGGWIN","n":"Pangwali - Wycliffe India Version","ln":"Pangwali","m":"audio, video","vsc":"27 parts, Matthew to Revelation"}},"country":"India","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/pangwal-buddhists?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/pangwali":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/pangwali.html)","at":"2026-09-24T12:25:19.162Z"},"https://live.bible.is/bible/PGGWIN":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:18.519Z"}},"sun":null}}

1
data/tables/card-49.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-4a.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-4b.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-4c.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-4d.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-4e.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-4f.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-50.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-51.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-52.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-53.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-54.json Normal file

File diff suppressed because one or more lines are too long

1
data/tables/card-55.json Normal file

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more