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:
commit
6e60c3d7db
896 changed files with 29888 additions and 51997 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -145,3 +145,6 @@ data-private/
|
|||
/public/assets/
|
||||
/public/__gallery/
|
||||
/public/index.html
|
||||
|
||||
# Local plugin backends (backend/<name>/) — the compiled binaries are rebuilt from source, never committed.
|
||||
/backend/bin/
|
||||
|
|
|
|||
100
README.md
100
README.md
|
|
@ -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.
|
||||
|
|
|
|||
13
backend/pocketbase/README.md
Normal file
13
backend/pocketbase/README.md
Normal 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.
|
||||
0
backend/pocketbase/pb_migrations/.gitkeep
Normal file
0
backend/pocketbase/pb_migrations/.gitkeep
Normal file
456
backend/rudder-engine/devices.go
Normal file
456
backend/rudder-engine/devices.go
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
// devices.go — microphone discovery, hot-plug, and the sound-server environment.
|
||||
//
|
||||
// The Jitsi Meet pattern (lib-jitsi-meet modules/RTC/RTCUtils.js), done natively because Rudder records
|
||||
// outside the browser:
|
||||
//
|
||||
// Jitsi (browser) Rudder (native, PipeWire/PulseAudio)
|
||||
// navigator.mediaDevices.enumerateDevices() → pactl -f json list sources
|
||||
// 'devicechange' event + polling fallback → pactl subscribe (+ re-list on every source/card event)
|
||||
// compareAvailableMediaDevices(new) → sameDevices(old, new) — only emit on a real change
|
||||
// getUserMedia({audio:{deviceId:{exact:id}}}) → parec --device=<id>
|
||||
//
|
||||
// Jitsi also reports the device list to analytics (sendDeviceListToAnalytics). Rudder never does:
|
||||
// the list goes to the local plugin over SSE and nowhere else.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// audioEnv gives child processes a path to the user's sound server even when the engine was launched
|
||||
// from a context without a desktop session (a detached shell, a service, an agent's terminal). Measured
|
||||
// 2026-09-27: started with no XDG_RUNTIME_DIR, parec exited instantly with "Connection refused" and every
|
||||
// recording captured nothing, although the socket sat at /run/user/<uid>/pulse/native all along.
|
||||
func audioEnv() []string {
|
||||
env := os.Environ()
|
||||
rt := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if rt == "" {
|
||||
rt = fmt.Sprintf("/run/user/%d", os.Getuid())
|
||||
if st, err := os.Stat(rt); err == nil && st.IsDir() {
|
||||
env = append(env, "XDG_RUNTIME_DIR="+rt)
|
||||
}
|
||||
}
|
||||
// systemctl --user (a headset-reset command may restart PipeWire) needs the session bus.
|
||||
if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
|
||||
if _, err := os.Stat(rt + "/bus"); err == nil {
|
||||
env = append(env, "DBUS_SESSION_BUS_ADDRESS=unix:path="+rt+"/bus")
|
||||
}
|
||||
}
|
||||
if os.Getenv("PULSE_SERVER") == "" {
|
||||
if _, err := os.Stat(rt + "/pulse/native"); err == nil {
|
||||
env = append(env, "PULSE_SERVER=unix:"+rt+"/pulse/native")
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// Device is one microphone as the plugin shows it.
|
||||
type Device struct {
|
||||
ID string `json:"id"` // the sound server's source name: what parec --device takes
|
||||
Label string `json:"label"` // human name ("My Headset", "Built-in Audio Analog Stereo")
|
||||
Bus string `json:"bus"` // bluetooth | usb | pci | ...
|
||||
Default bool `json:"default"` // the system's default input
|
||||
Muted bool `json:"muted"`
|
||||
Volume string `json:"volume"` // e.g. "52%"
|
||||
State string `json:"state"` // RUNNING | IDLE | SUSPENDED
|
||||
}
|
||||
|
||||
// DeviceList is the whole picture: the mics, plus sound cards that have a mic but are switched off
|
||||
// (the laptop's built-in card, profile "off", is the common case — its mic simply doesn't exist until
|
||||
// the card is turned on in Sound settings).
|
||||
type DeviceList struct {
|
||||
Devices []Device `json:"devices"`
|
||||
Default string `json:"default"`
|
||||
OffCards []string `json:"offCards"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func runPactl(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("pactl", args...)
|
||||
cmd.Env = audioEnv()
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
func listDevices() DeviceList {
|
||||
out := DeviceList{Devices: []Device{}, OffCards: []string{}}
|
||||
if _, err := exec.LookPath("pactl"); err != nil {
|
||||
out.Error = "pactl not found: install pipewire-pulse or pulseaudio-utils to list microphones"
|
||||
return out
|
||||
}
|
||||
if b, err := runPactl("get-default-source"); err == nil {
|
||||
out.Default = strings.TrimSpace(string(b))
|
||||
}
|
||||
b, err := runPactl("-f", "json", "list", "sources")
|
||||
if err != nil {
|
||||
out.Error = "cannot reach the sound server (pactl list sources failed: " + err.Error() + ")"
|
||||
return out
|
||||
}
|
||||
var raw []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"`
|
||||
Mute bool `json:"mute"`
|
||||
Volume map[string]any `json:"volume"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
out.Error = "unexpected pactl output: " + err.Error()
|
||||
return out
|
||||
}
|
||||
for _, s := range raw {
|
||||
// A ".monitor" source is the loopback of a SPEAKER, not a microphone: listing it would let the
|
||||
// user "record" their own music. Hidden, as Jitsi lists only audioinput kinds.
|
||||
if strings.HasSuffix(s.Name, ".monitor") || s.Properties["device.class"] == "monitor" {
|
||||
continue
|
||||
}
|
||||
d := Device{ID: s.Name, Label: s.Description, State: s.State, Muted: s.Mute, Default: s.Name == out.Default}
|
||||
if bus, ok := s.Properties["device.bus"].(string); ok {
|
||||
d.Bus = bus
|
||||
}
|
||||
for _, ch := range s.Volume { // first channel is representative
|
||||
if m, ok := ch.(map[string]any); ok {
|
||||
if p, ok := m["value_percent"].(string); ok {
|
||||
d.Volume = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Devices = append(out.Devices, d)
|
||||
}
|
||||
// Cards whose active profile is "off" but which offer an input profile.
|
||||
if b, err := runPactl("-f", "json", "list", "cards"); err == nil {
|
||||
var cards []struct {
|
||||
Name string `json:"name"`
|
||||
ActiveProfile string `json:"active_profile"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
Profiles map[string]struct {
|
||||
Sources int `json:"sources"`
|
||||
Available bool `json:"available"`
|
||||
} `json:"profiles"`
|
||||
}
|
||||
if json.Unmarshal(b, &cards) == nil {
|
||||
for _, c := range cards {
|
||||
if c.ActiveProfile != "off" {
|
||||
continue
|
||||
}
|
||||
for _, p := range c.Profiles {
|
||||
if p.Sources > 0 && p.Available {
|
||||
label, _ := c.Properties["device.description"].(string)
|
||||
if label == "" {
|
||||
label = c.Name
|
||||
}
|
||||
out.OffCards = append(out.OffCards, label)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameDevices mirrors Jitsi's compareAvailableMediaDevices: emit only when something a user would
|
||||
// notice changed (a mic came, went, was muted, or the default moved), not on every state flicker.
|
||||
func sameDevices(a, b DeviceList) bool {
|
||||
if a.Default != b.Default || len(a.Devices) != len(b.Devices) || a.Error != b.Error || len(a.OffCards) != len(b.OffCards) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Devices {
|
||||
x, y := a.Devices[i], b.Devices[i]
|
||||
if x.ID != y.ID || x.Label != y.Label || x.Muted != y.Muted || x.Volume != y.Volume {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// deviceWatcher keeps the current list and pushes a "devices" event on every real change. It listens
|
||||
// to `pactl subscribe` (the native 'devicechange') and, like Jitsi, also polls as a fallback in case
|
||||
// the subscription dies.
|
||||
type deviceWatcher struct {
|
||||
mu sync.Mutex
|
||||
last DeviceList
|
||||
hub *hub
|
||||
logs *ring
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) current() DeviceList {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.last
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) refresh(reason string) {
|
||||
now := listDevices()
|
||||
w.mu.Lock()
|
||||
changed := !sameDevices(w.last, now)
|
||||
w.last = now
|
||||
w.mu.Unlock()
|
||||
if changed {
|
||||
w.logs.add("microphones (%s): %d found, default=%s %s", reason, len(now.Devices), now.Default, now.Error)
|
||||
w.hub.send(event{"type": "devices", "devices": now})
|
||||
}
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) run() {
|
||||
w.refresh("startup")
|
||||
// Poll fallback (Jitsi polls enumerateDevices when devicechange is unavailable).
|
||||
go func() {
|
||||
for range time.Tick(5 * time.Second) {
|
||||
w.refresh("poll")
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
cmd := exec.Command("pactl", "subscribe")
|
||||
cmd.Env = audioEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err == nil && cmd.Start() == nil {
|
||||
sc := bufio.NewScanner(out)
|
||||
var debounce *time.Timer
|
||||
for sc.Scan() {
|
||||
l := sc.Text()
|
||||
if strings.Contains(l, " card ") || (strings.Contains(l, " source ") && !strings.Contains(l, "source-output")) {
|
||||
w.logs.add("sound-server event: %s", l)
|
||||
}
|
||||
if strings.Contains(l, " source ") || strings.Contains(l, " card ") || strings.Contains(l, " server ") {
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
debounce = time.AfterFunc(300*time.Millisecond, func() { w.refresh("hot-plug") })
|
||||
}
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
time.Sleep(3 * time.Second) // sound server restarted or unreachable: retry
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// micTest records ~3 s from one device WITHOUT transcribing and says, in plain words, whether the mic
|
||||
// works — Rudder's version of Jitsi's pre-join audio preview. Level events stream to the plugin while
|
||||
// it runs so the meter moves.
|
||||
func (e *engine) micTest(device string) event {
|
||||
cmds := recorderCmds(device)
|
||||
var cmd *exec.Cmd
|
||||
for _, c := range cmds {
|
||||
if _, err := exec.LookPath(c[0]); err == nil {
|
||||
cmd = exec.Command(c[0], c[1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if cmd == nil {
|
||||
return event{"ok": false, "verdict": "No recorder found (need parec, pw-record or arecord)."}
|
||||
}
|
||||
cmd.Env = audioEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
var errBuf strings.Builder
|
||||
cmd.Stderr = &errBuf
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil || cmd.Start() != nil {
|
||||
return event{"ok": false, "verdict": "Could not start the recorder."}
|
||||
}
|
||||
defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
|
||||
|
||||
frame := make([]byte, 3200) // 100 ms
|
||||
peak, frames, zeroFrames := 0, 0, 0
|
||||
var sumSq float64
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := io.ReadFull(out, frame)
|
||||
if err != nil {
|
||||
why := strings.TrimSpace(errBuf.String())
|
||||
e.logs.add("mic test on %q failed after %d frames: %v | %s", device, frames, err, why)
|
||||
return event{"ok": false, "verdict": "The microphone produced no audio. " + why, "recorder": cmd.Path}
|
||||
}
|
||||
fp := 0
|
||||
for i := 0; i+1 < n; i += 2 {
|
||||
v := int(int16(uint16(frame[i]) | uint16(frame[i+1])<<8))
|
||||
sumSq += float64(v * v)
|
||||
if v < 0 {
|
||||
v = -v
|
||||
}
|
||||
if v > fp {
|
||||
fp = v
|
||||
}
|
||||
}
|
||||
if fp == 0 {
|
||||
zeroFrames++
|
||||
}
|
||||
if fp > peak {
|
||||
peak = fp
|
||||
}
|
||||
frames++
|
||||
e.hub.send(event{"type": "level", "peak": float64(fp) / 32768, "test": true})
|
||||
}
|
||||
rms := 0.0
|
||||
if frames > 0 {
|
||||
rms = math.Sqrt(sumSq/float64(frames*1600)) / 32768
|
||||
}
|
||||
res := event{"ok": true, "peak": float64(peak) / 32768, "rms": rms, "frames": frames}
|
||||
switch {
|
||||
case zeroFrames == frames:
|
||||
res["ok"] = false
|
||||
res["verdict"] = "Pure silence — the mic is connected but sends nothing. It may be muted, or a Bluetooth headset's mic link may have dropped (try reconnecting the headset or pick another mic)."
|
||||
case peak < 330: // below about -40 dBFS
|
||||
res["verdict"] = "Very quiet — Rudder can hear the mic, but barely. Speak closer or raise its input volume."
|
||||
default:
|
||||
res["verdict"] = "Working — Rudder can hear you."
|
||||
}
|
||||
e.logs.add("mic test on %q: peak=%d rms=%.4f frames=%d verdict=%s", device, peak, rms, frames, res["verdict"])
|
||||
return res
|
||||
}
|
||||
|
||||
// ─── Optional Bluetooth headset reset ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Some Bluetooth headsets drop the mic side of the HFP link (exact-zero audio while everything looks
|
||||
// connected; the kernel logs `hci0: corrupted SCO packet`). Re-opening the recorder does not revive
|
||||
// it; a full reset (disconnect → restart PipeWire/WirePlumber → reconnect) can. That is machine-specific,
|
||||
// so Rudder only runs one if RUDDER_BT_RESET names an executable that does it (exit 0 = headset works
|
||||
// again). Rate-limited: never more than once per 20 s.
|
||||
func btResetPath() string {
|
||||
p := os.Getenv("RUDDER_BT_RESET")
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() && st.Mode()&0o111 != 0 {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
resetMu sync.Mutex
|
||||
lastReset time.Time
|
||||
)
|
||||
|
||||
// runHeadsetReset runs the RUDDER_BT_RESET command and reports whether it said the headset works again.
|
||||
func runHeadsetReset(logs *ring) (ok bool, ran bool) {
|
||||
path := btResetPath()
|
||||
if path == "" {
|
||||
return false, false
|
||||
}
|
||||
resetMu.Lock()
|
||||
if time.Since(lastReset) < 20*time.Second {
|
||||
resetMu.Unlock()
|
||||
logs.add("headset reset skipped: last one was %.0fs ago (rate limit 20s)", time.Since(lastReset).Seconds())
|
||||
return false, false
|
||||
}
|
||||
lastReset = time.Now()
|
||||
resetMu.Unlock()
|
||||
|
||||
logs.add("running %s (headset reset + CVSD)", path)
|
||||
cmd := exec.Command(path)
|
||||
cmd.Env = audioEnv()
|
||||
done := make(chan struct{})
|
||||
var out []byte
|
||||
var err error
|
||||
go func() { out, err = cmd.CombinedOutput(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
logs.add("headset reset timed out after 60s")
|
||||
return false, true
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if l != "" {
|
||||
logs.add("[headset reset] %s", l)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logs.add("headset reset failed: %v", err)
|
||||
return false, true
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
|
||||
// ─── Bluetooth voice-profile helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// btCard maps a Bluetooth mic (bluez_input.<MAC>.<n>, or "" = the default input) to its card.
|
||||
func btCard(device string) string {
|
||||
if device == "" {
|
||||
if b, err := runPactl("get-default-source"); err == nil {
|
||||
device = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(device, "bluez_input.") {
|
||||
return ""
|
||||
}
|
||||
mac := strings.TrimPrefix(device, "bluez_input.")
|
||||
if i := strings.LastIndex(mac, "."); i > 0 {
|
||||
mac = mac[:i]
|
||||
}
|
||||
return "bluez_card." + mac
|
||||
}
|
||||
|
||||
func currentProfile(card string) string {
|
||||
if card == "" {
|
||||
return "(not bluetooth)"
|
||||
}
|
||||
b, err := runPactl("-f", "json", "list", "cards")
|
||||
if err != nil {
|
||||
return "?"
|
||||
}
|
||||
var cards []struct {
|
||||
Name string `json:"name"`
|
||||
ActiveProfile string `json:"active_profile"`
|
||||
}
|
||||
if json.Unmarshal(b, &cards) != nil {
|
||||
return "?"
|
||||
}
|
||||
for _, c := range cards {
|
||||
if c.Name == card {
|
||||
return c.ActiveProfile
|
||||
}
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// switchProfile forces a fresh voice link: CVSD if the card is on anything else, otherwise a bounce
|
||||
// through mSBC and back to CVSD (a same-profile set is a no-op and would not renegotiate the link).
|
||||
func switchProfile(card string, logs *ring) error {
|
||||
before := currentProfile(card)
|
||||
target := "headset-head-unit-cvsd"
|
||||
if before == target {
|
||||
if _, err := runPactl("set-card-profile", card, "headset-head-unit-msbc"); err != nil {
|
||||
logs.add("profile bounce via msbc failed: %v", err)
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
if out, err := runPactl("set-card-profile", card, target); err != nil {
|
||||
return fmt.Errorf("pactl set-card-profile %s %s: %v %s", card, target, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond) // let the new SCO link and the source come up
|
||||
logs.add("voice profile %s → %s", before, currentProfile(card))
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchKernelBluetooth copies kernel Bluetooth messages into the log while a session runs
|
||||
// (`journalctl -k` is readable without root on this system).
|
||||
func watchKernelBluetooth(ctx context.Context, logs *ring) {
|
||||
cmd := exec.CommandContext(ctx, "journalctl", "-k", "-f", "-n", "0", "-o", "cat")
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil || cmd.Start() != nil {
|
||||
return
|
||||
}
|
||||
sc := bufio.NewScanner(out)
|
||||
for sc.Scan() {
|
||||
if l := sc.Text(); strings.Contains(strings.ToLower(l), "bluetooth") {
|
||||
logs.add("kernel: %s", l)
|
||||
}
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
5
backend/rudder-engine/go.mod
Normal file
5
backend/rudder-engine/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module dx-maps/backend/rudder-engine
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
2
backend/rudder-engine/go.sum
Normal file
2
backend/rudder-engine/go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
907
backend/rudder-engine/main.go
Normal file
907
backend/rudder-engine/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
59
backend/rudder-engine/setup-runtime.sh
Executable file
59
backend/rudder-engine/setup-runtime.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# setup-runtime.sh — fetch Rudder's speech runtime + model, verified by SHA-256, then build the engine.
|
||||
#
|
||||
# Rudder (plugin-designs/rudder-voice) transcribes on THIS computer. It needs two things that are too big
|
||||
# for git: NVIDIA's NeMo-Speech.cpp runtime (~4 MB, CPU build) and the Nemotron 3.5 ASR Streaming GGUF
|
||||
# (~742 MB). Both are pinned by version and checksum; a mismatch aborts instead of running an unverified
|
||||
# file. After this, `bun run dev` starts the engine and Rudder appears in the Plugin Studio.
|
||||
#
|
||||
# bash backend/rudder-engine/setup-runtime.sh # installs into ~/.local/share/rudder
|
||||
# RUDDER_HOME=/some/dir bash backend/rudder-engine/setup-runtime.sh
|
||||
#
|
||||
# Linux x86_64 only for now (the runtime ships other platforms; add them when a desktop build needs them).
|
||||
set -euo pipefail
|
||||
|
||||
RUDDER_HOME="${RUDDER_HOME:-$HOME/.local/share/rudder}"
|
||||
RT_VER="0.1.0"
|
||||
RT_NAME="nemo-speech-${RT_VER}-linux-x86_64-cpu"
|
||||
RT_URL="https://github.com/NVIDIA/NeMo-Speech.cpp/releases/download/v${RT_VER}/${RT_NAME}.tar.gz"
|
||||
RT_SHA="0f74131d631ad2c694cf0ec53490866bb6461147959589a69fb6fc231944065b"
|
||||
# NVIDIA re-converted the GGUF on 2026-09-10 (embedded tokenizer); this revision is the one Rudder is tested with.
|
||||
MODEL_REV="ea30d66debe3"
|
||||
MODEL_FILE="nemotron-3.5-asr-streaming-0.6b.q8_0.gguf"
|
||||
MODEL_URL="https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b/resolve/${MODEL_REV}/${MODEL_FILE}"
|
||||
MODEL_SHA="3fc991d3badad7277c11030a7519832cddaf2057aafed6d4b25147e953a070b1"
|
||||
|
||||
say() { printf '\033[1;34m→\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || die "This script currently supports Linux x86_64 only."
|
||||
command -v curl >/dev/null || die "curl is required."
|
||||
command -v sha256sum >/dev/null || die "sha256sum is required."
|
||||
|
||||
fetch() { # url dest sha
|
||||
local url="$1" dest="$2" sha="$3"
|
||||
if [ -f "$dest" ] && [ "$(sha256sum "$dest" | cut -d' ' -f1)" = "$sha" ]; then
|
||||
say "already have $(basename "$dest") (checksum OK)"; return
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --retry 3 -C - -o "$dest.part" "$url"
|
||||
[ "$(sha256sum "$dest.part" | cut -d' ' -f1)" = "$sha" ] || { rm -f "$dest.part"; die "checksum mismatch for $(basename "$dest") — refusing to use it"; }
|
||||
mv "$dest.part" "$dest"
|
||||
}
|
||||
|
||||
mkdir -p "$RUDDER_HOME/bin" "$RUDDER_HOME/models"
|
||||
fetch "$RT_URL" "$RUDDER_HOME/bin/$RT_NAME.tar.gz" "$RT_SHA"
|
||||
tar -xzf "$RUDDER_HOME/bin/$RT_NAME.tar.gz" -C "$RUDDER_HOME/bin"
|
||||
fetch "$MODEL_URL" "$RUDDER_HOME/models/$MODEL_FILE" "$MODEL_SHA"
|
||||
|
||||
if command -v go >/dev/null; then
|
||||
say "building backend/bin/rudder-engine"
|
||||
(cd "$(dirname "$0")" && go build -o ../bin/rudder-engine .)
|
||||
else
|
||||
printf '! Go not found — install Go, then: cd backend/rudder-engine && go build -o ../bin/rudder-engine .\n'
|
||||
fi
|
||||
|
||||
for tool in parec pactl; do command -v "$tool" >/dev/null || printf '! %s not found — install pipewire-pulse or pulseaudio-utils for microphone capture.\n' "$tool"; done
|
||||
|
||||
say "done. Runtime: $RUDDER_HOME/bin/$RT_NAME Model: $RUDDER_HOME/models/$MODEL_FILE"
|
||||
say "run \`bun run dev\` and open Rudder (rudder-voice) in the Plugin Studio."
|
||||
1
data/glottolog/glottolog-subset.json
Normal file
1
data/glottolog/glottolog-subset.json
Normal file
File diff suppressed because one or more lines are too long
23
data/people-groups/christian-share.json
Normal file
23
data/people-groups/christian-share.json
Normal 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
|
||||
}
|
||||
}
|
||||
50
data/people-groups/scripture-sources.json
Normal file
50
data/people-groups/scripture-sources.json
Normal 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."
|
||||
}
|
||||
1
data/people-groups/zoning-records.json
Normal file
1
data/people-groups/zoning-records.json
Normal file
File diff suppressed because one or more lines are too long
683
data/tables/bridge-language-reach.json
Normal file
683
data/tables/bridge-language-reach.json
Normal 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
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
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
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
1
data/tables/card-03.json
Normal 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
1
data/tables/card-20.json
Normal 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
1
data/tables/card-34.json
Normal 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
1
data/tables/card-48.json
Normal 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
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
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
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
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
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
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
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
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
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
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
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
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
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
Loading…
Reference in a new issue