open-standards/aac/aac-rs/third_party/moonshine-wasm/vendor.sh
Transaction Science 186c887f72
Some checks failed
standards workspaces build + test / jouleclaw-rs (push) Successful in 1m51s
standards workspaces build + test / aac-rs (push) Successful in 59s
standards workspaces build + test / joule-code-rs (push) Successful in 39s
standards workspaces build + test / joulecontract-rs (push) Successful in 35s
standards workspaces build + test / map-rs (push) Successful in 32s
standards workspaces build + test / mesh-rs (push) Successful in 24s
standards workspaces build + test / openpay (push) Successful in 1m48s
no-vanity-metrics / Scan committed Markdown for vanity metrics (push) Has been cancelled
standards workspaces build + test / arl-rs (push) Has been cancelled
standards workspaces build + test / eoc-rs (push) Has been cancelled
standards workspaces build + test / jcp-rs (push) Has been cancelled
standards workspaces build + test / joule-ui-rs (push) Has been cancelled
standards workspaces build + test / jouledesk-rs (push) Has been cancelled
standards workspaces build + test / joulehook-rs (push) Has been cancelled
standards workspaces build + test / proof-rs (push) Has been cancelled
standards workspaces build + test / sandbox-rs (push) Has been cancelled
standards workspaces build + test / sense-rs (push) Has been cancelled
standards workspaces build + test / smart-byte-rs (push) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (push) Has been cancelled
feat(aac-web): better voices, on the device, in 11 languages
The device's own voice is all most people get, and on many devices in
many languages it is poor or missing. A person can now download a
neural voice for their language, once, and it speaks their words on
the device, offline, with nothing sent anywhere.

- neural_voice.rs, neural_tts.js: the catalogue (aac-voice-index/1:
  runtimes, shared components, per-language packs, each file by size
  and SHA-256, read once per content-named catalogue and kept for
  offline use); downloads into the model store, one at a time, only
  with room for them, verified, and never left half-finished; a record
  of what each voice installed, so it keeps speaking from its own files
  whatever a later catalogue says (a newer version is an update to
  choose), read afresh before any change so another tab of the app
  never deletes a voice it installed; remove keeps what other voices
  need and takes the engine with the last voice.
- Speaking: taps never wait while the device has a voice for the
  language (a voice not ready in 1.2 s gives the words to the device
  voice); with none, the words wait for the downloaded voice rather
  than go unsaid. Words the person moved past (a newer utterance, Stop)
  are never said by either voice, and words no voice could say are
  reported. Sentences are split by the runtime's own splitter, so
  "1.5 mg", "1.000 Euro" and "Dr. Smith" stay whole. The chosen voice
  is loaded when the app opens and after a change of language, speed
  or voice; every call site speaks at the same speed.
- third_party/moonshine-wasm: the Moonshine WASM 0.1.5 runtime (the
  latest), vendored and pinned like MediaPipe, with its worker (one
  voice in memory at a time), revision aac2.
- Settings > Better voices: each voice rated in plain words (sounds
  natural / clear, though less natural / experimental), named in its
  radio button, with its size, licence and credits; downloads that
  outlive the panel and say when they finish; Remove asks first; focus
  stays in place; one quiet live region; plain errors. Settings > Voice
  names the better voice when one is chosen. A language without one
  says so plainly, with the recorded reason one click away.
- tools/voices: the pinned, reproducible pack builder (every source
  pinned by commit, version and SHA-256; byte-identical rebuilds; the
  pointer written last), a smoke test that synthesizes with every pack,
  and check_packs.py, which every static build runs so packs that are
  missing, stale or incomplete never ship (AAC_VOICES=none builds
  without them on purpose). Voices: en, es, fr, pt, it, ja, hi and zh
  (experimental) on Kokoro-82M; de (experimental), nl and uk on Piper.
  Nothing non-commercial or GPL. A voice that says something other than
  the word chosen is withheld: en-piper-norman turns "more" into
  seconds of babble. 32 languages record why they have no voice yet;
  ha and yo are planned.
- data/voices: the committed catalogue and LICENSES.md.
- prerender-static.sh ships the runtime and the checked packs with the
  static app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 18:23:13 -04:00

