@gjsify/spiel-native 0.0.0-stage → 0.64.12-next.281
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 +92 -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/src/c/spiel.map
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* What leaves libspiel-1.0.so: the spiel_* API and nothing else.
|
|
2
|
+
*
|
|
3
|
+
* ELF has ONE flat symbol namespace, so this is not cosmetic. libspeechprovider
|
|
4
|
+
* is linked statically into this library (meson.build § ONE library), and
|
|
5
|
+
* without this script its seven speech_provider_* functions are exported — where
|
|
6
|
+
* they would interpose on (or be interposed by) the real libspeechprovider.so
|
|
7
|
+
* that a speech PROVIDER in the same process links. Measured: 89 spiel_* and 0
|
|
8
|
+
* speech_provider_* exported with this script, 89 and 7 without it. The Spiel
|
|
9
|
+
* typelib names no speech_provider_* symbol, so nothing introspectable is lost
|
|
10
|
+
* (test/check-elf-deps.py reads both facts back from the built file).
|
|
11
|
+
*/
|
|
12
|
+
{
|
|
13
|
+
global:
|
|
14
|
+
spiel_*;
|
|
15
|
+
local:
|
|
16
|
+
*;
|
|
17
|
+
};
|
package/src/ts/index.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// @gjsify/spiel-native — make `gi://Spiel?version=1.0` importable, and say where it
|
|
2
|
+
// came from.
|
|
3
|
+
//
|
|
4
|
+
// This package contains no speech API. It ships libspiel (LGPL-2.1-or-later) as an
|
|
5
|
+
// ADR 0017 prebuild and exposes exactly two things to the package that does — the
|
|
6
|
+
// sibling `@gjsify/speech-synthesis`: {@link probeSpiel}, which answers
|
|
7
|
+
// system / prebuild / absent with a reason, and {@link requireSpiel}, which is the
|
|
8
|
+
// same answer as a namespace or a throw. Nothing else here is public API.
|
|
9
|
+
//
|
|
10
|
+
// ── WHY A PROBE AND NOT AN IMPORT ─────────────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// A static `import Spiel from 'gi://Spiel'` fails the whole module when the typelib
|
|
13
|
+
// is absent, and "absent" is an ORDINARY state here: this prebuild exists only for
|
|
14
|
+
// linux-x64 and linux-arm64, and even on those a consumer may have installed the
|
|
15
|
+
// bridge without its optional per-target package. A speech API that cannot be
|
|
16
|
+
// imported on a machine with no speech is the wrong failure — the W3C surface it
|
|
17
|
+
// backs answers with an empty voice list, which needs the module to load.
|
|
18
|
+
//
|
|
19
|
+
// ── SYSTEM BEFORE PREBUILD, AND WHY IT TAKES WORK ─────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// A host libspiel wins over ours. It is the distribution's, it is the one a
|
|
22
|
+
// provider package was built against, and it gets security updates we do not ship.
|
|
23
|
+
//
|
|
24
|
+
// That is NOT the default, and that is the whole reason this function exists.
|
|
25
|
+
// Every mechanism that registers an ADR 0017 prebuild PREPENDS it —
|
|
26
|
+
// `activateNativePrebuilds()` (`@gjsify/cli`) calls `prepend_search_path`, the
|
|
27
|
+
// `--app gjs` bundle prologue (`gi-runtime-paths.ts`) emits the same call, and the
|
|
28
|
+
// `gjsify` launcher puts the directory at the FRONT of `GI_TYPELIB_PATH`. All three
|
|
29
|
+
// are right for a bridge whose library exists nowhere else (ADR 0023's
|
|
30
|
+
// bundle-before-system precedence); for a library the host may genuinely have, the
|
|
31
|
+
// precedence is backwards. So the system directory is prepended AGAIN, after them,
|
|
32
|
+
// and therefore ahead of them.
|
|
33
|
+
//
|
|
34
|
+
// WHERE "the system directory" comes from, without guessing: the directory the
|
|
35
|
+
// RUNNING process loaded `GLib-2.0.typelib` from. GJS has GLib loaded before any
|
|
36
|
+
// of our code runs and its typelib is never a prebuild, so that directory is the
|
|
37
|
+
// host's own `girepository-1.0` — the compile-time default, or whatever
|
|
38
|
+
// `GI_TYPELIB_PATH` the user set, with no table of `/usr/lib64`-shaped candidates
|
|
39
|
+
// to keep current per architecture and distribution.
|
|
40
|
+
//
|
|
41
|
+
// ── WHAT THIS DOES NOT DO ─────────────────────────────────────────────────────
|
|
42
|
+
//
|
|
43
|
+
// It does not start a `Spiel.Speaker`, which would open the session bus and
|
|
44
|
+
// D-Bus-ACTIVATE every installed speech provider. `probeSpiel()` is a capability
|
|
45
|
+
// query, in the shape `hasGamepadBackend()` established: it says the namespace is
|
|
46
|
+
// usable, not that this host has a voice. Whether any provider answers is a
|
|
47
|
+
// separate question, and on a Flatpak-sandboxed host the answer is usually no —
|
|
48
|
+
// README.md § Provider discovery has the measurement.
|
|
49
|
+
|
|
50
|
+
import { loadOptionalNativeModule, type GiImportsView } from '@gjsify/utils/core';
|
|
51
|
+
|
|
52
|
+
import type { SpielNamespace } from './namespace.js';
|
|
53
|
+
|
|
54
|
+
export type {
|
|
55
|
+
SpielListModel,
|
|
56
|
+
SpielNamespace,
|
|
57
|
+
SpielProvider,
|
|
58
|
+
SpielSpeaker,
|
|
59
|
+
SpielSpeakerSignals,
|
|
60
|
+
SpielUtterance,
|
|
61
|
+
SpielVoice,
|
|
62
|
+
} from './namespace.js';
|
|
63
|
+
|
|
64
|
+
/** The GI namespace this package makes importable. */
|
|
65
|
+
export const SPIEL_NAMESPACE = 'Spiel';
|
|
66
|
+
|
|
67
|
+
/** The only version libspiel has ever published a typelib for. */
|
|
68
|
+
export const SPIEL_VERSION = '1.0';
|
|
69
|
+
|
|
70
|
+
/** The typelib file name both halves of the search look for. */
|
|
71
|
+
const SPIEL_TYPELIB = `${SPIEL_NAMESPACE}-${SPIEL_VERSION}.typelib`;
|
|
72
|
+
|
|
73
|
+
/** The namespace whose loaded typelib locates the host's own `girepository-1.0`. */
|
|
74
|
+
const ANCHOR_NAMESPACE = 'GLib';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where the `Spiel` typelib came from.
|
|
78
|
+
*
|
|
79
|
+
* - `system` — the host's own libspiel, on the directory its GLib typelib lives in.
|
|
80
|
+
* Preferred: it is the build every installed speech provider was made against.
|
|
81
|
+
* - `prebuild` — this package's `@gjsify/spiel-native-<target>` artifact, or any other
|
|
82
|
+
* directory a caller put on girepository's search path.
|
|
83
|
+
* - `absent` — no typelib, or one whose library will not open. {@link SpielProbe.reason}
|
|
84
|
+
* says which.
|
|
85
|
+
*/
|
|
86
|
+
export type SpielSource = 'system' | 'prebuild' | 'absent';
|
|
87
|
+
|
|
88
|
+
/** What {@link probeSpiel} found. */
|
|
89
|
+
export interface SpielProbe {
|
|
90
|
+
readonly source: SpielSource;
|
|
91
|
+
/** The namespace, or `null` exactly when `source` is `absent`. */
|
|
92
|
+
readonly module: SpielNamespace | null;
|
|
93
|
+
/** The directory the typelib was loaded from; `null` when none was. */
|
|
94
|
+
readonly typelibDir: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* One sentence naming what is missing — `null` exactly when `source` is not
|
|
97
|
+
* `absent`. Safe to show a user: it names the file and, for a library that was
|
|
98
|
+
* found and refused, the dependency the loader could not resolve.
|
|
99
|
+
*/
|
|
100
|
+
readonly reason: string | null;
|
|
101
|
+
/** The underlying error behind `reason`, for a caller that wants to log a cause. */
|
|
102
|
+
readonly error: Error | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Overrides. Tests only — every default here reads the running GJS host. */
|
|
106
|
+
export interface ProbeSpielOptions {
|
|
107
|
+
/** The `imports.gi` to use. */
|
|
108
|
+
readonly gi?: GiImportsView | undefined;
|
|
109
|
+
/** Skip the memo, so a spec can drive more than one outcome per process. */
|
|
110
|
+
readonly fresh?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** girepository's two writable search paths, plus the reader the probe needs. */
|
|
114
|
+
interface RepositoryPaths {
|
|
115
|
+
get_typelib_path(namespace: string): string | null;
|
|
116
|
+
prepend_search_path(directory: string): void;
|
|
117
|
+
prepend_library_path(directory: string): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface GiWithRepository extends GiImportsView {
|
|
121
|
+
readonly versions?: Record<string, string>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The host's `imports.gi`, or `undefined` off GJS. */
|
|
125
|
+
function hostGi(): GiWithRepository | undefined {
|
|
126
|
+
return (globalThis as { imports?: { gi?: GiWithRepository } }).imports?.gi;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Directory part of a HOST path — both separators, since a win32 host uses `\`. */
|
|
130
|
+
function directoryOf(path: string): string | null {
|
|
131
|
+
return /^(.*)[\\/][^\\/]*$/.exec(path)?.[1] ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function defaultRepository(gi: GiImportsView | undefined): RepositoryPaths | null {
|
|
135
|
+
try {
|
|
136
|
+
// `dup_default()` is GLib >= 2.86, probed as a capability: an older host
|
|
137
|
+
// simply keeps whatever precedence its environment already gives it, which
|
|
138
|
+
// is the behaviour before this module existed.
|
|
139
|
+
return (gi?.GIRepository?.Repository?.dup_default?.() as RepositoryPaths | undefined) ?? null;
|
|
140
|
+
} catch {
|
|
141
|
+
// `imports.gi.GIRepository` LOADS a namespace, and distributions ship that
|
|
142
|
+
// typelib apart from the girepository library GJS links against (Debian's
|
|
143
|
+
// `gir1.2-girepository-3.0` vs `libgirepository-2.0-0`).
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Put the host's own typelib directory ahead of every prepended prebuild, when a
|
|
150
|
+
* system `Spiel-1.0.typelib` is really there.
|
|
151
|
+
*
|
|
152
|
+
* Conditional on the FILE, not on the directory: prepending unconditionally would
|
|
153
|
+
* reorder the search path for every other namespace in the process to no purpose,
|
|
154
|
+
* and a change with no effect is one nobody can reason about later.
|
|
155
|
+
*
|
|
156
|
+
* @returns the system typelib directory when it holds Spiel, else `null`
|
|
157
|
+
*/
|
|
158
|
+
function preferSystemSpiel(gi: GiImportsView | undefined, repository: RepositoryPaths | null): string | null {
|
|
159
|
+
const glib = gi?.GLib;
|
|
160
|
+
if (!repository || !glib) return null;
|
|
161
|
+
try {
|
|
162
|
+
const anchor = repository.get_typelib_path(ANCHOR_NAMESPACE);
|
|
163
|
+
const dir = anchor ? directoryOf(anchor) : null;
|
|
164
|
+
if (!dir) return null;
|
|
165
|
+
const separator = anchor?.includes('\\') && !anchor.includes('/') ? '\\' : '/';
|
|
166
|
+
if (!glib.file_test(`${dir}${separator}${SPIEL_TYPELIB}`, glib.FileTest.EXISTS)) return null;
|
|
167
|
+
repository.prepend_search_path(dir);
|
|
168
|
+
// The library too: a system libspiel in the default libdir resolves through
|
|
169
|
+
// ld.so anyway, but a host whose GI stack sits under its own prefix
|
|
170
|
+
// (`/opt/…`, jhbuild) has that libdir on neither path. `girLibraryDir` is
|
|
171
|
+
// the INSTALL layout — `<libdir>/girepository-1.0/` beside `<libdir>/`.
|
|
172
|
+
const libDir = directoryOf(dir);
|
|
173
|
+
if (libDir) repository.prepend_library_path(libDir);
|
|
174
|
+
return dir;
|
|
175
|
+
} catch {
|
|
176
|
+
// Every call is girepository reading state this process already holds; a
|
|
177
|
+
// host where it throws has no system preference to express, which leaves
|
|
178
|
+
// the prebuild — the state before this call.
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let memo: SpielProbe | null = null;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Find `Spiel`, system first, and say what was found.
|
|
187
|
+
*
|
|
188
|
+
* Memoized: the answer cannot change within a process (girepository caches a loaded
|
|
189
|
+
* typelib, and the directories were decided at the first call), and the search path
|
|
190
|
+
* must not be rewritten on every query. Never throws — `absent` is an answer, not a
|
|
191
|
+
* fault, and a caller deciding how to degrade must not have to guard the question.
|
|
192
|
+
*/
|
|
193
|
+
export function probeSpiel(options: ProbeSpielOptions = {}): SpielProbe {
|
|
194
|
+
if (memo && !options.fresh) return memo;
|
|
195
|
+
|
|
196
|
+
// `GiWithRepository` rather than `GiImportsView`: `imports.gi.versions` is a GJS
|
|
197
|
+
// property the shared view does not model, and an injected `gi` from a test is
|
|
198
|
+
// allowed to carry it too.
|
|
199
|
+
const gi: GiWithRepository | undefined = options.gi ?? hostGi();
|
|
200
|
+
const repository = defaultRepository(gi);
|
|
201
|
+
const systemDir = preferSystemSpiel(gi, repository);
|
|
202
|
+
|
|
203
|
+
// Pin the version before the namespace resolves. libspiel has published exactly
|
|
204
|
+
// one typelib version, so today this changes nothing; it is here so that a host
|
|
205
|
+
// which later has two does not silently get the other one, which is the failure
|
|
206
|
+
// `gi://Ns` without `?version=` is known for.
|
|
207
|
+
try {
|
|
208
|
+
if (gi?.versions) gi.versions[SPIEL_NAMESPACE] = SPIEL_VERSION;
|
|
209
|
+
} catch {
|
|
210
|
+
// A frozen or absent `versions` table is not a reason to stop looking.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const loaded = loadOptionalNativeModule<SpielNamespace>(
|
|
214
|
+
SPIEL_NAMESPACE,
|
|
215
|
+
// Touched after the library opens, so a namespace that resolved while its
|
|
216
|
+
// library did not reads as absent here rather than throwing at first use.
|
|
217
|
+
['Speaker', 'Utterance'],
|
|
218
|
+
gi,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const typelibDir = (() => {
|
|
222
|
+
try {
|
|
223
|
+
const path = repository?.get_typelib_path(SPIEL_NAMESPACE) ?? null;
|
|
224
|
+
return path ? directoryOf(path) : null;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
|
|
230
|
+
const probe: SpielProbe = loaded.module
|
|
231
|
+
? {
|
|
232
|
+
// Compared against the directory we prepended, not against a path
|
|
233
|
+
// shape: "system" means THIS host's girepository-1.0, and that is the
|
|
234
|
+
// only directory anything here can say that about.
|
|
235
|
+
source: systemDir !== null && typelibDir === systemDir ? 'system' : 'prebuild',
|
|
236
|
+
module: loaded.module,
|
|
237
|
+
typelibDir,
|
|
238
|
+
reason: null,
|
|
239
|
+
error: null,
|
|
240
|
+
}
|
|
241
|
+
: {
|
|
242
|
+
source: 'absent',
|
|
243
|
+
module: null,
|
|
244
|
+
typelibDir,
|
|
245
|
+
reason: absentReason(loaded.error),
|
|
246
|
+
error: loaded.error,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (!options.fresh) memo = probe;
|
|
250
|
+
return probe;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One sentence a user can act on.
|
|
255
|
+
*
|
|
256
|
+
* Three causes, and they need different sentences: not on GJS at all, no typelib
|
|
257
|
+
* anywhere, and a typelib whose library was found and refused. The third already
|
|
258
|
+
* carries the loader's own text (`NativeLibraryLoadError`), so it is passed through
|
|
259
|
+
* rather than summarised — the dependency it names is the actionable part.
|
|
260
|
+
*/
|
|
261
|
+
function absentReason(error: Error | null): string {
|
|
262
|
+
if (!error) return `${SPIEL_NAMESPACE}: not available, and nothing was measurable about why.`;
|
|
263
|
+
if (error.name === 'NativeLibraryLoadError') return error.message;
|
|
264
|
+
if (error.message.includes('imports.gi')) {
|
|
265
|
+
return `${SPIEL_NAMESPACE}: no GObject-Introspection in this process — @gjsify/spiel-native needs GJS or @gjsify/node-gi.`;
|
|
266
|
+
}
|
|
267
|
+
return (
|
|
268
|
+
`${SPIEL_NAMESPACE}-${SPIEL_VERSION}.typelib was not found. Install libspiel, or the ` +
|
|
269
|
+
'`@gjsify/spiel-native-<os>-<arch>` package for this machine — it is an optional ' +
|
|
270
|
+
'dependency and exists for linux-x64 and linux-arm64 only.'
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* {@link probeSpiel} as a namespace or a throw — for a caller that has already
|
|
276
|
+
* decided Spiel is required and wants the reason in the stack trace.
|
|
277
|
+
*
|
|
278
|
+
* The `cause` carries the original error, so a loader fault keeps the file and the
|
|
279
|
+
* missing dependency the probe measured.
|
|
280
|
+
*/
|
|
281
|
+
export function requireSpiel(options: ProbeSpielOptions = {}): SpielNamespace {
|
|
282
|
+
const probe = probeSpiel(options);
|
|
283
|
+
if (probe.module) return probe.module;
|
|
284
|
+
throw new Error(probe.reason ?? `${SPIEL_NAMESPACE}: not available.`, { cause: probe.error ?? undefined });
|
|
285
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The `gi://Spiel` surface, as types.
|
|
2
|
+
//
|
|
3
|
+
// HAND-WRITTEN rather than generated, on `@gjsify/gamepad`'s precedent
|
|
4
|
+
// (`sdl-namespace.ts`): no `@girs/*` package binds Spiel-1.0 — there is no
|
|
5
|
+
// `@girs/spiel-1.0` on npm — and the namespace is five classes. Read off the GIR
|
|
6
|
+
// this package's own build produces, so a bump that changes the API changes this
|
|
7
|
+
// file in the same commit.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately INCOMPLETE where upstream is deprecated or unusable from JS:
|
|
10
|
+
//
|
|
11
|
+
// · `Provider.get_well_known_name()` is deprecated since libspiel 1.0.4 and GJS
|
|
12
|
+
// prints a warning on every call. `identifier` is the same string and is not.
|
|
13
|
+
// · `Speaker:sink` is `introspectable="0"` in the GIR, so `speaker.sink = el`
|
|
14
|
+
// is a SILENT no-op in GJS. The only spelling that works is
|
|
15
|
+
// `set_property('sink', el)`, which lives on GObject.Object and therefore is
|
|
16
|
+
// not re-declared here — see README.md § No audible output.
|
|
17
|
+
|
|
18
|
+
/** A `Gio.ListModel` of `T`, as the two members a consumer of this package uses. */
|
|
19
|
+
export interface SpielListModel<T> {
|
|
20
|
+
get_n_items(): number;
|
|
21
|
+
get_item(position: number): T | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `Spiel.Voice` — one voice offered by one provider. */
|
|
25
|
+
export interface SpielVoice {
|
|
26
|
+
get_name(): string;
|
|
27
|
+
/** Stable within a provider; the half of a voice id that is not the provider. */
|
|
28
|
+
get_identifier(): string;
|
|
29
|
+
/** BCP-47 tags this voice speaks. */
|
|
30
|
+
get_languages(): string[];
|
|
31
|
+
get_provider(): SpielProvider | null;
|
|
32
|
+
/** `SpeechProvider.VoiceFeature` flags, as the plain bitfield GJS sees. */
|
|
33
|
+
get_features(): number;
|
|
34
|
+
hash(): number;
|
|
35
|
+
equal(other: SpielVoice): boolean;
|
|
36
|
+
compare(other: SpielVoice, ...rest: unknown[]): number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `Spiel.Provider` — one speech-provider daemon on the session bus. */
|
|
40
|
+
export interface SpielProvider {
|
|
41
|
+
/** The D-Bus well-known name, e.g. `org.espeak.Speech.Provider`. */
|
|
42
|
+
get_identifier(): string;
|
|
43
|
+
get_name(): string;
|
|
44
|
+
get_voices(): SpielListModel<SpielVoice> | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `Spiel.Utterance` — a request to speak, with its per-utterance settings. */
|
|
48
|
+
export interface SpielUtterance {
|
|
49
|
+
get_text(): string | null;
|
|
50
|
+
set_text(text: string | null): void;
|
|
51
|
+
get_language(): string | null;
|
|
52
|
+
set_language(language: string | null): void;
|
|
53
|
+
get_voice(): SpielVoice | null;
|
|
54
|
+
set_voice(voice: SpielVoice | null): void;
|
|
55
|
+
/** 0..10, 1 is the voice's own. */
|
|
56
|
+
get_rate(): number;
|
|
57
|
+
set_rate(rate: number): void;
|
|
58
|
+
get_pitch(): number;
|
|
59
|
+
set_pitch(pitch: number): void;
|
|
60
|
+
get_volume(): number;
|
|
61
|
+
set_volume(volume: number): void;
|
|
62
|
+
get_is_ssml(): boolean;
|
|
63
|
+
set_is_ssml(isSsml: boolean): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The signals `Spiel.Speaker` emits, with the arguments each carries after the speaker. */
|
|
67
|
+
export interface SpielSpeakerSignals {
|
|
68
|
+
'utterance-started': [utterance: SpielUtterance];
|
|
69
|
+
'utterance-finished': [utterance: SpielUtterance];
|
|
70
|
+
'utterance-canceled': [utterance: SpielUtterance];
|
|
71
|
+
'utterance-error': [utterance: SpielUtterance, error: unknown];
|
|
72
|
+
'word-started': [utterance: SpielUtterance, start: number, end: number];
|
|
73
|
+
'sentence-started': [utterance: SpielUtterance, start: number, end: number];
|
|
74
|
+
'range-started': [utterance: SpielUtterance, start: number, end: number];
|
|
75
|
+
'mark-reached': [utterance: SpielUtterance, name: string];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `Spiel.Speaker` — the client. One per process is enough; it owns the registry. */
|
|
79
|
+
export interface SpielSpeaker {
|
|
80
|
+
speak(utterance: SpielUtterance): void;
|
|
81
|
+
pause(): void;
|
|
82
|
+
resume(): void;
|
|
83
|
+
cancel(): void;
|
|
84
|
+
get_voices(): SpielListModel<SpielVoice> | null;
|
|
85
|
+
get_providers(): SpielListModel<SpielProvider> | null;
|
|
86
|
+
|
|
87
|
+
/** GObject.Object — the only way to reach the `introspectable="0"` `sink`. */
|
|
88
|
+
set_property(name: string, value: unknown): void;
|
|
89
|
+
get_property(name: string): unknown;
|
|
90
|
+
connect<K extends keyof SpielSpeakerSignals>(
|
|
91
|
+
signal: K,
|
|
92
|
+
callback: (speaker: SpielSpeaker, ...args: SpielSpeakerSignals[K]) => void,
|
|
93
|
+
): number;
|
|
94
|
+
connect(signal: string, callback: (...args: never[]) => void): number;
|
|
95
|
+
disconnect(id: number): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The `gi://Spiel?version=1.0` namespace — the subset this package answers for. */
|
|
99
|
+
export interface SpielNamespace {
|
|
100
|
+
readonly Speaker: {
|
|
101
|
+
/** Blocks until the registry has answered the bus. Throws on failure. */
|
|
102
|
+
new_sync(cancellable?: unknown): SpielSpeaker;
|
|
103
|
+
new (cancellable: unknown, callback: (source: unknown, result: unknown) => void): void;
|
|
104
|
+
new_finish(result: unknown): SpielSpeaker;
|
|
105
|
+
};
|
|
106
|
+
readonly Utterance: { new (props?: Record<string, unknown>): SpielUtterance };
|
|
107
|
+
readonly Provider: object;
|
|
108
|
+
readonly Voice: object;
|
|
109
|
+
readonly VoicesListModel: object;
|
|
110
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# libspeechprovider (LGPL-2.1-or-later, docs/attribution.md) — the D-Bus
|
|
2
|
+
# interface and audio-stream format shared by libspiel and every speech provider.
|
|
3
|
+
# Linked STATICALLY into libspiel-1.0.so here, so this wrap produces no shipped
|
|
4
|
+
# artifact of its own; see meson.build § ONE library.
|
|
5
|
+
#
|
|
6
|
+
# OUR OWN COPY, at the TOP LEVEL, and both halves of that are load-bearing:
|
|
7
|
+
#
|
|
8
|
+
# · Top level, because libspiel resolves the interface XML through
|
|
9
|
+
# `meson.global_source_root() / 'subprojects' / 'libspeechprovider'`
|
|
10
|
+
# (libspiel/meson.build:111 at the pinned revision). A nested
|
|
11
|
+
# subprojects/libspiel/subprojects/libspeechprovider would need meson's
|
|
12
|
+
# subproject promotion to land in that path; declaring it here is the same
|
|
13
|
+
# result with nothing implicit.
|
|
14
|
+
# · Ours, because upstream's wrap says `revision = main` — an unpinned
|
|
15
|
+
# dependency makes the prebuild un-reproducible, which docs/prebuilds.md
|
|
16
|
+
# forbids. This is 1.0.3, the floor libspiel's `dependency()` requires.
|
|
17
|
+
[wrap-git]
|
|
18
|
+
directory = libspeechprovider
|
|
19
|
+
url = https://github.com/project-spiel/libspeechprovider.git
|
|
20
|
+
revision = 54b6013c2529e208e6a3385076e686fa1c71ef93
|
|
21
|
+
|
|
22
|
+
[provide]
|
|
23
|
+
dependency_names = speech-provider-1.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# libspiel — the Spiel speech-synthesis CLIENT library (LGPL-2.1-or-later,
|
|
2
|
+
# docs/attribution.md). It talks D-Bus to speech providers, so this build exists
|
|
3
|
+
# on Linux only; see README.md § What this is.
|
|
4
|
+
#
|
|
5
|
+
# PINNED TO A COMMIT, not a tag, and that is a measured choice rather than a
|
|
6
|
+
# preference: the newest upstream tag is SPIEL_1_0_4 (2025-04-03), while
|
|
7
|
+
# `meson.build` has said `version: '1.0.5'` since, and 1.0.4 predates the
|
|
8
|
+
# subproject-tracking fix this build needs (libspiel's own meson.build copies the
|
|
9
|
+
# provider interface XML through a custom_target for meson < 1.14.0). There is no
|
|
10
|
+
# SPIEL_1_0_5 to pin. Bumping it: change `revision`, rebuild, re-run
|
|
11
|
+
# `meson test -C build` (the ELF read-back asserts the exported symbol set) and
|
|
12
|
+
# re-measure the prebuild size in README.md § Updating libspiel.
|
|
13
|
+
#
|
|
14
|
+
# No `depth`: a shallow clone can only fetch a commit the server offers as a tip,
|
|
15
|
+
# and this revision is not one.
|
|
16
|
+
[wrap-git]
|
|
17
|
+
directory = libspiel
|
|
18
|
+
url = https://github.com/project-spiel/libspiel.git
|
|
19
|
+
revision = 7c88081b03ebb2903817cf101e4b9906052155c9
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""What `libspiel-1.0.so` links and exports, read back from the built file.
|
|
3
|
+
|
|
4
|
+
Two claims meson.build makes, neither of which the build log can prove:
|
|
5
|
+
|
|
6
|
+
1. DT_NEEDED names only libc and the GNOME libraries the host provides
|
|
7
|
+
(GLib/GObject/GIO and GStreamer). libspeechprovider must NOT appear: it is
|
|
8
|
+
linked statically (meson.build § ONE library), and a NEEDED entry would mean
|
|
9
|
+
the prebuild is half a set and cannot load without a libspeechprovider
|
|
10
|
+
package — the one thing this build exists to avoid.
|
|
11
|
+
2. The dynamic symbol table exports only `spiel_*`. ELF has ONE flat symbol
|
|
12
|
+
namespace, and the statically linked libspeechprovider's seven
|
|
13
|
+
`speech_provider_*` functions would otherwise interpose on (or be
|
|
14
|
+
interposed by) the real `libspeechprovider.so` that a speech PROVIDER in the
|
|
15
|
+
same process links. Measured: 89/0 with src/c/spiel.map, 89/7 without.
|
|
16
|
+
|
|
17
|
+
Prints both lists so the CI log carries the measurement, and exits 1 on any
|
|
18
|
+
entry outside them. Uses `readelf` (binutils): the file format is the contract,
|
|
19
|
+
not whatever meson was asked to do.
|
|
20
|
+
"""
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
# GStreamer is a load-time dependency here, unlike in the sibling bridges: Spiel
|
|
26
|
+
# plays a provider's audio stream through a GStreamer pipeline, so libgstreamer,
|
|
27
|
+
# libgstaudio and libgstbase are part of what `gi://Spiel` needs present.
|
|
28
|
+
ALLOWED_NEEDED = re.compile(
|
|
29
|
+
r"^(libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|ld-linux[-\w.]*\.so\.\d+"
|
|
30
|
+
r"|libglib-2\.0\.so\.0|libgobject-2\.0\.so\.0|libgio-2\.0\.so\.0"
|
|
31
|
+
r"|libgstreamer-1\.0\.so\.0|libgstaudio-1\.0\.so\.0|libgstbase-1\.0\.so\.0)$"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# The Spiel API as of the pinned revision. A floor rather than an equality: a
|
|
35
|
+
# libspiel bump that ADDS API must not fail this, while a version script that
|
|
36
|
+
# stopped being applied (or a build that exported nothing) must.
|
|
37
|
+
MIN_SPIEL_EXPORTS = 80
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def readelf(*args):
|
|
41
|
+
return subprocess.run(["readelf", "--wide", *args], check=True, capture_output=True, text=True).stdout
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(path):
|
|
45
|
+
needed = re.findall(r"\(NEEDED\)\s+Shared library: \[([^\]]+)\]", readelf("-d", path))
|
|
46
|
+
exported = []
|
|
47
|
+
for line in readelf("--dyn-syms", path).splitlines():
|
|
48
|
+
cols = line.split()
|
|
49
|
+
# Num: Value Size Type Bind Vis Ndx Name — defined (Ndx != UND), GLOBAL/WEAK.
|
|
50
|
+
if len(cols) >= 8 and cols[4] in ("GLOBAL", "WEAK") and cols[6] != "UND":
|
|
51
|
+
exported.append(cols[7].split("@")[0])
|
|
52
|
+
|
|
53
|
+
spiel = [s for s in exported if s.startswith("spiel_")]
|
|
54
|
+
other = [s for s in exported if not s.startswith("spiel_")]
|
|
55
|
+
print(f"{path}")
|
|
56
|
+
print(f" NEEDED ({len(needed)}): {', '.join(needed)}")
|
|
57
|
+
print(f" exported ({len(exported)}): {len(spiel)} spiel_*" + (f", plus {other}" if other else ""))
|
|
58
|
+
|
|
59
|
+
problems = [f"NEEDED {n} is not an OS/GNOME library" for n in needed if not ALLOWED_NEEDED.match(n)]
|
|
60
|
+
problems += [f"exports {s}, which is not spiel_*" for s in other]
|
|
61
|
+
# An empty parse would pass everything above: the library links libc and
|
|
62
|
+
# exports the whole Spiel API, so neither list can be short.
|
|
63
|
+
if not needed or len(spiel) < MIN_SPIEL_EXPORTS:
|
|
64
|
+
problems.append(
|
|
65
|
+
f"parsed {len(needed)} NEEDED / {len(spiel)} spiel_* exports — "
|
|
66
|
+
"either the version script hid the API or readelf's format moved"
|
|
67
|
+
)
|
|
68
|
+
for p in problems:
|
|
69
|
+
print(f" FAIL: {p}")
|
|
70
|
+
return 1 if problems else 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
sys.exit(main(sys.argv[1]))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The shipped PAIR under GJS: `Spiel-1.0.typelib` resolving, and the library it
|
|
2
|
+
// names by bare leaf opening from beside it. The only two facts a prebuild has
|
|
3
|
+
// to deliver — everything Spiel then does is upstream's.
|
|
4
|
+
//
|
|
5
|
+
// NOTHING HERE TOUCHES THE SESSION BUS, by design and not by caution:
|
|
6
|
+
//
|
|
7
|
+
// · A `Spiel.Speaker` enumerates providers over D-Bus and asks each for its
|
|
8
|
+
// voices, which D-BUS-ACTIVATES them — this test would start speech-provider
|
|
9
|
+
// daemons on the machine running it, and would answer differently on a
|
|
10
|
+
// runner that has none. The live end-to-end check is `speak-fakesink.gjs.js`,
|
|
11
|
+
// opt-in behind GJSIFY_SPIEL_LIVE=1.
|
|
12
|
+
// · Registering the GObject TYPES is already the whole load question: every
|
|
13
|
+
// `*_get_type()` below is a symbol girepository has to find in
|
|
14
|
+
// `libspiel-1.0.so`, so a typelib naming a leaf that is not beside it, or a
|
|
15
|
+
// version script that hid the API, fails right here.
|
|
16
|
+
//
|
|
17
|
+
// Run by meson with GI_TYPELIB_PATH/LD_LIBRARY_PATH pointing at the build
|
|
18
|
+
// directory, which is the one place all three artifacts sit together.
|
|
19
|
+
|
|
20
|
+
import GObject from 'gi://GObject?version=2.0';
|
|
21
|
+
import Spiel from 'gi://Spiel?version=1.0';
|
|
22
|
+
|
|
23
|
+
/** @param {boolean} ok @param {string} what */
|
|
24
|
+
function assert(ok, what) {
|
|
25
|
+
if (!ok) throw new Error(`FAIL: ${what}`);
|
|
26
|
+
console.log(` ok ${what}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resolving `gi://Spiel` loaded the TYPELIB only. Each `$gtype` read below runs
|
|
30
|
+
// the type-init function out of the library, so this is where it opens.
|
|
31
|
+
for (const name of ['Speaker', 'Utterance', 'Voice', 'Provider', 'VoicesListModel']) {
|
|
32
|
+
assert(typeof Spiel[name]?.$gtype === 'object', `Spiel.${name} registers its GType from libspiel-1.0.so`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A real call through the library, not just type registration: an Utterance is
|
|
36
|
+
// a plain GObject with no bus and no provider behind it.
|
|
37
|
+
const utterance = new Spiel.Utterance({ text: 'prebuild check', pitch: 1.0, rate: 1.0 });
|
|
38
|
+
assert(utterance.text === 'prebuild check', 'Spiel.Utterance round-trips its text property');
|
|
39
|
+
|
|
40
|
+
// THE SILENCE SWITCH, asserted rather than described. `Speaker:sink` is what
|
|
41
|
+
// redirects synthesised audio away from the speakers (README.md § No audible
|
|
42
|
+
// output); the property is marked `introspectable="0"` in the GIR, so
|
|
43
|
+
// `speaker.sink = element` is a SILENT no-op in GJS and `set_property` is the
|
|
44
|
+
// only spelling that works. What this checks is that the property is on the
|
|
45
|
+
// class at all — if a libspiel bump removes it, every caller silently starts
|
|
46
|
+
// playing out loud.
|
|
47
|
+
//
|
|
48
|
+
// `Spiel.Speaker.find_property` and not `GObject.type_class_ref(…)`: the latter
|
|
49
|
+
// answers a boxed `GObject.TypeClass`, which carries none of `GObjectClass`'s
|
|
50
|
+
// methods in GJS (`.find_property is not a function`). GJS puts the class-struct
|
|
51
|
+
// methods on the CLASS instead, and they need no instance — which is the whole
|
|
52
|
+
// point here, since a Speaker cannot be constructed without a session bus.
|
|
53
|
+
assert(Spiel.Speaker.find_property('sink') !== null, 'Spiel.Speaker has the `sink` property');
|
|
54
|
+
|
|
55
|
+
for (const signal of ['utterance-started', 'utterance-finished', 'utterance-error']) {
|
|
56
|
+
assert(GObject.signal_lookup(signal, Spiel.Speaker.$gtype) !== 0, `Spiel.Speaker emits \`${signal}\``);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('load-typelib: Spiel-1.0.typelib resolved and libspiel-1.0.so opened beside it');
|