osdy-pi 1.11.17 → 1.11.18
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 +23 -5
- package/docs/kokoro.md +91 -0
- package/extensions/osdy-pi/control-center-speech.test.ts +22 -0
- package/extensions/osdy-pi/control-center-speech.ts +4 -4
- package/extensions/osdy-pi/kokoro-audio-player.test.ts +136 -0
- package/extensions/osdy-pi/kokoro-audio-player.ts +150 -0
- package/extensions/osdy-pi/kokoro-speech-adapter.test.ts +128 -0
- package/extensions/osdy-pi/kokoro-speech-adapter.ts +146 -0
- package/extensions/osdy-pi/speech-service.test.ts +31 -0
- package/extensions/osdy-pi/speech-service.ts +5 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
8
8
|
|
|
9
9
|
**Audio notifications share a bundled 180 ms default sound on macOS and Windows**, with a persistent master mute in `/osdyConfig` → **Sounds**. Custom paths take precedence: startup flag → saved global path → bundled default. Muting preserves those paths and visual alerts; explicit **Test effective** still plays while muted. See [Audio notifications](#audio-notifications).
|
|
10
10
|
|
|
11
|
-
**Local response narration is opt-in on macOS**: `/osdyConfig` → **Speech** offers a one-line installed
|
|
11
|
+
**Local response narration is opt-in on macOS**: `/osdyConfig` → **Speech** offers a one-line selector for installed macOS voices and optional local Kokoro voices, with explicit previews without saving or enabling narration. Stop from chat with **Ctrl+Option+S** or `/osdy-pi speech stop`. Speech stays independent of notification sounds and their mute. See [Response speech](#response-speech).
|
|
12
12
|
|
|
13
13
|
**The Control Center consolidates Osdy controls in `/osdyConfig`**: eleven categories cover appearance, Git, sounds, accounts, usage, TODO/Agents provider settings, and opt-in response speech. Account quota previews query a selected stored Codex profile without switching it. The header shows the installed Osdy package version when it fits; constrained layouts show only the centered mascot. See [Control Center in Pi](#control-center-in-pi) for confirmation and persistence boundaries.
|
|
14
14
|
|
|
@@ -19,7 +19,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
19
19
|
| Area | What ships |
|
|
20
20
|
| --- | --- |
|
|
21
21
|
| Control Center | **Ctrl+Option+O** or `/osdyConfig` opens eleven categories, including confirmed TODO/Agents provider selection, explicit per-profile Codex quota previews, and Speech. |
|
|
22
|
-
| Speech | Default-off local macOS narration, one saved
|
|
22
|
+
| Speech | Default-off local macOS narration, optional Spanish Kokoro voices, one saved voice, explicit draft previews, and chat-accessible Stop. |
|
|
23
23
|
| Audio | Shared bundled default, per-event custom paths, and persistent automatic-audio mute; [explicit tests remain available](#audio-notifications). |
|
|
24
24
|
| Local analytics (1.10.0) | `/osdy-usage` opens Summary, Profiles, Models, and History pages for recorded tokens and estimated cost—not billing or subscription quota. |
|
|
25
25
|
| Themes introduced in 1.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
|
|
@@ -590,13 +590,31 @@ From chat, press **Ctrl+Option+S** on macOS (**Ctrl+Alt+S** elsewhere) for the s
|
|
|
590
590
|
|
|
591
591
|
Open **`/osdyConfig` → Speech** (the last category) to enable/disable narration, read the last response, stop playback, or select a voice:
|
|
592
592
|
|
|
593
|
-
1. Use **↑/↓** to reach the single **Voice: ‹ … ›** row. **←/→** browses installed voices
|
|
594
|
-
2. Select **Preview voice** and press **Enter** to hear a fixed
|
|
593
|
+
1. Use **↑/↓** to reach the single **Voice: ‹ … ›** row. **←/→** browses installed macOS voices and **Kokoro Dora / Kokoro Alex / Kokoro Santa**, wrapping at both ends. The row marks the draft **saved** or **unsaved**; the summary always identifies the saved voice. Browsing never saves or plays audio.
|
|
594
|
+
2. Select **Preview voice** and press **Enter** to hear a fixed Spanish sample for Kokoro or the existing fixed English sample for native voices, independent of the last response. Preview does **not** save the draft or enable narration, and works while narration is disabled. It may replace current playback. Leaving Speech or closing the panel cancels only its preview, not unrelated narration.
|
|
595
595
|
3. Return to **Voice** and press **Enter** to save the draft for subsequent narration. Preview, other controls, and failed saves preserve the draft. **Read last response** uses the saved voice, not an unsaved draft. Saving never plays audio, enables narration, or changes already-playing audio.
|
|
596
596
|
|
|
597
597
|
Opening Speech starts at the playback controls; **Home** returns there, **Tab** returns to categories, and arrows outside the Voice row retain normal pane focus behavior. The feminine Paulina voice remains the default until you save another voice.
|
|
598
598
|
|
|
599
|
-
The panel shows enabled/saved-voice/rate state
|
|
599
|
+
The panel shows enabled/saved-voice/rate state. Native speech uses supported voices already installed on the Pi host and needs no server or model download. Optional **Kokoro** needs a separately started local MLX-Audio server and an explicitly downloaded model; listing or saving a voice does not download anything. See [local Kokoro setup](docs/kokoro.md) for prerequisites and validation status. The extension never installs dependencies or starts the server automatically. Empty/unavailable voice lists leave narration controls accessible. Rate tuning is not exposed; the existing wpm display maps to Kokoro speed as **rate / 180**, not a guarantee of identical spoken wpm. Selection saves globally in the independent `<agent-dir>/extensions/osdy-pi/speech.json` store and survives reload/restart; **Sounds mute and event notifications are unchanged and independent**.
|
|
600
|
+
|
|
601
|
+
### Optional Kokoro voices
|
|
602
|
+
|
|
603
|
+
On Apple Silicon Macs, the same selector includes three local Spanish voices:
|
|
604
|
+
|
|
605
|
+
| Voice | Preset |
|
|
606
|
+
| --- | --- |
|
|
607
|
+
| **Kokoro Dora** | Feminine, `ef_dora` |
|
|
608
|
+
| **Kokoro Alex** | Masculine, `em_alex` |
|
|
609
|
+
| **Kokoro Santa** | Masculine, `em_santa` |
|
|
610
|
+
|
|
611
|
+
Follow [the Kokoro setup and startup guide](docs/kokoro.md) to prepare the isolated Python environment, explicitly download the free model, and start MLX-Audio on **`http://127.0.0.1:8000`**. Keep that service running while using Kokoro: the extension does **not** start it automatically. A preview reporting **`Kokoro synthesis failed`** can mean the server is stopped; check the service and the optional `OSDY_PI_KOKORO_URL` loopback origin before reinstalling anything. Native voices do not need this service.
|
|
612
|
+
|
|
613
|
+
After starting it, browse to a Kokoro voice and use **Preview voice** to listen without changing your saved voice or enabling narration. Save only when you want subsequent narration to use that voice. Generation is local, but the complete WAV is produced before playback; this is not streamed narration. Initial model loading is slower than warm requests.
|
|
614
|
+
|
|
615
|
+
**Known limit:** long Spanish responses may be truncated by the current upstream phonemizer. Osdy normalizes newlines, so adding line breaks is not a workaround in this integration. Prefer an installed native voice for long responses until chunking/completeness is evaluated. The [guide](docs/kokoro.md#observed-local-synthesis) records tested versions and short-sample measurements; these are not quality or latency guarantees.
|
|
616
|
+
|
|
617
|
+
### Narration lifecycle and compatibility
|
|
600
618
|
|
|
601
619
|
Automatic narration waits for final settlement, including retries, compaction, and queued continuations—not every message or intermediate agent end. Only successful final text is read, omitting fenced code, tools, thinking, and Markdown noise. Cancelled, failed, empty, and tool-only newest responses never fall back to older text automatically. Explicit replay retains the last successful response only in memory for this session; switching sessions, reload, and shutdown clear it. New input/runs stop narration, and queued follow-ups remain eligible once their final response settles. Explicit **Stop** also cancels pending starts and suppresses automatic narration for the rest of that run; explicit replay remains available when idle. Cancelling post-run compaction discards that run's pending response. Speech launch never waits for the full spoken playback in an agent lifecycle handler.
|
|
602
620
|
|
package/docs/kokoro.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Optional local Spanish speech with Kokoro
|
|
2
|
+
|
|
3
|
+
Kokoro adds **Kokoro Dora**, **Kokoro Alex**, and **Kokoro Santa** to the existing Speech selector. Native macOS speech remains the default: **Paulina**, narration **off**, **180 wpm**. Native voices never need a server. Kokoro uses a free local model, not cloud speech or voice cloning; initial playback support is macOS only.
|
|
4
|
+
|
|
5
|
+
## Quick path
|
|
6
|
+
|
|
7
|
+
1. On an Apple Silicon Mac, explicitly prepare the isolated Python environment below and start the local MLX-Audio service in a separate terminal.
|
|
8
|
+
2. In Pi, open **`/osdyConfig` → Speech → Voice**, browse with **←/→**, then choose **Preview voice**. Kokoro previews use a fixed Spanish sample; native voices retain their fixed English sample. Preview uses the unsaved draft without saving it or enabling narration.
|
|
9
|
+
3. Return to **Voice** and press **Enter** to save the draft. **Read last response** uses the saved voice even while narration is disabled. Enable automatic narration only if wanted.
|
|
10
|
+
4. Use **Stop**, **Ctrl+Option+S** from chat, or **`/osdy-pi speech stop`** to cancel pending synthesis or playback. If the terminal cannot deliver the shortcut, use the command. Leaving Speech or closing its panel stops only its preview, not unrelated narration.
|
|
11
|
+
|
|
12
|
+
## Explicit environment setup
|
|
13
|
+
|
|
14
|
+
Prerequisites: an **Apple Silicon Mac** and **uv**. The example explicitly downloads a managed Python 3.11 interpreter inside the project rather than installing Python packages globally. No npm runtime dependency is added.
|
|
15
|
+
|
|
16
|
+
The isolated environment was prepared successfully on an **M3 Pro / 18 GiB RAM** with **Python 3.11.15**, **MLX-Audio 0.5.9**, **Misaki 0.9.4**, and **espeakng-loader 0.2.4**. A Spanish `EspeakG2P(language="es")` probe returned phonemes successfully. `espeakng-loader` supplies the native library used by Misaki in this setup; no separate global Homebrew install was needed. All three Spanish voices also produced valid 24 kHz mono WAV through the local HTTP server. Audible playback and subjective listening quality were not evaluated.
|
|
17
|
+
|
|
18
|
+
Run from the project root. These environment variables must also be set in every terminal that runs setup, model download, or the server:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export KOKORO_HOME="$PWD/.pi-dev/kokoro"
|
|
22
|
+
export UV_CACHE_DIR="$KOKORO_HOME/cache/uv"
|
|
23
|
+
export UV_PYTHON_INSTALL_DIR="$KOKORO_HOME/python"
|
|
24
|
+
export HF_HOME="$KOKORO_HOME/cache/huggingface"
|
|
25
|
+
export HF_HUB_CACHE="$HF_HOME/hub"
|
|
26
|
+
export HUGGINGFACE_HUB_CACHE="$HF_HUB_CACHE"
|
|
27
|
+
export XDG_CACHE_HOME="$KOKORO_HOME/cache/xdg"
|
|
28
|
+
export PIP_CACHE_DIR="$KOKORO_HOME/cache/pip"
|
|
29
|
+
export TORCH_HOME="$KOKORO_HOME/cache/torch"
|
|
30
|
+
export NUMBA_CACHE_DIR="$KOKORO_HOME/cache/numba"
|
|
31
|
+
export MPLCONFIGDIR="$KOKORO_HOME/cache/matplotlib"
|
|
32
|
+
export PYTHONPYCACHEPREFIX="$KOKORO_HOME/cache/pycache"
|
|
33
|
+
export HF_HUB_DISABLE_TELEMETRY=1
|
|
34
|
+
mkdir -p "$KOKORO_HOME"
|
|
35
|
+
uv --no-config venv --managed-python --python 3.11 "$KOKORO_HOME/venv"
|
|
36
|
+
uv --no-config pip install --python "$KOKORO_HOME/venv/bin/python" \
|
|
37
|
+
'mlx-audio[server,tts]==0.5.9' 'misaki[en]==0.9.4' \
|
|
38
|
+
'espeakng-loader==0.2.4' soundfile
|
|
39
|
+
# Explicitly download before playback: a first download can exceed the request timeout.
|
|
40
|
+
"$KOKORO_HOME/venv/bin/python" -c \
|
|
41
|
+
'from huggingface_hub import snapshot_download; snapshot_download("mlx-community/Kokoro-82M-bf16")'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The managed interpreter, virtualenv, package caches, and model cache are explicitly project-contained. If you prefer an existing Python 3.10+ interpreter, omit `--managed-python` and supply its path to `--python`. Do not silently fall back to a global package environment. Keep any additional dependency caches project-local too.
|
|
45
|
+
|
|
46
|
+
### Start the server separately
|
|
47
|
+
|
|
48
|
+
With the same environment variables, start the server. These bind flags were exercised successfully with MLX-Audio 0.5.9; inspect `--help` when changing versions:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
"$KOKORO_HOME/venv/bin/python" -m mlx_audio.server --help
|
|
52
|
+
"$KOKORO_HOME/venv/bin/python" -m mlx_audio.server --host 127.0.0.1 --port 8000
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Bind only to **127.0.0.1:8000**, never `0.0.0.0` or a LAN address. Leave the process running to keep the model warm. Model files are downloaded only during explicit model setup or the first explicit Kokoro request, not by catalog browsing, selection, or native speech. The extension never installs Python packages, downloads models on startup, or launches the server automatically. Dependency/model setup can require internet access; synthesis sends response text only to the local service. Use a trusted local service that does not forward or log sensitive text.
|
|
56
|
+
|
|
57
|
+
## Integration contract
|
|
58
|
+
|
|
59
|
+
| Setting | Fixed behavior |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| Default origin | `http://127.0.0.1:8000` |
|
|
62
|
+
| Optional override | `OSDY_PI_KOKORO_URL`, set in Pi's launch environment |
|
|
63
|
+
| Allowed origins | HTTP with literal `127.0.0.1` or `[::1]` only; optional port, no path beyond `/`, credentials, query, or fragment. Hostnames including `localhost` are not accepted. Redirects are never followed. |
|
|
64
|
+
| Model | `mlx-community/Kokoro-82M-bf16` |
|
|
65
|
+
| Voices | `Kokoro Dora` → `ef_dora`; `Kokoro Alex` → `em_alex`; `Kokoro Santa` → `em_santa` |
|
|
66
|
+
| Language / format | Spanish `lang_code: "e"`; complete WAV generated before playback |
|
|
67
|
+
| Rate | Existing wpm setting mapped to bounded Kokoro `speed = rate / 180`; 180 maps to 1.0, not a measured words-per-minute promise |
|
|
68
|
+
| Settings | Existing v1 voice-name setting; no new engine selector, command grammar, or migration |
|
|
69
|
+
|
|
70
|
+
This is **not audio streaming**. Automatic speech waits for the successful settled final assistant response; it does not speak unfinished responses, thinking, tools, or fenced code. The first cold request may include model download/loading and be much slower than warm synthesis. Long responses also wait for the full WAV before playback; no latency or quality target is established here.
|
|
71
|
+
|
|
72
|
+
## Failures and verification
|
|
73
|
+
|
|
74
|
+
An unavailable server, missing Spanish dependency, rejected origin, synthesis failure, or playback failure is an error—not permission to silently substitute a native voice. Explicit playback reports failure; automatic speech failures do not interrupt the agent. Choose an installed native voice explicitly if you want to continue without Kokoro. Speech remains independent of Sounds mute.
|
|
75
|
+
|
|
76
|
+
### Observed local synthesis
|
|
77
|
+
|
|
78
|
+
An isolated MLX-Audio 0.5.9 server on an M3 Pro / 18 GiB generated the same short Spanish sample through `/v1/audio/speech` with the fixed model/language/WAV contract:
|
|
79
|
+
|
|
80
|
+
| Request | Complete WAV generation | Audio duration |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| Dora, first inference after download | 5.664 s | 4.350 s |
|
|
83
|
+
| Dora, same sample with warm model | 0.268 s | 4.350 s |
|
|
84
|
+
| Alex | 1.176 s | 4.325 s |
|
|
85
|
+
| Santa | 1.123 s | 4.325 s |
|
|
86
|
+
|
|
87
|
+
The one-time model download took about 291 seconds. These are short-sample measurements, not general latency guarantees or time-to-first-stream benchmarks. Generated WAVs were validated without playing sound; no subjective naturalness or accent ranking is established. Automated tests and independent public-interface probes passed for request cancellation, native independence, selector drafts and settings preservation. The independent check also exercised a real filesystem and a harmless child process: private directory/file modes were 0700/0600, Stop terminated the process, and WAV cleanup waited for child close. No audible player was invoked.
|
|
88
|
+
|
|
89
|
+
**Long Spanish responses are not yet validated.** MLX-Audio's Spanish phonemizer warns: `Chunking logic not yet implemented, so long texts may be truncated unless you split them with '\n'.` Osdy currently normalizes newlines before synthesis, so adding newlines to a response is not an app-level workaround. Prefer a native voice for long responses until Kokoro chunking/completeness is evaluated. Listening quality, audible-device playback, physical shortcut delivery, clean-machine installation and installed-package execution remain unverified.
|
|
90
|
+
|
|
91
|
+
Primary references: [MLX-Audio server and TTS guidance](https://github.com/Blaizzy/mlx-audio) and [Kokoro-82M model/language documentation](https://huggingface.co/hexgrad/Kokoro-82M).
|
|
@@ -52,6 +52,28 @@ void test("Speech displays enabled/voice/rate and routes only Enable/Disable, Re
|
|
|
52
52
|
assert.deepEqual(actions, ["on", "off", "last", "stop"]);
|
|
53
53
|
panel.dispose();
|
|
54
54
|
});
|
|
55
|
+
void test("Speech explains native and optional Kokoro previews without adding controls", async () => {
|
|
56
|
+
const previews: string[] = [];
|
|
57
|
+
const speech = createControlCenterSpeech({
|
|
58
|
+
settings: () => Promise.resolve({ version: 1, enabled: false, voice: "Paulina", rate: 180 }),
|
|
59
|
+
voices: () => Promise.resolve(["Paulina", "Kokoro Dora"]),
|
|
60
|
+
selectVoice: () => { throw new Error("must not save while browsing or previewing"); },
|
|
61
|
+
previewVoice: voice => { previews.push(voice); return Promise.resolve({ failed: false, message: "Preview started" }); },
|
|
62
|
+
stopPreview: () => {}, command: () => { throw new Error("must not enable narration"); },
|
|
63
|
+
}, {} as ExtensionContext);
|
|
64
|
+
const initial = await speech.read();
|
|
65
|
+
assert.match(initial.note, /installed macOS voices/);
|
|
66
|
+
assert.match(initial.note, /optional Kokoro.*local server/i);
|
|
67
|
+
assert.equal(initial.rows.length, 5);
|
|
68
|
+
const draft = speech.moveVoice(1)!;
|
|
69
|
+
assert.match(draft.summary, /disabled.*Paulina.*180 wpm/);
|
|
70
|
+
assert.deepEqual(draft.rows[3]?.action, { kind: "speech-voice", voice: "Kokoro Dora" });
|
|
71
|
+
assert.deepEqual(draft.rows[4]?.action, { kind: "speech-preview", voice: "Kokoro Dora" });
|
|
72
|
+
assert.match(draft.rows[4]?.details?.join(" ") ?? "", /Spanish.*Kokoro.*English.*native/);
|
|
73
|
+
await speech.apply({ kind: "speech-preview", voice: "Kokoro Dora" });
|
|
74
|
+
assert.deepEqual(previews, ["Kokoro Dora"]);
|
|
75
|
+
assert.match((await speech.read()).rows[3]!.label, /Kokoro Dora.*unsaved/);
|
|
76
|
+
});
|
|
55
77
|
void test("Stale Control Center speech actions cannot launch in a replaced session", async () => {
|
|
56
78
|
let calls = 0;
|
|
57
79
|
const speech = createControlCenterSpeech({
|
|
@@ -22,12 +22,12 @@ export function createControlCenterSpeech(speech: ResponseSpeech, ctx: Extension
|
|
|
22
22
|
let voices: readonly string[] = [];
|
|
23
23
|
let savedVoice: string | undefined;
|
|
24
24
|
let draft: string | undefined;
|
|
25
|
-
let voiceNote = "No
|
|
25
|
+
let voiceNote = "No voices available.";
|
|
26
26
|
const project = (): ControlCenterDetail => {
|
|
27
27
|
if (!settings) return { summary: "Speech unavailable", note: voiceNote, rows: [] };
|
|
28
28
|
return {
|
|
29
29
|
summary: `Speech: ${settings.enabled ? "enabled" : "disabled"} · Saved voice: ${settings.voice} · Rate: ${settings.rate} wpm`,
|
|
30
|
-
note: `Local macOS
|
|
30
|
+
note: `Local speech: installed macOS voices or optional Kokoro (requires a separately started local server). Independent of Sounds mute. Read last response uses the saved voice while disabled. ${voiceNote}`,
|
|
31
31
|
rows: [
|
|
32
32
|
{ label: settings.enabled ? "Disable" : "Enable", current: false, action: { kind: "speech-enabled", value: !settings.enabled } },
|
|
33
33
|
{ label: "Read last response", current: false, action: { kind: "speech-last" } },
|
|
@@ -37,7 +37,7 @@ export function createControlCenterSpeech(speech: ResponseSpeech, ctx: Extension
|
|
|
37
37
|
action: { kind: "speech-voice" as const, voice: draft }, speechSelector: true,
|
|
38
38
|
details: ["←/→ browse draft (wraps); Enter saves. Browsing and saving never play audio.", `Saved voice: ${settings.voice}. Preview voice explicitly plays the draft without saving.`] },
|
|
39
39
|
{ label: "Preview voice", current: false, action: { kind: "speech-preview" as const, voice: draft },
|
|
40
|
-
details: ["Play a fixed
|
|
40
|
+
details: ["Play a fixed sample: Spanish for Kokoro, English for native voices; does not save or enable narration."] },
|
|
41
41
|
]),
|
|
42
42
|
],
|
|
43
43
|
};
|
|
@@ -46,7 +46,7 @@ export function createControlCenterSpeech(speech: ResponseSpeech, ctx: Extension
|
|
|
46
46
|
async read() {
|
|
47
47
|
const nextSettings = await speech.settings();
|
|
48
48
|
let nextVoices: readonly string[] = [];
|
|
49
|
-
voiceNote = "No
|
|
49
|
+
voiceNote = "No voices available.";
|
|
50
50
|
try {
|
|
51
51
|
nextVoices = await speech.voices(ctx);
|
|
52
52
|
if (nextVoices.length) voiceNote = "←/→ on Voice browses; Enter saves; Preview voice plays an explicit sample. Tab returns to categories.";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
// @ts-expect-error Native Node tests import TypeScript sources.
|
|
5
|
+
import { createKokoroAudioPlayer } from "./kokoro-audio-player.ts";
|
|
6
|
+
|
|
7
|
+
export function wav(): Uint8Array {
|
|
8
|
+
const bytes = Buffer.alloc(46);
|
|
9
|
+
bytes.write("RIFF"); bytes.writeUInt32LE(38, 4); bytes.write("WAVEfmt ", 8);
|
|
10
|
+
bytes.writeUInt32LE(16, 16); bytes.writeUInt16LE(1, 20); bytes.writeUInt16LE(1, 22);
|
|
11
|
+
bytes.writeUInt32LE(24000, 24); bytes.writeUInt32LE(48000, 28);
|
|
12
|
+
bytes.writeUInt16LE(2, 32); bytes.writeUInt16LE(16, 34);
|
|
13
|
+
bytes.write("data", 36); bytes.writeUInt32LE(2, 40);
|
|
14
|
+
return bytes;
|
|
15
|
+
}
|
|
16
|
+
class Child extends EventEmitter {
|
|
17
|
+
kills: string[] = [];
|
|
18
|
+
kill(signal: string): boolean { this.kills.push(signal); return true; }
|
|
19
|
+
}
|
|
20
|
+
function fixture() {
|
|
21
|
+
const child = new Child();
|
|
22
|
+
const calls: unknown[] = [];
|
|
23
|
+
let create = (): Promise<string> => Promise.resolve("/private/test");
|
|
24
|
+
let write = (): Promise<void> => Promise.resolve();
|
|
25
|
+
let remove = (): Promise<void> => Promise.resolve();
|
|
26
|
+
let spawnHook = (): void => {};
|
|
27
|
+
const player = createKokoroAudioPlayer({
|
|
28
|
+
fs: {
|
|
29
|
+
createDirectory: () => { calls.push("create"); return create(); },
|
|
30
|
+
write: (path, audio) => { calls.push(["write", path, audio.byteLength]); return write(); },
|
|
31
|
+
remove: (directory) => { calls.push(["remove", directory]); return remove(); },
|
|
32
|
+
},
|
|
33
|
+
spawn: (command, args) => { calls.push([command, args]); spawnHook(); return child; },
|
|
34
|
+
});
|
|
35
|
+
return { player, child, calls, setSpawn: (fn: typeof spawnHook) => { spawnHook = fn; }, setCreate: (fn: typeof create) => { create = fn; },
|
|
36
|
+
setWrite: (fn: typeof write) => { write = fn; }, setRemove: (fn: typeof remove) => { remove = fn; } };
|
|
37
|
+
}
|
|
38
|
+
async function tick(): Promise<void> { await new Promise<void>((resolve) => setImmediate(resolve)); }
|
|
39
|
+
void test("player separates launch from completion and cleans only after termination", async () => {
|
|
40
|
+
const f = fixture(); let launched = false;
|
|
41
|
+
const pending = f.player.play(wav(), new AbortController().signal).then((value) => { launched = true; return value; });
|
|
42
|
+
await tick(); assert.equal(launched, false);
|
|
43
|
+
assert.deepEqual(f.calls, ["create", ["write", "/private/test/speech.wav", 46], ["/usr/bin/afplay", ["/private/test/speech.wav"]]]);
|
|
44
|
+
f.child.emit("spawn"); const playback = await pending;
|
|
45
|
+
assert.equal(f.calls.length, 3);
|
|
46
|
+
f.child.emit("close", 0); await playback.done;
|
|
47
|
+
assert.deepEqual(f.calls[3], ["remove", "/private/test"]);
|
|
48
|
+
});
|
|
49
|
+
void test("malformed/empty/oversized WAV never creates or spawns", async () => {
|
|
50
|
+
for (const audio of [new Uint8Array(), new Uint8Array(44), new Uint8Array(25 * 1024 * 1024 + 1), wav().slice(0, 45)]) {
|
|
51
|
+
const f = fixture(); await assert.rejects(f.player.play(audio, new AbortController().signal), /WAV/i);
|
|
52
|
+
assert.deepEqual(f.calls, []);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
void test("RIFF metadata and chunk boundaries are validated before any filesystem call", async () => {
|
|
56
|
+
for (const mutate of [
|
|
57
|
+
(audio: Buffer) => audio.writeUInt32LE(999, 4),
|
|
58
|
+
(audio: Buffer) => audio.writeUInt32LE(999, 16),
|
|
59
|
+
(audio: Buffer) => audio.writeUInt16LE(99, 20),
|
|
60
|
+
(audio: Buffer) => audio.writeUInt16LE(0, 22),
|
|
61
|
+
(audio: Buffer) => audio.writeUInt32LE(0, 24),
|
|
62
|
+
(audio: Buffer) => audio.writeUInt32LE(0, 28),
|
|
63
|
+
(audio: Buffer) => audio.writeUInt16LE(0, 32),
|
|
64
|
+
(audio: Buffer) => audio.writeUInt16LE(0, 34),
|
|
65
|
+
(audio: Buffer) => audio.writeUInt32LE(0, 40),
|
|
66
|
+
]) {
|
|
67
|
+
const f = fixture(); const audio = Buffer.from(wav()); mutate(audio);
|
|
68
|
+
await assert.rejects(f.player.play(audio, new AbortController().signal), /WAV/); assert.deepEqual(f.calls, []);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
void test("non-ASCII lookalike RIFF bytes must not pass envelope validation", async () => {
|
|
72
|
+
const f = fixture(); const audio = Buffer.from(wav()); audio[0] = 0xd2;
|
|
73
|
+
f.setSpawn(() => { queueMicrotask(() => { f.child.emit("spawn"); f.child.emit("close", 0); }); });
|
|
74
|
+
await assert.rejects(f.player.play(audio, new AbortController().signal), /WAV/);
|
|
75
|
+
assert.deepEqual(f.calls, []);
|
|
76
|
+
});
|
|
77
|
+
void test("pre-abort has no effects; synchronous spawn failure and abort inside spawn clean safely", async () => {
|
|
78
|
+
const pre = fixture(); const controller = new AbortController(); controller.abort();
|
|
79
|
+
await assert.rejects(pre.player.play(wav(), controller.signal), /cancel/i); assert.deepEqual(pre.calls, []);
|
|
80
|
+
const failed = fixture(); failed.setSpawn(() => { throw new Error("private prose"); });
|
|
81
|
+
await assert.rejects(failed.player.play(wav(), new AbortController().signal), /Audio playback failed/);
|
|
82
|
+
assert.deepEqual(failed.calls.at(-1), ["remove", "/private/test"]);
|
|
83
|
+
const f = fixture(); const during = new AbortController(); f.setSpawn(() => { during.abort(); });
|
|
84
|
+
const pending = f.player.play(wav(), during.signal); const rejection = assert.rejects(pending, /cancel/i);
|
|
85
|
+
await tick(); assert.deepEqual(f.child.kills, ["SIGKILL"]); assert.equal(f.calls.length, 3);
|
|
86
|
+
f.child.emit("close", null); await rejection; assert.equal(f.calls.length, 4);
|
|
87
|
+
});
|
|
88
|
+
void test("abort while creating or writing prevents spawn and still cleans", async () => {
|
|
89
|
+
for (const stage of ["create", "write"]) {
|
|
90
|
+
const f = fixture(); const controller = new AbortController();
|
|
91
|
+
let release!: () => void;
|
|
92
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
93
|
+
if (stage === "create") f.setCreate(async () => { await gate; return "/private/test"; });
|
|
94
|
+
else f.setWrite(() => gate);
|
|
95
|
+
const pending = f.player.play(wav(), controller.signal); await tick();
|
|
96
|
+
controller.abort(); controller.abort(); release();
|
|
97
|
+
await assert.rejects(pending, /cancel/i);
|
|
98
|
+
assert.equal(f.calls.some((call) => Array.isArray(call) && call[0] === "/usr/bin/afplay"), false);
|
|
99
|
+
assert.deepEqual(f.calls.at(-1), ["remove", "/private/test"]);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
void test("abort before spawn and during playback kills once, waits for close, then cleans", async () => {
|
|
103
|
+
for (const active of [false, true]) {
|
|
104
|
+
const f = fixture(); const controller = new AbortController();
|
|
105
|
+
const pending = f.player.play(wav(), controller.signal); await tick();
|
|
106
|
+
let done: Promise<unknown> = pending;
|
|
107
|
+
if (active) { f.child.emit("spawn"); done = (await pending).done; }
|
|
108
|
+
const rejection = assert.rejects(done, /cancel/i);
|
|
109
|
+
controller.abort(); controller.abort(); await tick();
|
|
110
|
+
assert.deepEqual(f.child.kills, ["SIGKILL"]); assert.equal(f.calls.length, 3);
|
|
111
|
+
f.child.emit("spawn"); f.child.emit("close", null); await rejection;
|
|
112
|
+
assert.deepEqual(f.calls.at(-1), ["remove", "/private/test"]);
|
|
113
|
+
f.child.emit("error", new Error("late secret"));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
void test("process and filesystem failures stay private and clean artifacts", async () => {
|
|
117
|
+
const f = fixture(); const pending = f.player.play(wav(), new AbortController().signal); await tick();
|
|
118
|
+
const rejection = assert.rejects(pending, /Audio playback failed/);
|
|
119
|
+
f.child.emit("error", new Error("private response prose"));
|
|
120
|
+
assert.equal(f.calls.length, 3); f.child.emit("close", -1); await rejection;
|
|
121
|
+
assert.equal(f.calls.length, 4);
|
|
122
|
+
const failed = fixture(); failed.setWrite(() => Promise.reject(new Error("private path")));
|
|
123
|
+
await assert.rejects(failed.player.play(wav(), new AbortController().signal), /Audio playback failed/);
|
|
124
|
+
assert.deepEqual(failed.calls.at(-1), ["remove", "/private/test"]);
|
|
125
|
+
});
|
|
126
|
+
void test("abort during cleanup rejects completion and cleanup failure is observable", async () => {
|
|
127
|
+
const f = fixture(); const controller = new AbortController(); let release!: () => void;
|
|
128
|
+
f.setRemove(() => new Promise<void>((resolve) => { release = resolve; }));
|
|
129
|
+
const pending = f.player.play(wav(), controller.signal); await tick(); f.child.emit("spawn");
|
|
130
|
+
const playback = await pending; f.child.emit("close", 0); await tick(); controller.abort(); release();
|
|
131
|
+
await assert.rejects(playback.done, /cancel/i);
|
|
132
|
+
const failed = fixture(); failed.setRemove(() => Promise.reject(new Error("secret path")));
|
|
133
|
+
const next = failed.player.play(wav(), new AbortController().signal); await tick(); failed.child.emit("spawn");
|
|
134
|
+
const handle = await next; failed.child.emit("close", 1);
|
|
135
|
+
await assert.rejects(handle.done, /Audio cleanup failed/);
|
|
136
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { SpeechPlayback } from "./speech-adapter.js";
|
|
6
|
+
|
|
7
|
+
export const MAX_KOKORO_WAV_BYTES = 25 * 1024 * 1024;
|
|
8
|
+
export interface KokoroAudioPlayer {
|
|
9
|
+
play(audio: Uint8Array, signal: AbortSignal): Promise<SpeechPlayback>;
|
|
10
|
+
}
|
|
11
|
+
export interface KokoroAudioProcess {
|
|
12
|
+
on(event: "spawn", listener: () => void): this;
|
|
13
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
14
|
+
on(event: "close", listener: (code: number | null) => void): this;
|
|
15
|
+
kill(signal: "SIGKILL"): boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface KokoroAudioFileSystem {
|
|
18
|
+
createDirectory(): Promise<string>;
|
|
19
|
+
write(path: string, audio: Uint8Array): Promise<void>;
|
|
20
|
+
remove(directory: string): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface KokoroAudioPlayerOptions {
|
|
23
|
+
fs?: KokoroAudioFileSystem;
|
|
24
|
+
spawn?: (command: string, args: readonly string[]) => KokoroAudioProcess;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Require a complete, nonempty PCM/IEEE-float RIFF WAVE, not a MIME claim. */
|
|
28
|
+
export function validateKokoroWav(audio: Uint8Array): void {
|
|
29
|
+
const invalid = (): never => { throw new Error("Invalid or oversized Kokoro WAV audio"); };
|
|
30
|
+
if (audio.byteLength < 44 || audio.byteLength > MAX_KOKORO_WAV_BYTES) invalid();
|
|
31
|
+
const bytes = Buffer.from(audio.buffer, audio.byteOffset, audio.byteLength);
|
|
32
|
+
if (bytes.toString("latin1", 0, 4) !== "RIFF" || bytes.toString("latin1", 8, 12) !== "WAVE" ||
|
|
33
|
+
bytes.readUInt32LE(4) !== bytes.length - 8) invalid();
|
|
34
|
+
let format = false;
|
|
35
|
+
let dataSize = 0;
|
|
36
|
+
let alignment = 0;
|
|
37
|
+
let offset = 12;
|
|
38
|
+
while (offset + 8 <= bytes.length) {
|
|
39
|
+
const id = bytes.toString("latin1", offset, offset + 4);
|
|
40
|
+
const size = bytes.readUInt32LE(offset + 4);
|
|
41
|
+
const start = offset + 8;
|
|
42
|
+
const end = start + size;
|
|
43
|
+
if (end > bytes.length) invalid();
|
|
44
|
+
if (id === "fmt ") {
|
|
45
|
+
if (format || size < 16) invalid();
|
|
46
|
+
const encoding = bytes.readUInt16LE(start);
|
|
47
|
+
const channels = bytes.readUInt16LE(start + 2);
|
|
48
|
+
const sampleRate = bytes.readUInt32LE(start + 4);
|
|
49
|
+
const byteRate = bytes.readUInt32LE(start + 8);
|
|
50
|
+
alignment = bytes.readUInt16LE(start + 12);
|
|
51
|
+
const bits = bytes.readUInt16LE(start + 14);
|
|
52
|
+
if (![1, 3].includes(encoding) || channels < 1 || channels > 2 || sampleRate < 8000 || sampleRate > 192000 ||
|
|
53
|
+
!(encoding === 3 ? [32, 64] : [8, 16, 24, 32]).includes(bits) ||
|
|
54
|
+
alignment !== channels * bits / 8 || byteRate !== sampleRate * alignment) invalid();
|
|
55
|
+
format = true;
|
|
56
|
+
}
|
|
57
|
+
if (id === "data") {
|
|
58
|
+
if (!format || dataSize !== 0 || size === 0 || size % alignment !== 0) invalid();
|
|
59
|
+
dataSize = size;
|
|
60
|
+
}
|
|
61
|
+
offset = end + size % 2;
|
|
62
|
+
}
|
|
63
|
+
if (!format || dataSize === 0 || offset !== bytes.length) invalid();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cancelled(): Error { return new Error("Speech cancelled"); }
|
|
67
|
+
function checkAbort(signal: AbortSignal): void { if (signal.aborted) throw cancelled(); }
|
|
68
|
+
|
|
69
|
+
const privateFileSystem: KokoroAudioFileSystem = {
|
|
70
|
+
createDirectory: () => mkdtemp(join(tmpdir(), "osdy-kokoro-")),
|
|
71
|
+
write: (path, audio) => writeFile(path, audio, {mode: 0o600, flag: "wx"}),
|
|
72
|
+
remove: (directory) => rm(directory, {recursive: true, force: true}),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function createKokoroAudioPlayer(options: KokoroAudioPlayerOptions = {}): KokoroAudioPlayer {
|
|
76
|
+
const fs = options.fs ?? privateFileSystem;
|
|
77
|
+
const spawnProcess = options.spawn ?? ((command, args) => spawn(command, [...args], {shell: false, stdio: "ignore"}));
|
|
78
|
+
return {
|
|
79
|
+
async play(audio, signal) {
|
|
80
|
+
checkAbort(signal);
|
|
81
|
+
validateKokoroWav(audio);
|
|
82
|
+
let directory: string | undefined;
|
|
83
|
+
const cleanup = async (): Promise<void> => {
|
|
84
|
+
if (directory === undefined) return;
|
|
85
|
+
try { await fs.remove(directory); }
|
|
86
|
+
catch { throw new Error("Audio cleanup failed"); }
|
|
87
|
+
};
|
|
88
|
+
let child: KokoroAudioProcess;
|
|
89
|
+
try {
|
|
90
|
+
directory = await fs.createDirectory();
|
|
91
|
+
checkAbort(signal);
|
|
92
|
+
const file = join(directory, "speech.wav");
|
|
93
|
+
await fs.write(file, audio);
|
|
94
|
+
checkAbort(signal);
|
|
95
|
+
child = spawnProcess("/usr/bin/afplay", [file]);
|
|
96
|
+
} catch {
|
|
97
|
+
await cleanup();
|
|
98
|
+
throw signal.aborted ? cancelled() : new Error("Audio playback failed");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let resolveLaunch!: () => void;
|
|
102
|
+
let rejectLaunch!: (error: Error) => void;
|
|
103
|
+
let resolveExit!: () => void;
|
|
104
|
+
let rejectExit!: (error: Error) => void;
|
|
105
|
+
let failure: Error | undefined;
|
|
106
|
+
let closed = false;
|
|
107
|
+
let killed = false;
|
|
108
|
+
const launched = new Promise<void>((resolve, reject) => { resolveLaunch = resolve; rejectLaunch = reject; });
|
|
109
|
+
const exited = new Promise<void>((resolve, reject) => { resolveExit = resolve; rejectExit = reject; });
|
|
110
|
+
void launched.catch(() => {});
|
|
111
|
+
const abort = (): void => {
|
|
112
|
+
if (closed || failure) return;
|
|
113
|
+
failure = cancelled();
|
|
114
|
+
rejectLaunch(failure);
|
|
115
|
+
if (!killed) { killed = true; child.kill("SIGKILL"); }
|
|
116
|
+
};
|
|
117
|
+
child.on("spawn", () => {
|
|
118
|
+
if (!failure && !signal.aborted) resolveLaunch();
|
|
119
|
+
});
|
|
120
|
+
child.on("error", () => {
|
|
121
|
+
if (closed || failure) return;
|
|
122
|
+
failure = new Error("Audio playback failed");
|
|
123
|
+
rejectLaunch(failure);
|
|
124
|
+
if (!killed) { killed = true; child.kill("SIGKILL"); }
|
|
125
|
+
});
|
|
126
|
+
child.on("close", (code) => {
|
|
127
|
+
if (closed) return;
|
|
128
|
+
closed = true;
|
|
129
|
+
const error = signal.aborted ? cancelled() : failure ?? (code === 0 ? undefined : new Error("Audio playback failed"));
|
|
130
|
+
if (error) { rejectLaunch(error); rejectExit(error); }
|
|
131
|
+
else { resolveLaunch(); resolveExit(); }
|
|
132
|
+
});
|
|
133
|
+
signal.addEventListener("abort", abort, {once: true});
|
|
134
|
+
if (signal.aborted) abort();
|
|
135
|
+
// Cleanup is owned by completion, never by the launch event or kill request.
|
|
136
|
+
const done = (async (): Promise<void> => {
|
|
137
|
+
try { await exited; }
|
|
138
|
+
finally {
|
|
139
|
+
try { await cleanup(); }
|
|
140
|
+
finally { signal.removeEventListener("abort", abort); }
|
|
141
|
+
}
|
|
142
|
+
checkAbort(signal);
|
|
143
|
+
})();
|
|
144
|
+
void done.catch(() => {});
|
|
145
|
+
try { await launched; }
|
|
146
|
+
catch (error) { await done.catch(() => {}); throw error; }
|
|
147
|
+
return {done};
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
// @ts-expect-error Native Node tests import TypeScript sources.
|
|
4
|
+
import { createKokoroSpeechAdapter, createLocalSpeechAdapter } from "./kokoro-speech-adapter.ts";
|
|
5
|
+
import type { SpeechAdapter } from "./speech-adapter.js";
|
|
6
|
+
|
|
7
|
+
function wav(): Uint8Array<ArrayBuffer> {
|
|
8
|
+
const bytes = Buffer.alloc(46);
|
|
9
|
+
bytes.write("RIFF"); bytes.writeUInt32LE(38, 4); bytes.write("WAVEfmt ", 8);
|
|
10
|
+
bytes.writeUInt32LE(16, 16); bytes.writeUInt16LE(1, 20); bytes.writeUInt16LE(1, 22);
|
|
11
|
+
bytes.writeUInt32LE(24000, 24); bytes.writeUInt32LE(48000, 28);
|
|
12
|
+
bytes.writeUInt16LE(2, 32); bytes.writeUInt16LE(16, 34);
|
|
13
|
+
bytes.write("data", 36); bytes.writeUInt32LE(2, 40); return new Uint8Array(bytes);
|
|
14
|
+
}
|
|
15
|
+
function fixture(url: unknown = "http://127.0.0.1:8000", platform = "darwin") {
|
|
16
|
+
const requests: Array<{ url: string; init: RequestInit }> = [];
|
|
17
|
+
const plays: Uint8Array[] = [];
|
|
18
|
+
let respond: (init: RequestInit) => Promise<Response> = () => Promise.resolve(new Response(wav()));
|
|
19
|
+
const adapter = createKokoroSpeechAdapter({ platform, url,
|
|
20
|
+
fetch: (url, init) => { requests.push({url, init}); return respond(init); },
|
|
21
|
+
player: { play: (audio) => { plays.push(audio); return Promise.resolve({ done: Promise.resolve() }); } },
|
|
22
|
+
});
|
|
23
|
+
return { adapter, requests, plays, setResponse: (fn: typeof respond) => { respond = fn; } };
|
|
24
|
+
}
|
|
25
|
+
void test("construction and listing never fetch; fixed Spanish voices and full prose payload", async () => {
|
|
26
|
+
const f = fixture(); const signal = new AbortController().signal;
|
|
27
|
+
assert.equal(f.adapter.available(), true); assert.deepEqual(f.requests, []);
|
|
28
|
+
assert.deepEqual(await f.adapter.voices(signal), ["Kokoro Dora", "Kokoro Alex", "Kokoro Santa"]);
|
|
29
|
+
assert.deepEqual(f.requests, []);
|
|
30
|
+
for (const [voice, id] of [["Kokoro Dora", "ef_dora"], ["Kokoro Alex", "em_alex"], ["Kokoro Santa", "em_santa"]]) {
|
|
31
|
+
const text = "Buenos días. ".repeat(2000);
|
|
32
|
+
await (await f.adapter.speak(text, voice!, 270, signal)).done;
|
|
33
|
+
const request: {url: string; init: RequestInit} = f.requests.at(-1)!;
|
|
34
|
+
assert.equal(request.url, "http://127.0.0.1:8000/v1/audio/speech");
|
|
35
|
+
assert.equal(request.init.redirect, "error"); assert.equal(request.init.method, "POST");
|
|
36
|
+
assert.ok(typeof request.init.body === "string");
|
|
37
|
+
assert.deepEqual(JSON.parse(request.init.body), { model: "mlx-community/Kokoro-82M-bf16", input: text,
|
|
38
|
+
voice: id, lang_code: "e", response_format: "wav", stream: false, speed: 1.5 });
|
|
39
|
+
}
|
|
40
|
+
assert.equal(f.plays.length, 3);
|
|
41
|
+
});
|
|
42
|
+
void test("only explicit literal loopback HTTP origins are accepted", async () => {
|
|
43
|
+
for (const url of ["http://127.0.0.1", "http://[::1]:8000/"]) {
|
|
44
|
+
const f = fixture(url); await f.adapter.speak("hola", "Kokoro Dora", 180, new AbortController().signal);
|
|
45
|
+
assert.equal(f.requests.length, 1);
|
|
46
|
+
}
|
|
47
|
+
for (const url of ["https://127.0.0.1", "http://localhost", "http://example.com", "http://127.1", "http://2130706433",
|
|
48
|
+
"http://127.0.0.1/path", "http://127.0.0.1?x", "http://127.0.0.1#x", "http://user@127.0.0.1", "http://127.0.0.1:99999", {}, null, ""]) {
|
|
49
|
+
const f = fixture(url);
|
|
50
|
+
await assert.rejects(f.adapter.speak("private", "Kokoro Dora", 180, new AbortController().signal), /loopback/i);
|
|
51
|
+
assert.deepEqual(f.requests, []); assert.deepEqual(f.plays, []);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
void test("invalid voices/rates and unsupported platforms never fetch or play", async () => {
|
|
55
|
+
for (const [voice, rate] of [["Paulina", 180], ["Kokoro Dora", 79], ["Kokoro Dora", 351], ["Kokoro Dora", NaN], ["Kokoro Dora", 180.5]] as const) {
|
|
56
|
+
const f = fixture(); await assert.rejects(f.adapter.speak("hola", voice, rate, new AbortController().signal));
|
|
57
|
+
assert.deepEqual(f.requests, []); assert.deepEqual(f.plays, []);
|
|
58
|
+
}
|
|
59
|
+
const f = fixture(undefined, "linux"); assert.equal(f.adapter.available(), false);
|
|
60
|
+
assert.deepEqual(await f.adapter.voices(new AbortController().signal), []);
|
|
61
|
+
await assert.rejects(f.adapter.speak("hola", "Kokoro Dora", 180, new AbortController().signal), /macOS/);
|
|
62
|
+
assert.deepEqual(f.requests, []);
|
|
63
|
+
for (const rate of [80, 350]) {
|
|
64
|
+
const mac = fixture(); await mac.adapter.speak("hola", "Kokoro Dora", rate, new AbortController().signal);
|
|
65
|
+
const body = mac.requests[0]!.init.body;
|
|
66
|
+
assert.ok(typeof body === "string");
|
|
67
|
+
const payload: unknown = JSON.parse(body);
|
|
68
|
+
assert.ok(typeof payload === "object" && payload !== null && "speed" in payload);
|
|
69
|
+
assert.equal(payload.speed, rate / 180);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
void test("HTTP, redirect, unavailable server and invalid audio errors disclose no response text", async () => {
|
|
73
|
+
for (const response of [new Response("secret prose", {status: 500}), new Response("secret prose", {status: 302, headers: {location: "https://example.com"}}),
|
|
74
|
+
new Response("secret prose", {headers: {"content-type": "audio/wav"}}), new Response(new Uint8Array()),
|
|
75
|
+
new Response(wav(), {headers: {"content-length": String(25 * 1024 * 1024 + 1)}}), new Response(new Uint8Array(25 * 1024 * 1024 + 1))]) {
|
|
76
|
+
const f = fixture(); f.setResponse(() => Promise.resolve(response));
|
|
77
|
+
await assert.rejects(f.adapter.speak("private", "Kokoro Dora", 180, new AbortController().signal),
|
|
78
|
+
(error: unknown) => error instanceof Error && !error.message.includes("secret prose"));
|
|
79
|
+
assert.deepEqual(f.plays, []);
|
|
80
|
+
}
|
|
81
|
+
const f = fixture(); f.setResponse(() => Promise.reject(new Error("secret prose")));
|
|
82
|
+
await assert.rejects(f.adapter.speak("private", "Kokoro Dora", 180, new AbortController().signal), /local.*MLX-Audio/i);
|
|
83
|
+
});
|
|
84
|
+
void test("pending synthesis cancellation settles even if transport ignores abort and prevents late play", async () => {
|
|
85
|
+
const f = fixture(); let release!: (response: Response) => void;
|
|
86
|
+
f.setResponse(() => new Promise<Response>((resolve) => { release = resolve; }));
|
|
87
|
+
const controller = new AbortController(); const pending = f.adapter.speak("hola", "Kokoro Dora", 180, controller.signal);
|
|
88
|
+
controller.abort(); controller.abort(); await assert.rejects(pending, /cancel/i);
|
|
89
|
+
assert.equal(f.requests[0]!.init.signal?.aborted, true);
|
|
90
|
+
release(new Response(wav())); await new Promise<void>((resolve) => setImmediate(resolve)); assert.deepEqual(f.plays, []);
|
|
91
|
+
const pre = new AbortController(); pre.abort();
|
|
92
|
+
await assert.rejects(f.adapter.speak("hola", "Kokoro Dora", 180, pre.signal), /cancel/i);
|
|
93
|
+
assert.equal(f.requests.length, 1);
|
|
94
|
+
});
|
|
95
|
+
void test("synthesis timeout is bounded even when transport ignores its signal", async (context) => {
|
|
96
|
+
context.mock.timers.enable({apis: ["setTimeout"]});
|
|
97
|
+
const f = fixture(); f.setResponse(() => new Promise<Response>(() => {}));
|
|
98
|
+
const pending = f.adapter.speak("hola", "Kokoro Dora", 180, new AbortController().signal);
|
|
99
|
+
const rejection = assert.rejects(pending, /timed out.*120 seconds/);
|
|
100
|
+
context.mock.timers.tick(120_000); await rejection;
|
|
101
|
+
assert.equal(f.requests[0]!.init.signal?.aborted, true); assert.deepEqual(f.plays, []);
|
|
102
|
+
});
|
|
103
|
+
void test("abort during response download cancels reader and prevents playback", async () => {
|
|
104
|
+
const f = fixture(); let cancelled = false;
|
|
105
|
+
const body = new ReadableStream<Uint8Array>({
|
|
106
|
+
start(controller) { controller.enqueue(wav().slice(0, 20)); },
|
|
107
|
+
cancel() { cancelled = true; },
|
|
108
|
+
});
|
|
109
|
+
f.setResponse(() => Promise.resolve(new Response(body)));
|
|
110
|
+
const controller = new AbortController(); const pending = f.adapter.speak("hola", "Kokoro Dora", 180, controller.signal);
|
|
111
|
+
await new Promise<void>((resolve) => setImmediate(resolve)); controller.abort();
|
|
112
|
+
await assert.rejects(pending, /cancel/i); assert.equal(cancelled, true); assert.deepEqual(f.plays, []);
|
|
113
|
+
});
|
|
114
|
+
void test("combined adapter retains native availability/catalog/errors and native speech never fetches", async () => {
|
|
115
|
+
let network = 0; const nativeCalls: unknown[] = []; const failure = new Error("native discovery failure");
|
|
116
|
+
const native: SpeechAdapter = { available: () => true, voices: () => Promise.resolve(["Paulina"]),
|
|
117
|
+
speak: (...args) => { nativeCalls.push(args); return Promise.resolve({done: Promise.resolve()}); } };
|
|
118
|
+
const combined = createLocalSpeechAdapter({ platform: "darwin", native, url: "https://unsafe.example",
|
|
119
|
+
fetch: () => { network++; return Promise.reject(new Error("must not fetch")); } });
|
|
120
|
+
const signal = new AbortController().signal;
|
|
121
|
+
assert.equal(combined.available(), true);
|
|
122
|
+
assert.deepEqual(await combined.voices(signal), ["Paulina", "Kokoro Dora", "Kokoro Alex", "Kokoro Santa"]);
|
|
123
|
+
await combined.speak("hola", "Paulina", 180, signal); assert.equal(nativeCalls.length, 1); assert.equal(network, 0);
|
|
124
|
+
await assert.rejects(combined.speak("hola", "Kokoro Dora", 180, signal), /loopback/i); assert.equal(network, 0);
|
|
125
|
+
native.voices = () => Promise.reject(failure); await assert.rejects(combined.voices(signal), (error: unknown) => error === failure);
|
|
126
|
+
const unsupported = createLocalSpeechAdapter({platform: "linux", native});
|
|
127
|
+
native.voices = () => Promise.resolve(["Paulina"]); assert.deepEqual(await unsupported.voices(signal), ["Paulina"]);
|
|
128
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { SpeechAdapter } from "./speech-adapter.js";
|
|
2
|
+
// @ts-expect-error Native Node tests import TypeScript sources.
|
|
3
|
+
import { createNativeSpeechAdapter } from "./speech-adapter.ts";
|
|
4
|
+
// @ts-expect-error Native Node tests import TypeScript sources.
|
|
5
|
+
import { validateSpeechSettings } from "./speech-settings.ts";
|
|
6
|
+
// @ts-expect-error Native Node tests import TypeScript sources.
|
|
7
|
+
import { createKokoroAudioPlayer, MAX_KOKORO_WAV_BYTES, validateKokoroWav } from "./kokoro-audio-player.ts";
|
|
8
|
+
import type { KokoroAudioPlayer } from "./kokoro-audio-player.js";
|
|
9
|
+
|
|
10
|
+
const VOICES = new Map([
|
|
11
|
+
["Kokoro Dora", "ef_dora"],
|
|
12
|
+
["Kokoro Alex", "em_alex"],
|
|
13
|
+
["Kokoro Santa", "em_santa"],
|
|
14
|
+
]);
|
|
15
|
+
const SYNTHESIS_TIMEOUT_MS = 120_000;
|
|
16
|
+
export interface KokoroSpeechAdapterOptions {
|
|
17
|
+
platform?: string;
|
|
18
|
+
/** Base origin only. Unknown configuration is validated before sending prose. */
|
|
19
|
+
url?: unknown;
|
|
20
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
21
|
+
player?: KokoroAudioPlayer;
|
|
22
|
+
}
|
|
23
|
+
export interface LocalSpeechAdapterOptions extends KokoroSpeechAdapterOptions {
|
|
24
|
+
native?: SpeechAdapter;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function endpoint(value: unknown): string {
|
|
28
|
+
const invalid = (): never => { throw new Error("Kokoro URL must be a literal HTTP loopback origin (127.0.0.1 or [::1])"); };
|
|
29
|
+
if (typeof value !== "string" || !/^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::[0-9]+)?\/?$/u.test(value)) return invalid();
|
|
30
|
+
let url: URL;
|
|
31
|
+
try { url = new URL(value); } catch { return invalid(); }
|
|
32
|
+
if (url.port === "0") return invalid();
|
|
33
|
+
return `${url.origin}/v1/audio/speech`;
|
|
34
|
+
}
|
|
35
|
+
function cancelled(): Error { return new Error("Speech cancelled"); }
|
|
36
|
+
function setupError(): Error {
|
|
37
|
+
return new Error("Kokoro synthesis failed. Start the local MLX-Audio server with mlx-community/Kokoro-82M-bf16; check local Kokoro setup and OSDY_PI_KOKORO_URL.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readWav(response: Response, signal: AbortSignal): Promise<Uint8Array> {
|
|
41
|
+
if (!response.ok || response.redirected || !response.body) {
|
|
42
|
+
await response.body?.cancel();
|
|
43
|
+
throw setupError();
|
|
44
|
+
}
|
|
45
|
+
const declared = response.headers.get("content-length");
|
|
46
|
+
if (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > MAX_KOKORO_WAV_BYTES)) {
|
|
47
|
+
await response.body.cancel();
|
|
48
|
+
throw new Error("Invalid or oversized Kokoro WAV audio");
|
|
49
|
+
}
|
|
50
|
+
const reader = response.body.getReader();
|
|
51
|
+
const chunks: Uint8Array[] = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
const abort = (): void => { void reader.cancel().catch(() => {}); };
|
|
54
|
+
signal.addEventListener("abort", abort, {once: true});
|
|
55
|
+
try {
|
|
56
|
+
if (signal.aborted) throw cancelled();
|
|
57
|
+
while (true) {
|
|
58
|
+
const chunk = await reader.read();
|
|
59
|
+
if (signal.aborted) throw cancelled();
|
|
60
|
+
if (chunk.done) break;
|
|
61
|
+
size += chunk.value.byteLength;
|
|
62
|
+
if (size > MAX_KOKORO_WAV_BYTES) throw new Error("Invalid or oversized Kokoro WAV audio");
|
|
63
|
+
chunks.push(chunk.value);
|
|
64
|
+
}
|
|
65
|
+
const audio = new Uint8Array(size);
|
|
66
|
+
let offset = 0;
|
|
67
|
+
for (const chunk of chunks) { audio.set(chunk, offset); offset += chunk.byteLength; }
|
|
68
|
+
validateKokoroWav(audio);
|
|
69
|
+
return audio;
|
|
70
|
+
} finally {
|
|
71
|
+
signal.removeEventListener("abort", abort);
|
|
72
|
+
void reader.cancel().catch(() => {});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createKokoroSpeechAdapter(options: KokoroSpeechAdapterOptions = {}): SpeechAdapter {
|
|
77
|
+
const platform = options.platform ?? process.platform;
|
|
78
|
+
const transport = options.fetch ?? fetch;
|
|
79
|
+
const player = options.player ?? createKokoroAudioPlayer();
|
|
80
|
+
// Reading configuration is inert: invalid Kokoro configuration cannot disable native speech.
|
|
81
|
+
const baseUrl: unknown = options.url !== undefined ? options.url : process.env.OSDY_PI_KOKORO_URL ?? "http://127.0.0.1:8000";
|
|
82
|
+
return {
|
|
83
|
+
available: () => platform === "darwin",
|
|
84
|
+
voices: (signal) => signal.aborted ? Promise.reject(cancelled()) : Promise.resolve(platform === "darwin" ? [...VOICES.keys()] : []),
|
|
85
|
+
async speak(text, voice, rate, signal) {
|
|
86
|
+
if (signal.aborted) throw cancelled();
|
|
87
|
+
if (platform !== "darwin") throw new Error("Kokoro playback is only available on macOS");
|
|
88
|
+
validateSpeechSettings({version: 1, enabled: false, voice, rate});
|
|
89
|
+
const mapped = VOICES.get(voice);
|
|
90
|
+
if (!mapped) throw new Error("Unknown Kokoro voice");
|
|
91
|
+
const url = endpoint(baseUrl);
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
let rejectCancelled!: (error: Error) => void;
|
|
94
|
+
const interrupted = new Promise<never>((_, reject) => { rejectCancelled = reject; });
|
|
95
|
+
const abort = (): void => { controller.abort(); rejectCancelled(cancelled()); };
|
|
96
|
+
signal.addEventListener("abort", abort, {once: true});
|
|
97
|
+
const timeout = setTimeout(() => {
|
|
98
|
+
controller.abort();
|
|
99
|
+
rejectCancelled(new Error("Kokoro synthesis timed out after 120 seconds. Check the local MLX-Audio setup."));
|
|
100
|
+
}, SYNTHESIS_TIMEOUT_MS);
|
|
101
|
+
let audio: Uint8Array;
|
|
102
|
+
try {
|
|
103
|
+
if (signal.aborted) abort();
|
|
104
|
+
const synthesis = (async (): Promise<Uint8Array> => {
|
|
105
|
+
if (controller.signal.aborted) throw cancelled();
|
|
106
|
+
const response = await transport(url, {
|
|
107
|
+
method: "POST", redirect: "error", signal: controller.signal,
|
|
108
|
+
headers: {"Content-Type": "application/json"},
|
|
109
|
+
body: JSON.stringify({model: "mlx-community/Kokoro-82M-bf16", input: text,
|
|
110
|
+
voice: mapped, lang_code: "e", response_format: "wav", stream: false,
|
|
111
|
+
// Existing validated 80–350 wpm maps to explicit speed bounds 4/9–35/18.
|
|
112
|
+
speed: rate / 180}),
|
|
113
|
+
});
|
|
114
|
+
if (controller.signal.aborted) { void response.body?.cancel().catch(() => {}); throw cancelled(); }
|
|
115
|
+
return readWav(response, controller.signal);
|
|
116
|
+
})();
|
|
117
|
+
audio = await Promise.race([synthesis, interrupted]);
|
|
118
|
+
} catch {
|
|
119
|
+
if (signal.aborted) throw cancelled();
|
|
120
|
+
if (controller.signal.aborted) throw new Error("Kokoro synthesis timed out after 120 seconds. Check the local MLX-Audio setup.");
|
|
121
|
+
throw setupError();
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timeout);
|
|
124
|
+
signal.removeEventListener("abort", abort);
|
|
125
|
+
}
|
|
126
|
+
if (signal.aborted) throw cancelled();
|
|
127
|
+
return player.play(audio, signal);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Native speech remains independent: no probing, fallback, settings or lifecycle ownership. */
|
|
133
|
+
export function createLocalSpeechAdapter(options: LocalSpeechAdapterOptions = {}): SpeechAdapter {
|
|
134
|
+
const native = options.native ?? createNativeSpeechAdapter(options);
|
|
135
|
+
const kokoro = createKokoroSpeechAdapter(options);
|
|
136
|
+
return {
|
|
137
|
+
available: () => native.available() || kokoro.available(),
|
|
138
|
+
async voices(signal) {
|
|
139
|
+
const voices = await native.voices(signal);
|
|
140
|
+
return [...voices, ...await kokoro.voices(signal)];
|
|
141
|
+
},
|
|
142
|
+
speak: (text, voice, rate, signal) => VOICES.has(voice)
|
|
143
|
+
? kokoro.speak(text, voice, rate, signal)
|
|
144
|
+
: native.speak(text, voice, rate, signal),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -26,6 +26,18 @@ function fixture() {
|
|
|
26
26
|
const service = createSpeechService({ adapter, store, onError: (error) => { errors.push(error); } });
|
|
27
27
|
return { service, adapter, store, calls, errors, done, stored: () => stored, saves: () => saves };
|
|
28
28
|
}
|
|
29
|
+
void test("production macOS catalog retains native voices and adds optional Kokoro without reading settings", { skip: process.platform !== "darwin" }, async () => {
|
|
30
|
+
let storeCalls = 0;
|
|
31
|
+
const service = createSpeechService({ store: {
|
|
32
|
+
load: () => { storeCalls++; return Promise.reject(new Error("Discovery must not read settings")); },
|
|
33
|
+
save: () => { storeCalls++; return Promise.reject(new Error("Discovery must not save settings")); },
|
|
34
|
+
} });
|
|
35
|
+
const voices = await service.voices();
|
|
36
|
+
for (const voice of ["Kokoro Dora", "Kokoro Alex", "Kokoro Santa"]) assert.ok(voices.includes(voice), voice);
|
|
37
|
+
assert.ok(voices.some(voice => !voice.startsWith("Kokoro ")));
|
|
38
|
+
assert.equal(storeCalls, 0);
|
|
39
|
+
service.stop();
|
|
40
|
+
});
|
|
29
41
|
void test("off by default; manual uses installed Paulina and returns before playback ends", async () => {
|
|
30
42
|
const f = fixture();
|
|
31
43
|
assert.equal(f.service.available(), true);
|
|
@@ -168,6 +180,25 @@ void test("preview uses the explicit installed voice and fixed sample at persist
|
|
|
168
180
|
assert.deepEqual(await f.service.settings(), before); assert.equal(f.saves(), saves);
|
|
169
181
|
f.service.stop(); f.done.resolve();
|
|
170
182
|
});
|
|
183
|
+
void test("only the three fixed Kokoro labels preview Spanish without saving or enabling", async () => {
|
|
184
|
+
const f = fixture();
|
|
185
|
+
const kokoro = ["Kokoro Dora", "Kokoro Alex", "Kokoro Santa"];
|
|
186
|
+
const native = ["Kokoro Dora (Enhanced)", "Kokoro Native", "Jorge"];
|
|
187
|
+
f.adapter.voices = () => Promise.resolve([...kokoro, ...native]);
|
|
188
|
+
const before = await f.service.settings();
|
|
189
|
+
for (const voice of [...kokoro, ...native]) {
|
|
190
|
+
assert.deepEqual(await f.service.preview(voice), {kind: "started"});
|
|
191
|
+
assert.equal(f.calls.at(-1)?.text, kokoro.includes(voice)
|
|
192
|
+
? "¡Hola! Esta es una muestra de mi voz en Osdy Pi. Puedo leer tus respuestas en español, con claridad y naturalidad."
|
|
193
|
+
: "Hello! This is a preview of my voice in Osdy Pi.");
|
|
194
|
+
assert.equal(f.calls.at(-1)?.voice, voice);
|
|
195
|
+
assert.equal(f.calls.at(-1)?.rate, 180);
|
|
196
|
+
assert.deepEqual(await f.service.settings(), before);
|
|
197
|
+
assert.equal(f.saves(), 0);
|
|
198
|
+
}
|
|
199
|
+
assert.equal((await f.service.speak("still disabled", {automatic: true})).kind, "disabled");
|
|
200
|
+
f.service.stop(); f.done.resolve();
|
|
201
|
+
});
|
|
171
202
|
void test("preview rejects invalid or uninstalled exact choices without fallback or settings writes", async () => {
|
|
172
203
|
const f = fixture(); const before = await f.service.settings();
|
|
173
204
|
for (const voice of ["", "bad\nvoice", "[[rate 999]]", "Missing", "Paulina"]) {
|
|
@@ -2,7 +2,7 @@ import type { SpeechResult, SpeechService, SpeechSettings } from "./speech-types
|
|
|
2
2
|
import type { SpeechAdapter } from "./speech-adapter.js";
|
|
3
3
|
import type { SpeechSettingsStore } from "./speech-settings.js";
|
|
4
4
|
// @ts-expect-error Native Node tests import TypeScript sources.
|
|
5
|
-
import {
|
|
5
|
+
import { createLocalSpeechAdapter } from "./kokoro-speech-adapter.ts";
|
|
6
6
|
// @ts-expect-error Native Node tests import TypeScript sources.
|
|
7
7
|
import { createSpeechSettingsStore, defaultSpeechSettings, validateSpeechSettings } from "./speech-settings.ts";
|
|
8
8
|
// @ts-expect-error Native Node tests import TypeScript sources.
|
|
@@ -25,7 +25,7 @@ function resolveVoice(installed: readonly string[], requested: string): string |
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function createSpeechService(options: SpeechServiceOptions = {}): SpeechService {
|
|
28
|
-
const adapter = options.adapter ??
|
|
28
|
+
const adapter = options.adapter ?? createLocalSpeechAdapter();
|
|
29
29
|
const store = options.store ?? createSpeechSettingsStore(options.agentDir);
|
|
30
30
|
let generation = 0;
|
|
31
31
|
let active: AbortController | undefined;
|
|
@@ -114,7 +114,9 @@ export function createSpeechService(options: SpeechServiceOptions = {}): SpeechS
|
|
|
114
114
|
configureQueue = update.then(() => {}, () => {});
|
|
115
115
|
return update;
|
|
116
116
|
},
|
|
117
|
-
preview: voice => launch("
|
|
117
|
+
preview: voice => launch(["Kokoro Dora", "Kokoro Alex", "Kokoro Santa"].includes(voice)
|
|
118
|
+
? "¡Hola! Esta es una muestra de mi voz en Osdy Pi. Puedo leer tus respuestas en español, con claridad y naturalidad."
|
|
119
|
+
: "Hello! This is a preview of my voice in Osdy Pi.", { voice }),
|
|
118
120
|
stopPreview() {
|
|
119
121
|
if (activeKind !== "preview") return;
|
|
120
122
|
generation++;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "osdy-pi",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.18",
|
|
4
4
|
"description": "Osdy Pi package: themes, header, and custom editor for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"README.md",
|
|
25
|
+
"docs/kokoro.md",
|
|
25
26
|
"LICENSE",
|
|
26
27
|
"bin",
|
|
27
28
|
"scripts/osdy-pi-account-profiles.mjs",
|
|
@@ -83,7 +84,7 @@
|
|
|
83
84
|
"typescript": "^5.9.3",
|
|
84
85
|
"typescript-eslint": "^8.46.3"
|
|
85
86
|
},
|
|
86
|
-
"releaseSource": "
|
|
87
|
+
"releaseSource": "1a36c12e766b5a18613c1d893af4c2f095b040f4",
|
|
87
88
|
"repository": {
|
|
88
89
|
"type": "git",
|
|
89
90
|
"url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"
|