169 lines
9 KiB
Shell

#!/usr/bin/env bash
# ============================================================================
# vendor.sh — put the on-device voice runtime (Moonshine WASM) on the app's
# own site.
#
# third_party/moonshine-wasm/vendor.sh <site-dir> [cache-dir]
#
# Writes <site-dir>/vendor/moonshine-wasm-<version>-<revision>/ (and removes
# any other vendor/moonshine-wasm-* there):
# moonshine.mjs, moonshine.wasm from @moonshine-ai/moonshine-wasm on npm
# voice-worker.js this directory's worker (the app's own)
# LICENSE.txt, NOTICE.txt this directory's licence and notice
# THIRD_PARTY/ licences of the code linked into the wasm
#
# The app never imports these URLs directly. When someone downloads a neural
# voice, src/neural_voice.rs stores the three runtime files in the model
# store (SHA-256 checked, kept across app updates) and neural_tts.js runs
# them from there, in a worker, offline. The TypeScript layer of the npm
# package is not vendored: it downloads from Moonshine's CDN, which the app
# must never do.
#
# The version and revision come from MOONSHINE_VERSION and
# MOONSHINE_VENDOR_REV in crates/aac-web/src/neural_voice.rs, and each
# runtime file's size and SHA-256 from RUNTIME_FILES there. This script fails
# loudly on any drift: another version asked for than pinned here, an npm
# tarball that is not the pinned one, a vendored file that differs from the
# Rust pin, or a directory whose bytes differ from VENDOR_DIGEST. Changed
# bytes need a new revision, because browsers and the model store treat the
# directory's URLs as immutable.
#
# Idempotent; downloads are cached in cache-dir (default ~/.cache/aac-rs).
# ============================================================================
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AAC="$(cd "$HERE/../.." && pwd)"
SITE="${1:?usage: vendor.sh <site-dir> [cache-dir]}"
CACHE="${2:-${XDG_CACHE_HOME:-$HOME/.cache}/aac-rs}"
NV="$AAC/crates/aac-web/src/neural_voice.rs"
MS_VERSION="$(sed -n 's/^pub const MOONSHINE_VERSION: &str = "\([^"]*\)";$/\1/p' "$NV")"
MS_REV="$(sed -n 's/^pub const MOONSHINE_VENDOR_REV: &str = "\([^"]*\)";$/\1/p' "$NV")"
[ -n "$MS_VERSION" ] || { echo "✗ MOONSHINE_VERSION not found in $NV" >&2; exit 1; }
[ -n "$MS_REV" ] || { echo "✗ MOONSHINE_VENDOR_REV not found in $NV" >&2; exit 1; }
DIR="moonshine-wasm-$MS_VERSION-$MS_REV"
# ─── Pins ────────────────────────────────────────────────────────────────────
# The npm release, by its registry integrity (npm view
# @moonshine-ai/[email protected] dist.integrity, read 2026-09-29).
PINNED_VERSION="0.1.5"
NPM_INTEGRITY="sha512-b90GVFkbuQQthW8wZFU/dvJpnnsISHSSxs/p6Xv/NMfaHotLhRp0e9Risy5/np6QSF3SFust3jGE3Uj2LdecmQ=="
# What vendor/$VENDOR_PINNED holds, as a sha256 over its sorted file list and
# each file's sha256. Re-pin only together with a new MOONSHINE_VENDOR_REV.
VENDOR_PINNED="moonshine-wasm-0.1.5-aac2"
VENDOR_DIGEST="a1102edb2e06645107aa8cb38c2774820f4fad3e9f76c0945ac8600ad3801537"
# Licences of the code linked into moonshine.wasm, from Moonshine's
# core/third-party/ at the commit below and ONNX Runtime's own notices at the
# version Moonshine builds (scripts/ort-build-common.sh: ORT_VERSION 1.23.2).
MS_COMMIT="234f60faa0eb388b01cdf7e60aca232af37aefda"
MS_RAW="https://raw.githubusercontent.com/moonshine-ai/moonshine/$MS_COMMIT/core/third-party"
THIRD_PARTY=(
"onnxruntime-LICENSE.txt|$MS_RAW/onnxruntime/LICENSE.txt|2f07c72751aed99790b8a4869cf2311df85a860b22ded05fa22803587a48922c"
"onnxruntime-ThirdPartyNotices.txt|https://raw.githubusercontent.com/microsoft/onnxruntime/v1.23.2/ThirdPartyNotices.txt|e9e90971a8e75a9a8ac0c6412e29c1202d079998389915aa485f46c816c3b4cc"
"Eigen-COPYING.MPL2|$MS_RAW/Eigen/COPYING.MPL2|fab3dd6bdab226f1c08630b1dd917e11fcb4ec5e1e020e2c16f83a0a13863e85"
"kaldi-native-fbank-LICENSE|$MS_RAW/kaldi-native-fbank/LICENSE|cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
"kissfft-COPYING|$MS_RAW/kissfft/COPYING|a2840585f8411be8e6826a31ef15ae65c950bd74a2437a73b013398a934ad0c6"
"nlohmann-LICENSE.MIT|$MS_RAW/nlohmann/LICENSE.MIT|86b998c792894ccb911a1cb7994f7a9652894e7a094c0b5e45be2f553f45cf14"
"utf-8-LICENSE.txt|$MS_RAW/utf-8/LICENSE.txt|c9bff75738922193e67fa726fa225535870d2aa1059f91452c411736284ad566"
"utf8proc-LICENSE.md|$MS_RAW/utf8proc/LICENSE.md|3b510150d34f248a221bb88e1d811238d6c6c18b51231822c42974c39bb07256"
)
[ "$MS_VERSION" = "$PINNED_VERSION" ] || {
echo "✗ neural_voice.rs asks for Moonshine WASM $MS_VERSION, but vendor.sh is pinned to $PINNED_VERSION —" >&2
echo " check the new release (licence, no GPL, no network use), then re-pin PINNED_VERSION and NPM_INTEGRITY" >&2
exit 1
}
[ "$DIR" = "$VENDOR_PINNED" ] || {
echo "✗ neural_voice.rs asks for $DIR, but vendor.sh is pinned to $VENDOR_PINNED — vendor it, check it, and re-pin VENDOR_PINNED and VENDOR_DIGEST" >&2
exit 1
}
sha256() { shasum -a 256 "$1" | cut -c1-64; }
fsize() { wc -c < "$1" | tr -d ' '; }
echo "▸ vendoring Moonshine WASM $MS_VERSION ($DIR)"
mkdir -p "$CACHE"
# Tell (but do not fail) when npm has a newer release to review.
LATEST="$(npm view @moonshine-ai/moonshine-wasm version 2>/dev/null || true)"
if [ -n "$LATEST" ] && [ "$LATEST" != "$MS_VERSION" ]; then
echo " note: npm's latest @moonshine-ai/moonshine-wasm is $LATEST (pinned: $MS_VERSION)"
fi
# npm package: fetched and checked against the pinned integrity beside the
# cache entry, then moved in whole, so an interrupted run never leaves half a
# package that looks complete.
MS_PKG="$CACHE/moonshine-wasm-$MS_VERSION/package"
if [ ! -f "$MS_PKG/package.json" ]; then
TMP="$(mktemp -d "$CACHE/moonshine-wasm.XXXXXX")"
(cd "$TMP" && npm pack --silent "@moonshine-ai/moonshine-wasm@$MS_VERSION" >/dev/null)
TGZ="$(ls "$TMP"/*.tgz)"
GOT="sha512-$(openssl dgst -sha512 -binary "$TGZ" | base64 | tr -d '\n')"
[ "$GOT" = "$NPM_INTEGRITY" ] || {
rm -rf "$TMP"
echo "✗ @moonshine-ai/moonshine-wasm@$MS_VERSION from npm is not the pinned tarball (got $GOT)" >&2
exit 1
}
(cd "$TMP" && tar xzf "$TGZ")
rm -rf "$CACHE/moonshine-wasm-$MS_VERSION"
mv "$TMP" "$CACHE/moonshine-wasm-$MS_VERSION"
fi
grep -q "\"version\": \"$MS_VERSION\"" "$MS_PKG/package.json" || { echo "✗ cached moonshine-wasm is not $MS_VERSION" >&2; exit 1; }
grep -q '"license": "MIT"' "$MS_PKG/package.json" || { echo "✗ moonshine-wasm $MS_VERSION no longer declares MIT — review it" >&2; exit 1; }
# Third-party licence texts: fetched to a temporary name and moved in only
# once their hash matches.
TP_CACHE="$CACHE/moonshine-wasm-third-party"
mkdir -p "$TP_CACHE"
for entry in "${THIRD_PARTY[@]}"; do
IFS='|' read -r name url want <<< "$entry"
f="$TP_CACHE/$name"
if [ ! -f "$f" ] || [ "$(sha256 "$f")" != "$want" ]; then
rm -f "$f"
curl -fsSL "$url" -o "$f.tmp"
[ "$(sha256 "$f.tmp")" = "$want" ] || { rm -f "$f.tmp"; echo "✗ $name ($url) does not match its pinned sha256" >&2; exit 1; }
mv "$f.tmp" "$f"
fi
done
rm -rf "$SITE"/vendor/moonshine-wasm-*
MS="$SITE/vendor/$DIR"
mkdir -p "$MS/THIRD_PARTY"
cp "$MS_PKG/dist/moonshine.mjs" "$MS_PKG/dist/moonshine.wasm" "$MS/"
cp "$HERE/voice-worker.js" "$HERE/LICENSE.txt" "$HERE/NOTICE.txt" "$MS/"
for entry in "${THIRD_PARTY[@]}"; do
IFS='|' read -r name _ _ <<< "$entry"
cp "$TP_CACHE/$name" "$MS/THIRD_PARTY/$name"
done
# The runtime must never reach another site. (moonshine.mjs only fetches its
# own wasm, same origin; the app hands it the bytes anyway.)
if grep -nE 'https?://' "$MS/moonshine.mjs" "$MS/voice-worker.js"; then
echo "✗ the voice runtime names another site — review it" >&2; exit 1
fi
# Each runtime file must be exactly what neural_voice.rs pins (RUNTIME_FILES):
# the app downloads it by that size and SHA-256.
PINS="$(sed -n 's/^ *RuntimeFile { name: "\([^"]*\)", bytes: \([0-9_]*\), sha256: "\([0-9a-f]*\)" },$/\1 \2 \3/p' "$NV" | tr -d '_')"
[ "$(printf '%s\n' "$PINS" | grep -c .)" = 3 ] || { echo "✗ RUNTIME_FILES in $NV should pin 3 files" >&2; exit 1; }
while read -r name bytes want; do
f="$MS/$name"
[ -s "$f" ] || { echo "✗ vendored runtime is missing $name" >&2; exit 1; }
got_bytes="$(fsize "$f")"; got="$(sha256 "$f")"
if [ "$got_bytes" != "$bytes" ] || [ "$got" != "$want" ]; then
echo "✗ vendor/$DIR/$name is $got_bytes bytes, sha256 $got;" >&2
echo " neural_voice.rs RUNTIME_FILES pins $bytes bytes, sha256 $want." >&2
echo " Changed bytes need a new MOONSHINE_VENDOR_REV (a new URL), then new pins there and here." >&2
exit 1
fi
done <<< "$PINS"
DIGEST="$(cd "$MS" && find . -type f | LC_ALL=C sort | while IFS= read -r f; do printf '%s %s\n' "$f" "$(sha256 "$f")"; done | shasum -a 256 | cut -c1-64)"
[ "$DIGEST" = "$VENDOR_DIGEST" ] || {
echo "✗ vendor/$DIR differs from what was pinned (got $DIGEST)." >&2
echo " Its URLs are cached as immutable: bump MOONSHINE_VENDOR_REV in neural_voice.rs," >&2
echo " then set VENDOR_PINNED and VENDOR_DIGEST in $HERE/vendor.sh." >&2
exit 1
}
echo "✓ vendor/$DIR ($(du -sh "$MS" | cut -f1))"