@gjsify/spiel-native 0.0.0-stage → 0.64.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -2
- package/lib/esm/_virtual/_rolldown/runtime.js +1 -0
- package/lib/esm/index.js +1 -0
- package/lib/esm/namespace.js +0 -0
- package/lib/types/index.d.ts +58 -0
- package/lib/types/namespace.d.ts +85 -0
- package/meson.build +208 -0
- package/package.json +91 -5
- package/src/c/spiel.map +17 -0
- package/src/ts/index.ts +285 -0
- package/src/ts/namespace.ts +110 -0
- package/subprojects/libspeechprovider.wrap +23 -0
- package/subprojects/libspiel.wrap +19 -0
- package/test/check-elf-deps.py +74 -0
- package/test/load-typelib.gjs.js +59 -0
- package/test/speak-fakesink.gjs.js +83 -0
package/README.md
CHANGED
|
@@ -1,3 +1,187 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gjsify/spiel-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[libspiel](https://github.com/project-spiel/libspiel), the GNOME **Spiel**
|
|
4
|
+
speech-synthesis client library, built as one relocatable shared library plus its
|
|
5
|
+
GObject-Introspection typelib — so `gi://Spiel?version=1.0` can be imported on a host
|
|
6
|
+
that has no libspiel installed.
|
|
7
|
+
|
|
8
|
+
This is the backend of [`@gjsify/speech-synthesis`](../speech-synthesis/README.md). It
|
|
9
|
+
contains no speech API of its own.
|
|
10
|
+
|
|
11
|
+
## What it is
|
|
12
|
+
|
|
13
|
+
Spiel is a **D-Bus protocol**, not an engine. A client discovers *speech providers* by
|
|
14
|
+
their well-known names on the session bus, asks them to synthesise, and plays the audio
|
|
15
|
+
they stream back through GStreamer. libspiel is the client half; the voices live in
|
|
16
|
+
separate processes (`ai.piper.Speech.Provider`, `org.espeak.Speech.Provider`, …).
|
|
17
|
+
|
|
18
|
+
| ships | from |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `libspiel-1.0.so` + `Spiel-1.0.typelib`, per target, in `@gjsify/spiel-native-<os>-<arch>` | this package's meson build |
|
|
21
|
+
| `probeSpiel()` / `requireSpiel()` | `src/ts/index.ts` |
|
|
22
|
+
| hand-written `Spiel-1.0` types | `src/ts/namespace.ts` — there is no `@girs/spiel-1.0` on npm |
|
|
23
|
+
|
|
24
|
+
Linux only, and that is not a gap to fill later: macOS and Windows have no session bus
|
|
25
|
+
and no provider implementation, so a prebuild there would load and then find nothing,
|
|
26
|
+
permanently.
|
|
27
|
+
|
|
28
|
+
## The API
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { probeSpiel, requireSpiel } from '@gjsify/spiel-native';
|
|
32
|
+
|
|
33
|
+
const probe = probeSpiel();
|
|
34
|
+
// probe.source 'system' | 'prebuild' | 'absent'
|
|
35
|
+
// probe.module the Spiel namespace, or null exactly when absent
|
|
36
|
+
// probe.typelibDir where the typelib was loaded from, or null
|
|
37
|
+
// probe.reason one sentence naming what is missing, or null
|
|
38
|
+
// probe.error the underlying Error, for a caller that logs a cause
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`probeSpiel()` is memoised and **never throws** — absent is an answer, not a fault, and a
|
|
42
|
+
caller deciding how to degrade should not have to guard the question. `requireSpiel()` is
|
|
43
|
+
the same answer as a namespace or a throw whose `cause` is `probe.error`.
|
|
44
|
+
|
|
45
|
+
It is a capability query, in the shape `hasGamepadBackend()` established: it says the
|
|
46
|
+
namespace is usable, not that this host has a voice. It deliberately does **not**
|
|
47
|
+
construct a `Spiel.Speaker`, which would open the session bus and D-Bus-activate every
|
|
48
|
+
installed provider.
|
|
49
|
+
|
|
50
|
+
### A system libspiel wins
|
|
51
|
+
|
|
52
|
+
A host libspiel takes precedence over the prebuild: it is the distribution's, it is the
|
|
53
|
+
build every installed provider was made against, and it gets security updates we do not
|
|
54
|
+
ship.
|
|
55
|
+
|
|
56
|
+
That needs work, because it is the opposite of the default. Every mechanism that
|
|
57
|
+
registers an ADR 0017 prebuild **prepends** it — `activateNativePrebuilds()`, the
|
|
58
|
+
`--app gjs` bundle prologue, and the `gjsify` launcher's `GI_TYPELIB_PATH`. All three are
|
|
59
|
+
right for a bridge whose library exists nowhere else (ADR 0023); for a library the host
|
|
60
|
+
may genuinely have, the precedence is backwards. So `probeSpiel()` prepends the system
|
|
61
|
+
directory *again*, after them, and therefore ahead of them.
|
|
62
|
+
|
|
63
|
+
"The system directory" is measured, not guessed: it is the directory the running process
|
|
64
|
+
loaded `GLib-2.0.typelib` from (`Repository.get_typelib_path('GLib')`). GJS has GLib
|
|
65
|
+
loaded before any of our code runs and its typelib is never a prebuild, so that is the
|
|
66
|
+
host's own `girepository-1.0` — with no table of `/usr/lib64`-shaped candidates to keep
|
|
67
|
+
current per architecture and distribution. The prepend is conditional on
|
|
68
|
+
`Spiel-1.0.typelib` actually being in it, so the search path of every other namespace in
|
|
69
|
+
the process is left alone.
|
|
70
|
+
|
|
71
|
+
## Provider discovery, and Flatpak
|
|
72
|
+
|
|
73
|
+
From `spiel-registry.c`: libspiel calls `ListActivatableNames` and then `ListNames` on the
|
|
74
|
+
session bus, keeps every name ending in `.Speech.Provider` (`PROVIDER_SUFFIX`), and
|
|
75
|
+
subscribes to `ActivatableServicesChanged` + `NameOwnerChanged` to stay current. There is
|
|
76
|
+
no central registry and no GSettings requirement — libspiel installs
|
|
77
|
+
`org.monotonous.libspiel` but works without it.
|
|
78
|
+
|
|
79
|
+
**A Flatpak app therefore needs an explicit D-Bus permission, or it sees zero voices.**
|
|
80
|
+
Measured on Fedora 44 against a sandboxed app (`gdbus` inside `app.devsuite.Ptyxis`), with
|
|
81
|
+
two providers running on the host:
|
|
82
|
+
|
|
83
|
+
| finish args | `*.Speech.Provider` visible |
|
|
84
|
+
|---|---|
|
|
85
|
+
| *(default sandbox, no talk-name)* | **none** — in neither `ListActivatableNames` nor `ListNames` |
|
|
86
|
+
| `--talk-name=ai.piper.Speech.Provider` | yes, that one |
|
|
87
|
+
| `--talk-name=ai.piper.*` | yes |
|
|
88
|
+
| `--socket=session-bus` | yes, everything (and that is the point — it is full bus access) |
|
|
89
|
+
| `--talk-name=*.Speech.Provider` | rejected: `error: Invalid dbus name` |
|
|
90
|
+
| `--talk-name=ai.*.Speech.Provider` | rejected: `error: Invalid dbus name` |
|
|
91
|
+
| `--talk-name=ai.*` / `org.*` / `*` | rejected: `error: Invalid dbus name` |
|
|
92
|
+
|
|
93
|
+
Flatpak's name matching is **prefix-only**. There is no pattern that means "any provider",
|
|
94
|
+
because the `.Speech.Provider` suffix is the discriminator and a suffix wildcard is not
|
|
95
|
+
expressible. So an app ships either one `--talk-name` per provider it supports, or
|
|
96
|
+
`--socket=session-bus`. No Flatpak template in this repository has been changed for Spiel;
|
|
97
|
+
an app that wants speech makes that trade itself, with this table to make it with.
|
|
98
|
+
|
|
99
|
+
## No audible output in tests
|
|
100
|
+
|
|
101
|
+
`Spiel.Speaker` has a `sink` property (a `GstElement`) which replaces the autoaudiosink.
|
|
102
|
+
Point it at a `fakesink` or a `filesink` and synthesis runs end to end in silence.
|
|
103
|
+
|
|
104
|
+
The GIR marks `Speaker:sink` `introspectable="0"`, so the property **accessor silently
|
|
105
|
+
does nothing**:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
speaker.sink = fakesink; // no-op, no warning
|
|
109
|
+
speaker.set_property('sink', fakesink); // works
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
(Related GJS trap met while measuring this: `GObject.type_class_ref(gtype)` returns a boxed
|
|
113
|
+
`TypeClass` with no `find_property` — use the class-level `Spiel.Speaker.find_property()`
|
|
114
|
+
/ `list_properties()`. And `Provider:well-known-name` is deprecated since 1.0.4; read
|
|
115
|
+
`provider.identifier`.)
|
|
116
|
+
|
|
117
|
+
## Building
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
gjsify workspace @gjsify/spiel-native run build:prebuilds # meson + stage-prebuild
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`meson setup` clones the two pinned upstream checkouts, so the first setup needs network
|
|
124
|
+
access. Three things in `meson.build` are worth knowing:
|
|
125
|
+
|
|
126
|
+
- **libspeechprovider is linked statically** (`default_library=static`,
|
|
127
|
+
`introspection=false`). It is libspiel's own dependency; vendoring it as a second shared
|
|
128
|
+
object would mean a second typelib and an `$ORIGIN` RUNPATH to find it. One file ships.
|
|
129
|
+
- **libspeechprovider is pinned in OUR top-level `subprojects/`**, not left to libspiel's
|
|
130
|
+
own wrap, because libspiel resolves its interface XML through
|
|
131
|
+
`meson.global_source_root() / 'subprojects' / 'libspeechprovider'` — from *our* root.
|
|
132
|
+
- **Symbols are hidden with a version script**, applied with
|
|
133
|
+
`add_global_link_arguments('-Wl,--version-script=…')` before `subproject()`, so it covers
|
|
134
|
+
the subproject's objects too. Measured on the built artifact: 89 exported symbols, all
|
|
135
|
+
`spiel_*`, none else.
|
|
136
|
+
|
|
137
|
+
The versioned SONAME is handled without patching upstream: `cp -L` to a plain
|
|
138
|
+
`libspiel-1.0.so`, rewrite the `.gir`'s `shared-library`, recompile the typelib with
|
|
139
|
+
`--shared-library libspiel-1.0.so`. The ELF SONAME stays `libspiel-1.0.so.1`, which is
|
|
140
|
+
harmless — nothing links against this file, it is `dlopen`ed by name.
|
|
141
|
+
|
|
142
|
+
Measured DT_NEEDED: `libgio-2.0`, `libgobject-2.0`, `libglib-2.0`, `libgstreamer-1.0`,
|
|
143
|
+
`libgstaudio-1.0`, `libgstbase-1.0`, `libc`. No RUNPATH, no RPATH.
|
|
144
|
+
|
|
145
|
+
**arm64 is built by CI only** (`prebuilds.yml`, the `ubuntu-24.04-arm` leg).
|
|
146
|
+
|
|
147
|
+
## Testing
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
gjsify workspace @gjsify/spiel-native run test:meson
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- `elf-deps` — reads DT_NEEDED, RUNPATH and the exported symbol table back off the built
|
|
154
|
+
file (`test/check-elf-deps.py`).
|
|
155
|
+
- `load-typelib-gjs` — imports `gi://Spiel?version=1.0` under `gjs` from the build
|
|
156
|
+
directory and touches the classes, which is what proves the rewritten GIR and the
|
|
157
|
+
recompiled typelib agree with the library.
|
|
158
|
+
- `speak-fakesink-gjs` — the end-to-end one, **opt-in**: it needs a session bus with real
|
|
159
|
+
providers, so it exits 77 (skip) unless `GJSIFY_SPIEL_LIVE=1`. With it set, measured on
|
|
160
|
+
Fedora 44: 2 providers, 5 voices, `utterance-started` → `utterance-finished` into a
|
|
161
|
+
`fakesink`. Silent by construction.
|
|
162
|
+
|
|
163
|
+
## Updating libspiel
|
|
164
|
+
|
|
165
|
+
Both wraps pin a **commit**, not a tag, and `subprojects/libspiel.wrap` carries no
|
|
166
|
+
`depth=1` — a shallow clone cannot fetch a non-tip revision.
|
|
167
|
+
|
|
168
|
+
The pin is a commit on purpose: the newest upstream tag is `SPIEL_1_0_4` (2025-04-03),
|
|
169
|
+
which predates the subproject-tracking fix this build needs under meson < 1.14, and
|
|
170
|
+
upstream's own `meson.build` already reads 1.0.5 with no tag cut. Move to a tag as soon as
|
|
171
|
+
one exists at or past the pinned commit.
|
|
172
|
+
|
|
173
|
+
To update: change the `revision` in `subprojects/libspiel.wrap` (and
|
|
174
|
+
`libspeechprovider.wrap` if its 1.0.x moves), rebuild, run the tests, and re-measure the
|
|
175
|
+
two numbers above — exported symbols and DT_NEEDED. Only GLib/GObject/Gio, GStreamer and
|
|
176
|
+
libc may appear.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
This package is MIT.
|
|
181
|
+
|
|
182
|
+
The **prebuilt library** in `@gjsify/spiel-native-linux-x64` /
|
|
183
|
+
`-linux-arm64` is `MIT AND LGPL-2.1-or-later`: it contains libspiel and, statically
|
|
184
|
+
linked, libspeechprovider — both © the Spiel contributors, both LGPL-2.1-or-later. The
|
|
185
|
+
pinned commits in `subprojects/*.wrap` are the corresponding source those packages'
|
|
186
|
+
recipients are entitled to, and the shared library is relinkable against a modified
|
|
187
|
+
libspiel by replacing the file. See [`docs/attribution.md`](../../../docs/attribution.md).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=Object.defineProperty,__name=(t,n)=>e(t,`name`,{value:n,configurable:!0});export{__name};
|
package/lib/esm/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{loadOptionalNativeModule as e}from"@gjsify/utils/core";const t=`Spiel`,n=`1.0`,r=`${t}-1.0.typelib`;function hostGi(){return globalThis.imports?.gi}function directoryOf(e){return/^(.*)[\\/][^\\/]*$/.exec(e)?.[1]??null}function defaultRepository(e){try{return e?.GIRepository?.Repository?.dup_default?.()??null}catch{return null}}function preferSystemSpiel(e,t){let n=e?.GLib;if(!t||!n)return null;try{let e=t.get_typelib_path(`GLib`),i=e?directoryOf(e):null;if(!i)return null;let a=e?.includes(`\\`)&&!e.includes(`/`)?`\\`:`/`;if(!n.file_test(`${i}${a}${r}`,n.FileTest.EXISTS))return null;t.prepend_search_path(i);let o=directoryOf(i);return o&&t.prepend_library_path(o),i}catch{return null}}let i=null;function probeSpiel(n={}){if(i&&!n.fresh)return i;let r=n.gi??hostGi(),a=defaultRepository(r),o=preferSystemSpiel(r,a);try{r?.versions&&(r.versions[t]=`1.0`)}catch{}let s=e(t,[`Speaker`,`Utterance`],r),c=(()=>{try{let e=a?.get_typelib_path(`Spiel`)??null;return e?directoryOf(e):null}catch{return null}})(),l=s.module?{source:o!==null&&c===o?`system`:`prebuild`,module:s.module,typelibDir:c,reason:null,error:null}:{source:`absent`,module:null,typelibDir:c,reason:absentReason(s.error),error:s.error};return n.fresh||(i=l),l}function absentReason(e){return e?e.name===`NativeLibraryLoadError`?e.message:e.message.includes(`imports.gi`)?`${t}: no GObject-Introspection in this process — @gjsify/spiel-native needs GJS or @gjsify/node-gi.`:`${t}-1.0.typelib was not found. Install libspiel, or the \`@gjsify/spiel-native-<os>-<arch>\` package for this machine — it is an optional dependency and exists for linux-x64 and linux-arm64 only.`:`${t}: not available, and nothing was measurable about why.`}function requireSpiel(e={}){let t=probeSpiel(e);if(t.module)return t.module;throw Error(t.reason??`Spiel: not available.`,{cause:t.error??void 0})}export{t as SPIEL_NAMESPACE,n as SPIEL_VERSION,probeSpiel,requireSpiel};
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type GiImportsView } from '@gjsify/utils/core';
|
|
2
|
+
import type { SpielNamespace } from './namespace.js';
|
|
3
|
+
export type { SpielListModel, SpielNamespace, SpielProvider, SpielSpeaker, SpielSpeakerSignals, SpielUtterance, SpielVoice, } from './namespace.js';
|
|
4
|
+
/** The GI namespace this package makes importable. */
|
|
5
|
+
export declare const SPIEL_NAMESPACE = "Spiel";
|
|
6
|
+
/** The only version libspiel has ever published a typelib for. */
|
|
7
|
+
export declare const SPIEL_VERSION = "1.0";
|
|
8
|
+
/**
|
|
9
|
+
* Where the `Spiel` typelib came from.
|
|
10
|
+
*
|
|
11
|
+
* - `system` — the host's own libspiel, on the directory its GLib typelib lives in.
|
|
12
|
+
* Preferred: it is the build every installed speech provider was made against.
|
|
13
|
+
* - `prebuild` — this package's `@gjsify/spiel-native-<target>` artifact, or any other
|
|
14
|
+
* directory a caller put on girepository's search path.
|
|
15
|
+
* - `absent` — no typelib, or one whose library will not open. {@link SpielProbe.reason}
|
|
16
|
+
* says which.
|
|
17
|
+
*/
|
|
18
|
+
export type SpielSource = 'system' | 'prebuild' | 'absent';
|
|
19
|
+
/** What {@link probeSpiel} found. */
|
|
20
|
+
export interface SpielProbe {
|
|
21
|
+
readonly source: SpielSource;
|
|
22
|
+
/** The namespace, or `null` exactly when `source` is `absent`. */
|
|
23
|
+
readonly module: SpielNamespace | null;
|
|
24
|
+
/** The directory the typelib was loaded from; `null` when none was. */
|
|
25
|
+
readonly typelibDir: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* One sentence naming what is missing — `null` exactly when `source` is not
|
|
28
|
+
* `absent`. Safe to show a user: it names the file and, for a library that was
|
|
29
|
+
* found and refused, the dependency the loader could not resolve.
|
|
30
|
+
*/
|
|
31
|
+
readonly reason: string | null;
|
|
32
|
+
/** The underlying error behind `reason`, for a caller that wants to log a cause. */
|
|
33
|
+
readonly error: Error | null;
|
|
34
|
+
}
|
|
35
|
+
/** Overrides. Tests only — every default here reads the running GJS host. */
|
|
36
|
+
export interface ProbeSpielOptions {
|
|
37
|
+
/** The `imports.gi` to use. */
|
|
38
|
+
readonly gi?: GiImportsView | undefined;
|
|
39
|
+
/** Skip the memo, so a spec can drive more than one outcome per process. */
|
|
40
|
+
readonly fresh?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Find `Spiel`, system first, and say what was found.
|
|
44
|
+
*
|
|
45
|
+
* Memoized: the answer cannot change within a process (girepository caches a loaded
|
|
46
|
+
* typelib, and the directories were decided at the first call), and the search path
|
|
47
|
+
* must not be rewritten on every query. Never throws — `absent` is an answer, not a
|
|
48
|
+
* fault, and a caller deciding how to degrade must not have to guard the question.
|
|
49
|
+
*/
|
|
50
|
+
export declare function probeSpiel(options?: ProbeSpielOptions): SpielProbe;
|
|
51
|
+
/**
|
|
52
|
+
* {@link probeSpiel} as a namespace or a throw — for a caller that has already
|
|
53
|
+
* decided Spiel is required and wants the reason in the stack trace.
|
|
54
|
+
*
|
|
55
|
+
* The `cause` carries the original error, so a loader fault keeps the file and the
|
|
56
|
+
* missing dependency the probe measured.
|
|
57
|
+
*/
|
|
58
|
+
export declare function requireSpiel(options?: ProbeSpielOptions): SpielNamespace;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** A `Gio.ListModel` of `T`, as the two members a consumer of this package uses. */
|
|
2
|
+
export interface SpielListModel<T> {
|
|
3
|
+
get_n_items(): number;
|
|
4
|
+
get_item(position: number): T | null;
|
|
5
|
+
}
|
|
6
|
+
/** `Spiel.Voice` — one voice offered by one provider. */
|
|
7
|
+
export interface SpielVoice {
|
|
8
|
+
get_name(): string;
|
|
9
|
+
/** Stable within a provider; the half of a voice id that is not the provider. */
|
|
10
|
+
get_identifier(): string;
|
|
11
|
+
/** BCP-47 tags this voice speaks. */
|
|
12
|
+
get_languages(): string[];
|
|
13
|
+
get_provider(): SpielProvider | null;
|
|
14
|
+
/** `SpeechProvider.VoiceFeature` flags, as the plain bitfield GJS sees. */
|
|
15
|
+
get_features(): number;
|
|
16
|
+
hash(): number;
|
|
17
|
+
equal(other: SpielVoice): boolean;
|
|
18
|
+
compare(other: SpielVoice, ...rest: unknown[]): number;
|
|
19
|
+
}
|
|
20
|
+
/** `Spiel.Provider` — one speech-provider daemon on the session bus. */
|
|
21
|
+
export interface SpielProvider {
|
|
22
|
+
/** The D-Bus well-known name, e.g. `org.espeak.Speech.Provider`. */
|
|
23
|
+
get_identifier(): string;
|
|
24
|
+
get_name(): string;
|
|
25
|
+
get_voices(): SpielListModel<SpielVoice> | null;
|
|
26
|
+
}
|
|
27
|
+
/** `Spiel.Utterance` — a request to speak, with its per-utterance settings. */
|
|
28
|
+
export interface SpielUtterance {
|
|
29
|
+
get_text(): string | null;
|
|
30
|
+
set_text(text: string | null): void;
|
|
31
|
+
get_language(): string | null;
|
|
32
|
+
set_language(language: string | null): void;
|
|
33
|
+
get_voice(): SpielVoice | null;
|
|
34
|
+
set_voice(voice: SpielVoice | null): void;
|
|
35
|
+
/** 0..10, 1 is the voice's own. */
|
|
36
|
+
get_rate(): number;
|
|
37
|
+
set_rate(rate: number): void;
|
|
38
|
+
get_pitch(): number;
|
|
39
|
+
set_pitch(pitch: number): void;
|
|
40
|
+
get_volume(): number;
|
|
41
|
+
set_volume(volume: number): void;
|
|
42
|
+
get_is_ssml(): boolean;
|
|
43
|
+
set_is_ssml(isSsml: boolean): void;
|
|
44
|
+
}
|
|
45
|
+
/** The signals `Spiel.Speaker` emits, with the arguments each carries after the speaker. */
|
|
46
|
+
export interface SpielSpeakerSignals {
|
|
47
|
+
'utterance-started': [utterance: SpielUtterance];
|
|
48
|
+
'utterance-finished': [utterance: SpielUtterance];
|
|
49
|
+
'utterance-canceled': [utterance: SpielUtterance];
|
|
50
|
+
'utterance-error': [utterance: SpielUtterance, error: unknown];
|
|
51
|
+
'word-started': [utterance: SpielUtterance, start: number, end: number];
|
|
52
|
+
'sentence-started': [utterance: SpielUtterance, start: number, end: number];
|
|
53
|
+
'range-started': [utterance: SpielUtterance, start: number, end: number];
|
|
54
|
+
'mark-reached': [utterance: SpielUtterance, name: string];
|
|
55
|
+
}
|
|
56
|
+
/** `Spiel.Speaker` — the client. One per process is enough; it owns the registry. */
|
|
57
|
+
export interface SpielSpeaker {
|
|
58
|
+
speak(utterance: SpielUtterance): void;
|
|
59
|
+
pause(): void;
|
|
60
|
+
resume(): void;
|
|
61
|
+
cancel(): void;
|
|
62
|
+
get_voices(): SpielListModel<SpielVoice> | null;
|
|
63
|
+
get_providers(): SpielListModel<SpielProvider> | null;
|
|
64
|
+
/** GObject.Object — the only way to reach the `introspectable="0"` `sink`. */
|
|
65
|
+
set_property(name: string, value: unknown): void;
|
|
66
|
+
get_property(name: string): unknown;
|
|
67
|
+
connect<K extends keyof SpielSpeakerSignals>(signal: K, callback: (speaker: SpielSpeaker, ...args: SpielSpeakerSignals[K]) => void): number;
|
|
68
|
+
connect(signal: string, callback: (...args: never[]) => void): number;
|
|
69
|
+
disconnect(id: number): void;
|
|
70
|
+
}
|
|
71
|
+
/** The `gi://Spiel?version=1.0` namespace — the subset this package answers for. */
|
|
72
|
+
export interface SpielNamespace {
|
|
73
|
+
readonly Speaker: {
|
|
74
|
+
/** Blocks until the registry has answered the bus. Throws on failure. */
|
|
75
|
+
new_sync(cancellable?: unknown): SpielSpeaker;
|
|
76
|
+
new (cancellable: unknown, callback: (source: unknown, result: unknown) => void): void;
|
|
77
|
+
new_finish(result: unknown): SpielSpeaker;
|
|
78
|
+
};
|
|
79
|
+
readonly Utterance: {
|
|
80
|
+
new (props?: Record<string, unknown>): SpielUtterance;
|
|
81
|
+
};
|
|
82
|
+
readonly Provider: object;
|
|
83
|
+
readonly Voice: object;
|
|
84
|
+
readonly VoicesListModel: object;
|
|
85
|
+
}
|
package/meson.build
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# GjsifySpiel — libspiel, the Spiel speech-synthesis CLIENT library, shipped as
|
|
2
|
+
# ONE relocatable shared library plus its typelib (ADR 0017), so `gi://Spiel` can
|
|
3
|
+
# be imported on a host that has no libspiel package.
|
|
4
|
+
#
|
|
5
|
+
# No C of our own: unlike the sibling bridges here, this build wraps an UPSTREAM
|
|
6
|
+
# GNOME library unchanged (LGPL-2.1-or-later, see README.md § License and
|
|
7
|
+
# docs/attribution.md). What this file adds is only what makes the upstream build
|
|
8
|
+
# shippable as a prebuild — three properties, each measured rather than assumed,
|
|
9
|
+
# and each with its own section below:
|
|
10
|
+
#
|
|
11
|
+
# 1. ONE library, not two (libspeechprovider is linked statically),
|
|
12
|
+
# 2. nothing but spiel_* leaves it (version script),
|
|
13
|
+
# 3. an UNVERSIONED leaf the typelib names (`libspiel-1.0.so`).
|
|
14
|
+
#
|
|
15
|
+
# LINUX ONLY, and that is a property of Spiel rather than of this build: a client
|
|
16
|
+
# finds its speech providers over the D-Bus SESSION BUS and plays their audio
|
|
17
|
+
# through GStreamer. There is no provider protocol on win32 or darwin, so a
|
|
18
|
+
# prebuild for them would load and then find nothing, forever. README.md
|
|
19
|
+
# § Provider discovery has the measurement.
|
|
20
|
+
project('GjsifySpiel', 'c',
|
|
21
|
+
version: '1.0.5',
|
|
22
|
+
meson_version: '>= 1.3.2',
|
|
23
|
+
license: 'LGPL-2.1-or-later',
|
|
24
|
+
# `-Os` WITHOUT `-g`, spelled as the two options rather than as a buildtype,
|
|
25
|
+
# because no buildtype is both: meson's `minsize` carries `debug=true`, and
|
|
26
|
+
# `plain` carries no optimisation at all. The size of this prebuild IS the
|
|
27
|
+
# size of upstream's objects — there is no code of ours to optimise — and a
|
|
28
|
+
# `-g` build would put object-file paths from the CI runner into the shipped
|
|
29
|
+
# image (docs/prebuilds.md § Reproducibility).
|
|
30
|
+
default_options: ['optimization=s', 'debug=false'],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
fs = import('fs')
|
|
34
|
+
|
|
35
|
+
if host_machine.system() != 'linux'
|
|
36
|
+
error('@gjsify/spiel-native builds for linux only — Spiel finds its providers on the ' +
|
|
37
|
+
'D-Bus session bus, which exists on no other target (README.md § Provider discovery). ' +
|
|
38
|
+
'This host is ' + host_machine.system() + '.')
|
|
39
|
+
endif
|
|
40
|
+
|
|
41
|
+
# ----------------------------------------------------------------------------
|
|
42
|
+
# 2. WHAT LEAVES THE LIBRARY — the version script, delivered GLOBALLY.
|
|
43
|
+
#
|
|
44
|
+
# This has to be set BEFORE `subproject()`, and it has to be `add_global_link_arguments`
|
|
45
|
+
# rather than an option on the subproject. Both halves are measured:
|
|
46
|
+
#
|
|
47
|
+
# · `-Dlibspiel:c_link_args=…` is ACCEPTED, stored and echoed back by `meson
|
|
48
|
+
# configure` — and never reaches the linker, because a subproject's
|
|
49
|
+
# `c_link_args` is a per-project option that `shared_library()` in that
|
|
50
|
+
# project does not consume for a target declared without `link_args`.
|
|
51
|
+
# Measured: 89 spiel_* and 7 speech_provider_* exported, i.e. no script.
|
|
52
|
+
# · `LDFLAGS` in the environment does work, and is exactly the kind of fact
|
|
53
|
+
# that is true in one shell and false in the next — `build:prebuilds` would
|
|
54
|
+
# ship a different library depending on who ran it.
|
|
55
|
+
#
|
|
56
|
+
# A global link argument is neither: it is recorded in the build definition and
|
|
57
|
+
# applied to every link in this build tree, subprojects included. Measured with
|
|
58
|
+
# this arrangement: 89 spiel_* and 0 speech_provider_*, which is what
|
|
59
|
+
# test/check-elf-deps.py reads back from the built file on every run.
|
|
60
|
+
# ----------------------------------------------------------------------------
|
|
61
|
+
spiel_map = meson.current_source_dir() / 'src' / 'c' / 'spiel.map'
|
|
62
|
+
add_global_link_arguments('-Wl,--version-script=' + spiel_map, language: 'c')
|
|
63
|
+
|
|
64
|
+
# ----------------------------------------------------------------------------
|
|
65
|
+
# 1. ONE LIBRARY — libspeechprovider linked STATICALLY into libspiel.
|
|
66
|
+
#
|
|
67
|
+
# Upstream builds libspeechprovider as a second shared library that libspiel
|
|
68
|
+
# links. Shipped that way, a prebuild would be two `.so` files, the second
|
|
69
|
+
# findable only through an `$ORIGIN` RUNPATH this build would have to inject
|
|
70
|
+
# (upstream sets none) — and a second typelib nobody imports. Static collapses
|
|
71
|
+
# all of it: one file, no RUNPATH, no loader-path question for
|
|
72
|
+
# `check-prebuild-loader-path.mjs` to answer. `Spiel-1.0.gir` references ZERO
|
|
73
|
+
# `speech_provider_*` symbols (measured), so nothing introspectable is lost —
|
|
74
|
+
# which is also what makes hiding them above safe.
|
|
75
|
+
#
|
|
76
|
+
# `introspection=false` on the subproject's subproject for the same reason: a
|
|
77
|
+
# `SpeechProvider-1.0.typelib` naming a library that is no longer shipped is a
|
|
78
|
+
# broken artifact, and nothing here imports it.
|
|
79
|
+
# ----------------------------------------------------------------------------
|
|
80
|
+
libspiel = subproject('libspiel',
|
|
81
|
+
default_options: [
|
|
82
|
+
'docs=false',
|
|
83
|
+
'tests=false',
|
|
84
|
+
'utils=false',
|
|
85
|
+
'introspection=true',
|
|
86
|
+
'libspeechprovider:default_library=static',
|
|
87
|
+
'libspeechprovider:introspection=false',
|
|
88
|
+
'libspeechprovider:docs=false',
|
|
89
|
+
'libspeechprovider:tests=false',
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
spiel_lib = libspiel.get_variable('spiel_lib')
|
|
93
|
+
# `gnome.generate_gir()` answers [gir, typelib]; only the GIR is taken, because
|
|
94
|
+
# the typelib beside it was compiled from a `shared-library` this package does
|
|
95
|
+
# not ship (below).
|
|
96
|
+
spiel_gir = libspiel.get_variable('spiel_gir')[0]
|
|
97
|
+
|
|
98
|
+
# ----------------------------------------------------------------------------
|
|
99
|
+
# 3. THE UNVERSIONED LEAF, and why nothing upstream is patched.
|
|
100
|
+
#
|
|
101
|
+
# meson's `version:` makes `libspiel-1.0.so.1.0.5` with `libspiel-1.0.so.1` and
|
|
102
|
+
# `libspiel-1.0.so` as SYMLINKS to it, and records the middle one in the GIR's
|
|
103
|
+
# `shared-library`. A prebuild directory is copied file by file by
|
|
104
|
+
# `stage-prebuild.mjs` — symlinks become copies — so shipping the chain would
|
|
105
|
+
# ship three identical megabytes, and shipping only what the GIR names would ship
|
|
106
|
+
# a leaf whose own name says it is one of a set.
|
|
107
|
+
#
|
|
108
|
+
# So the shipped set is re-derived rather than patched: the real file under a
|
|
109
|
+
# plain name, the GIR rewritten to name it, and the typelib recompiled from that
|
|
110
|
+
# GIR. The same three steps as gamepad-native/meson.build:238-251, for the same
|
|
111
|
+
# reason — the typelib's `shared-library` entry must name a file that is actually
|
|
112
|
+
# in the directory beside it.
|
|
113
|
+
#
|
|
114
|
+
# The ELF SONAME stays `libspiel-1.0.so.1` and that is harmless: a SONAME is what
|
|
115
|
+
# the loader records in a DEPENDENT's DT_NEEDED, and nothing here depends on this
|
|
116
|
+
# library — girepository opens it by the path the typelib names.
|
|
117
|
+
#
|
|
118
|
+
# All three land in the TOP-LEVEL build directory on purpose: `stage-prebuild.mjs`
|
|
119
|
+
# reads it with a NON-RECURSIVE `readdirSync`, so an artifact in
|
|
120
|
+
# `build/subprojects/libspiel/libspiel/` is not shipped at all.
|
|
121
|
+
# ----------------------------------------------------------------------------
|
|
122
|
+
spiel_so_leaf = 'libspiel-1.0.so'
|
|
123
|
+
|
|
124
|
+
spiel_so = custom_target(spiel_so_leaf,
|
|
125
|
+
input: spiel_lib,
|
|
126
|
+
output: spiel_so_leaf,
|
|
127
|
+
# `cp` dereferences, so this is the real file even though `@INPUT@` may be
|
|
128
|
+
# the versioned one meson built.
|
|
129
|
+
command: ['cp', '-L', '@INPUT@', '@OUTPUT@'],
|
|
130
|
+
build_by_default: true,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
sed = find_program('sed')
|
|
134
|
+
spiel_gir_relinked = custom_target('Spiel-1.0.gir',
|
|
135
|
+
input: spiel_gir,
|
|
136
|
+
output: 'Spiel-1.0.gir',
|
|
137
|
+
# NO BACKSLASH ESCAPES in this pattern, and that is not laziness: meson
|
|
138
|
+
# rewrites `\\.` in a string literal to `/.` by the time the argument reaches
|
|
139
|
+
# the program (seen in `ninja -t commands`), so the escaped form matches
|
|
140
|
+
# nothing and the rewrite silently does not happen — which is exactly how it
|
|
141
|
+
# failed first. Unescaped `.` matches any character, which over this literal
|
|
142
|
+
# is a distinction without a difference.
|
|
143
|
+
command: [sed, '-e', 's|shared-library="libspiel-1.0.so.1"|shared-library="' + spiel_so_leaf + '"|', '@INPUT@'],
|
|
144
|
+
capture: true,
|
|
145
|
+
build_by_default: true,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
spiel_typelib = custom_target('Spiel-1.0.typelib',
|
|
149
|
+
input: spiel_gir_relinked,
|
|
150
|
+
output: 'Spiel-1.0.typelib',
|
|
151
|
+
command: [
|
|
152
|
+
find_program('g-ir-compiler', 'gi-compile-repository'),
|
|
153
|
+
'--output', '@OUTPUT@',
|
|
154
|
+
# Belt and braces with the rewrite above: this is the entry girepository
|
|
155
|
+
# reads, and `check-prebuild-loader-path.mjs` asserts the file it names
|
|
156
|
+
# is in the staged directory.
|
|
157
|
+
'--shared-library', spiel_so_leaf,
|
|
158
|
+
'@INPUT@',
|
|
159
|
+
],
|
|
160
|
+
depends: [spiel_so],
|
|
161
|
+
build_by_default: true,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# ----------------------------------------------------------------------------
|
|
165
|
+
# Tests — what this build CLAIMS, read back from what it produced.
|
|
166
|
+
#
|
|
167
|
+
# Nothing here synthesises speech, and that is not caution about CI noise: a
|
|
168
|
+
# `speak()` on a developer's machine comes out of their speakers. The gjs leg
|
|
169
|
+
# sets a `fakesink` before it touches a Speaker at all (see the file).
|
|
170
|
+
# ----------------------------------------------------------------------------
|
|
171
|
+
python3 = find_program('python3', required: false)
|
|
172
|
+
if python3.found()
|
|
173
|
+
# python3 is `required: false` for the same reason as in gamepad-native: a
|
|
174
|
+
# host without it gets a tree that builds with the test SKIPPED. The BUILD
|
|
175
|
+
# needs python3 regardless (both upstream projects run it), so in practice
|
|
176
|
+
# this only ever skips where nothing built either.
|
|
177
|
+
test('elf-deps', python3,
|
|
178
|
+
args: [files('test/check-elf-deps.py'), spiel_so.full_path()],
|
|
179
|
+
depends: [spiel_so])
|
|
180
|
+
endif
|
|
181
|
+
|
|
182
|
+
gjs = find_program('gjs', required: false)
|
|
183
|
+
if gjs.found()
|
|
184
|
+
# The shipped PAIR, through girepository, exactly as a consumer reaches it:
|
|
185
|
+
# the typelib names `libspiel-1.0.so` by bare leaf and finds it beside
|
|
186
|
+
# itself. Both are in this directory only because of the three targets above.
|
|
187
|
+
gi_env = {
|
|
188
|
+
'GI_TYPELIB_PATH': meson.current_build_dir(),
|
|
189
|
+
'LD_LIBRARY_PATH': meson.current_build_dir(),
|
|
190
|
+
}
|
|
191
|
+
test('load-typelib-gjs', gjs,
|
|
192
|
+
args: ['-m', files('test/load-typelib.gjs.js')],
|
|
193
|
+
depends: [spiel_so, spiel_typelib],
|
|
194
|
+
timeout: 60,
|
|
195
|
+
env: gi_env)
|
|
196
|
+
|
|
197
|
+
# The live leg, registered always and SKIPPED (exit 77) without
|
|
198
|
+
# GJSIFY_SPIEL_LIVE=1 — see the file for why it cannot be the default: it
|
|
199
|
+
# D-Bus-activates the speech providers of whoever runs it. Registered rather
|
|
200
|
+
# than left out so `meson test --list` shows it exists; a check nobody can
|
|
201
|
+
# see is a check nobody runs.
|
|
202
|
+
test('speak-fakesink-gjs', gjs,
|
|
203
|
+
args: ['-m', files('test/speak-fakesink.gjs.js')],
|
|
204
|
+
depends: [spiel_so, spiel_typelib],
|
|
205
|
+
timeout: 120,
|
|
206
|
+
is_parallel: false,
|
|
207
|
+
env: gi_env)
|
|
208
|
+
endif
|
package/package.json
CHANGED
|
@@ -1,6 +1,92 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@gjsify/spiel-native",
|
|
3
|
+
"version": "0.64.11",
|
|
4
|
+
"description": "libspiel, the GNOME Spiel speech-synthesis client library, built as one relocatable shared library plus its typelib so `gi://Spiel` can be imported without a system libspiel — the backend of @gjsify/speech-synthesis. The prebuilt library arrives through per-target optionalDependencies; this package adds only the probe that says where Spiel came from.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/esm/index.js",
|
|
7
|
+
"module": "lib/esm/index.js",
|
|
8
|
+
"types": "lib/types/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./lib/types/index.d.ts",
|
|
12
|
+
"default": "./lib/esm/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"meson.build",
|
|
18
|
+
"src",
|
|
19
|
+
"subprojects",
|
|
20
|
+
"test"
|
|
21
|
+
],
|
|
22
|
+
"gjsify": {
|
|
23
|
+
"platforms": [
|
|
24
|
+
"linux-x64",
|
|
25
|
+
"linux-arm64"
|
|
26
|
+
],
|
|
27
|
+
"runtimes": {
|
|
28
|
+
"gjs": "polyfill",
|
|
29
|
+
"node": "none",
|
|
30
|
+
"browser": "none",
|
|
31
|
+
"nativescript": "none"
|
|
32
|
+
},
|
|
33
|
+
"os": {
|
|
34
|
+
"linux": "partial",
|
|
35
|
+
"darwin": "none",
|
|
36
|
+
"win32": "none"
|
|
37
|
+
},
|
|
38
|
+
"osNotes": {
|
|
39
|
+
"linux": "Builds for linux-x64 and linux-arm64 against the host GLib, GObject, Gio and GStreamer; libspeechprovider is linked statically, so the prebuild is one `.so` with no RUNPATH. `gi://Spiel` loads from the staged directory and `Speaker.new_sync()` reaches the session bus — measured on Fedora 44 / x86_64 with two providers answering. arm64 is built by CI only.",
|
|
40
|
+
"darwin": "Not built. Spiel is a D-Bus protocol: a client finds providers by their `*.Speech.Provider` well-known names on the session bus and plays their audio through GStreamer. macOS has no session bus and no provider implementation, so a prebuild would load and then find nothing, permanently.",
|
|
41
|
+
"win32": "Not built, for the same reason as darwin."
|
|
42
|
+
},
|
|
43
|
+
"prebuildLicense": "MIT AND LGPL-2.1-or-later",
|
|
44
|
+
"tier": 3
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"clear": "gjsify clear lib build tsconfig.tsbuildinfo",
|
|
48
|
+
"check": "gjsify tsc --noEmit",
|
|
49
|
+
"init:meson": "meson setup build .",
|
|
50
|
+
"init:meson:wipe": "gjsify run init:meson --wipe",
|
|
51
|
+
"build": "gjsify run build:gjsify && gjsify run build:types",
|
|
52
|
+
"build:gjsify": "gjsify build --library 'src/ts/**/*.{ts,js}'",
|
|
53
|
+
"build:types": "gjsify tsc -b --force",
|
|
54
|
+
"build:meson": "gjsify run init:meson && meson compile -C build",
|
|
55
|
+
"build:prebuilds": "gjsify run build:meson && node ../../../scripts/stage-prebuild.mjs .",
|
|
56
|
+
"test": "echo 'native only — see test:meson (runs on every prebuild leg)'",
|
|
57
|
+
"test:meson": "meson test -C build --print-errorlogs"
|
|
58
|
+
},
|
|
59
|
+
"keywords": [
|
|
60
|
+
"gjs",
|
|
61
|
+
"spiel",
|
|
62
|
+
"speech-synthesis",
|
|
63
|
+
"tts",
|
|
64
|
+
"gnome",
|
|
65
|
+
"linux"
|
|
66
|
+
],
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@girs/gjs": "5.4.0",
|
|
69
|
+
"@girs/glib-2.0": "5.4.0",
|
|
70
|
+
"@girs/gobject-2.0": "5.4.0",
|
|
71
|
+
"@gjsify/utils": "^0.64.11"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@gjsify/cli": "^0.64.11",
|
|
75
|
+
"@types/node": "^25.9.2",
|
|
76
|
+
"typescript": "^6.0.3"
|
|
77
|
+
},
|
|
78
|
+
"license": "MIT",
|
|
79
|
+
"repository": {
|
|
80
|
+
"type": "git",
|
|
81
|
+
"url": "git+https://github.com/gjsify/gjsify.git",
|
|
82
|
+
"directory": "packages/web/spiel-native"
|
|
83
|
+
},
|
|
84
|
+
"bugs": {
|
|
85
|
+
"url": "https://github.com/gjsify/gjsify/issues"
|
|
86
|
+
},
|
|
87
|
+
"homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/spiel-native#readme",
|
|
88
|
+
"optionalDependencies": {
|
|
89
|
+
"@gjsify/spiel-native-linux-arm64": "0.64.11",
|
|
90
|
+
"@gjsify/spiel-native-linux-x64": "0.64.11"
|
|
91
|
+
}
|
|
92
|
+
}
|