@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 CHANGED
@@ -1,3 +1,187 @@
1
- # Temporary Holding Version
1
+ # @gjsify/spiel-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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};
@@ -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
- "name": "@gjsify/spiel-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }