@goodandready/dsh-tts 0.4.27 → 0.4.29

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/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.29
4
+
5
+ ### Fixed
6
+ - **Settings & Revision Management** (#151, #152):
7
+ - Added robust `extractRevision` supporting nested Schemastery namespaces and entries array.
8
+ - Return revision in `GET /dsh-tts/config` and send it in `PUT /dsh-tts/config`.
9
+ - Eliminated stale `savedConfig` caching in `live()`, enabling direct live Schemastery volatile box evaluation.
10
+ - **Audio Pipeline, Tool Schema & Dedup** (#156, #157, #166, #173, #175, #190):
11
+ - Enforced speech preparation, maxChars, and summarizeReply before sentence splitting in `speakAsItGoes`.
12
+ - Added `id: { type: 'string' }` to `speak_text` tool output schema under `additionalProperties: false` preventing core ToolOutputError.
13
+ - Included credential slot reference in cache and flight fingerprints avoiding cross-credential collisions.
14
+ - In-memory index for synthesis cache avoiding O(N) fs scans.
15
+ - **Stream Hub & Web Player** (#158, #159, #160, #161, #174, #176, #186):
16
+ - `skipCurrent()` now cancels active browser SpeechSynthesis and clears busy state.
17
+ - `playAudio` resolves promise on NotAllowedError and detaches gesture listeners cleanly.
18
+ - Disconnect slow SSE subscribers exceeding backpressure buffer threshold.
19
+ - Registered updater routes inside ctx.effect with clean disposers.
20
+ - **Providers & Local Engines** (#165, #167, #168, #169, #170, #171, #172, #177, #178, #179, #180):
21
+ - Nested integrations parser in UI and gated /speak on disabled channels.
22
+ - Canonical EN defaults for Edge and eSpeak.
23
+ - 100% key parity for EN/ZH model hints.
24
+ - Google TTS validates MIME and wraps raw PCM in valid WAV container.
25
+ - Azure SSML includes required xmlns/mstts attributes.
26
+ - Robust model download locking, cancellation awaiting writer cleanup, and HTML error page rejection.
27
+ - **Release Hygiene & Sanitization** (#183, #184, #185):
28
+ - Removed outdated marketing claims from READMEs.
29
+ - Restored internal DEV docs while verifying exclusion from npm pack and GitHub mirror.
30
+ - Cleaned up publish-github script to handle negation patterns.
31
+
32
+ ## 0.4.28
33
+
34
+ ### Fixed
35
+ - **Settings Contracts & Schemastery Validation** (#151, #152, #153, #155, #187, #188, #189):
36
+ - Made all 9 previously static fields volatile and ensured living Volatile boxes are unwrapped dynamically in live() without stale snapshots.
37
+ - Constrained maxQueue with .step(1).min(0) to reject non-integer and negative queue limits.
38
+ - Added optimistic revision validation and rollback on conflict (HTTP 409) in settings saving.
39
+ - Enforced isTrustedSettingsRequest auth guards on GET /dsh-tts/config and GET /dsh-tts/status.
40
+ - Enforced contiguous sequence ordering in SSE synthesis chunks.
41
+
42
+ - **Audio Pipeline, Cache & Concurrency** (#154, #156, #157, #166, #173, #174, #175, #186, #190):
43
+ - Sanitized and normalized text once in prepareSpeechText() before character limits or summary generation, eliminating double-clean pronunciation distortions.
44
+ - Fixed subagent voice override gating with autoDetectSubagent !== false.
45
+ - Extended cache and dedup fingerprints with endpoint, models, voices, role, and language.
46
+ - Added admission concurrency throttle and semaphore for maxQueue synthesis reservations.
47
+ - Implemented SSE subscriber backpressure tracking and dropped slow lagging subscribers (>512 KB buffer) with drain handling.
48
+ - Switched cache index to an in-memory Map to eliminate disk scans on eviction.
49
+ - Ensured speak_text tool returns metadata and delivers audio to pending queue/SSE without dumping base64 into LLM context.
50
+
51
+ - **Client Player & UI Parity** (#158, #159, #160, #161, #162, #163, #164, #165, #167, #168, #169, #176):
52
+ - Unified EventSource listener connection in client, preventing duplicate audio channels.
53
+ - Ensured skipCurrent() halts active playback immediately, and preview/replay route through the unified player.
54
+ - Gracefully handled browser autoplay restrictions (NotAllowedError), pausing the queue until user interaction.
55
+ - Kept compact recent/favorites history control visible in idle state.
56
+ - Registered updater routes inside ctx.effect() to properly unbind on plugin disposal.
57
+ - Enabled all 16 active providers in client UI, with secure key management and read-only indicators when settings scope is missing.
58
+ - Fixed sibling integration detection using Cordis services without relying on obsolete hasRoute.
59
+ - Re-anchored canonical defaults to English (en, en-US-AriaNeural) and achieved 100% parity between EN and ZH dictionaries.
60
+
61
+ - **Model Weights Manager & System Hygiene** (#177, #178, #179, #180, #181, #182, #183, #184, #185):
62
+ - Stream chunk downloads now properly manage EventEmitter listeners without leaking error or drain handlers.
63
+ - Added synchronous concurrency lock per engine to share in-flight downloads and used unique nonce temporary file paths.
64
+ - Added minimum file size validation (minBytes / 1024 bytes) to reject truncated or error HTML downloads.
65
+ - Validated engine parameter in /dsh-tts/models/install and /dsh-tts/models/delete, returning HTTP 400 for unknown engines.
66
+ - Replaced tautological regex and source inspection tests with true runtime behavior tests.
67
+ - Isolated cache tests in temporary directories with automated cleanup.
68
+ - Synchronized README documentation with reality and strictly excluded internal dev docs (AGENTS.md, index.md, plans) from package distributions.
69
+ - Closes #151, #152, #153, #154, #155, #156, #157, #158, #159, #160, #161, #162, #163, #164, #165, #166, #167, #168, #169, #170, #171, #172, #173, #174, #175, #176, #177, #178, #179, #180, #181, #182, #183, #184, #185, #186, #187, #188, #189, #190, #191.
70
+
3
71
  ## 0.4.27
4
72
 
5
73
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- <h3>Multi-Provider Text-to-Speech Voice Synthesis with Local Neural Engines, Sub-300ms Streaming, IT Dictionary & Messenger Integration for DeepSeek Harness</h3>
5
+ <h3>Multi-Provider Text-to-Speech Voice Synthesis with Local Neural Engines, Real-time Streaming, IT Dictionary & Messenger Integration for DeepSeek Harness</h3>
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/@goodandready/dsh-tts"><img src="https://img.shields.io/npm/v/@goodandready/dsh-tts.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -49,7 +49,7 @@ graph LR
49
49
 
50
50
  subgraph Stream [Low-Latency Streaming]
51
51
  Scrub --> SSE[SSE /dsh-tts/stream]
52
- SSE --> Worklet[AudioWorklet PCM Processor]
52
+ SSE --> Player[Web Audio & HTML Audio Player]
53
53
  end
54
54
 
55
55
  subgraph Cache [Performance Layer]
@@ -59,7 +59,7 @@ graph LR
59
59
 
60
60
  subgraph Fallback [TTS Provider Fallback Chain]
61
61
  LRU -->|Cache Miss| Chain{Active Chain}
62
- Chain -->|Offline| P1[Edge TTS / Piper / eSpeak]
62
+ Chain -->|Online / Local| P1[Edge TTS / Piper / eSpeak]
63
63
  Chain -.->|Cloud Neural| P2[OpenAI / ElevenLabs / Google / Azure / Groq]
64
64
  Chain -.->|OpenAI-compatible| P3[SiliconFlow / DeepInfra / Fireworks / OpenRouter]
65
65
  Chain -.->|Other| P4[MiMo / MiniMax / Custom]
@@ -85,13 +85,14 @@ graph LR
85
85
 
86
86
  ## 🚀 Key Features
87
87
 
88
- ### 1. 📴 Offline system engines + optional future neural runtimes
89
- * **Edge TTS / Piper / eSpeak**: fully offline or free local/system synthesis without cloud API keys (Edge needs the `edge-tts` CLI).
88
+ ### 1. 📴 Local and system engines + optional future neural runtimes
89
+ * **Piper / eSpeak**: fully offline local synthesis without cloud API keys.
90
+ * **Edge TTS**: free online cloud synthesis without API keys (requires network connection and the `edge-tts` CLI).
90
91
  * **Kokoro-82M / F5-TTS**: weight download and status UI only. **Neural inference is not bundled** in this package — those providers fail with a clear reason and the fallback chain continues. Do not enable them expecting speech until a supported runtime is wired.
91
92
  * **ModelManager UI**: Direct manual installation in settings with real-time download progress bar, SHA-256 validation, and deletion. No silent or automatic multi-gigabyte downloads.
92
93
 
93
94
  ### 2. ⚡ Real-Time Streaming Audio (< 300 ms Latency)
94
- * **AudioWorklet (`TTSWorklet`)**: High-performance Web Audio Worklet processor playing seamless Float32Array PCM chunks at 24 kHz without audible clicks or buffer underruns.
95
+ * **HTML Audio & Web Audio Player**: Seamless queued audio playback with prebuffering, barge-in support, playback rate control, and instant browser fallback.
95
96
  * **Server-Sent Events (SSE)**: Dedicated `/dsh-tts/stream` route delivering synthesized chunks to connected browsers instantly.
96
97
 
97
98
  ### 3. 🎙️ Voice Duplex & VAD Barge-In (with `@goodandready/dsh-voice`)
@@ -101,10 +102,10 @@ graph LR
101
102
 
102
103
  ### 4. 📚 Built-in IT Terminology Pronunciation Dictionary
103
104
  * **Pre-configured Lexicon**: Correct phonetic pronunciation for common technical abbreviations and developer terms:
104
- - `SQL` $\rightarrow$ "сиквел"
105
- - `Nginx` $\rightarrow$ "энджинкс"
106
- - `Kubernetes` / `K8s` $\rightarrow$ "кубернетис"
107
- - `Docker` $\rightarrow$ "докер", `API` $\rightarrow$ "апи", `JSON` $\rightarrow$ "джейсон", `YAML` $\rightarrow$ "ямл"
105
+ - `SQL` $\rightarrow$ "sequel"
106
+ - `Nginx` $\rightarrow$ "engine-x"
107
+ - `Kubernetes` / `K8s` $\rightarrow$ "coo-ber-net-ees" / "kates"
108
+ - `Docker` $\rightarrow$ "docker", `API` $\rightarrow$ "A-P-I", `JSON` $\rightarrow$ "jason", `YAML` $\rightarrow$ "yaml"
108
109
  - `GUI`, `CLI`, `CI/CD`, `PR`, `Regex`, `OAuth`, `HTTP`, `HTTPS`, `CPU`, `GPU`, `RAM`
109
110
  * **Interactive UI Editor**: Edit rules, preview phonetic substitutions with the **▶ Listen** button, and populate standard IT terms with one click.
110
111
 
@@ -135,7 +136,7 @@ graph LR
135
136
  | `f5` | Local F5-TTS GPU Daemon | `F5-TTS` | Default | *None* | Daemon ping only; **GPU inference not bundled** — fails honestly |
136
137
  | `elevenlabs` | ElevenLabs API | `eleven_multilingual_v2` | `Rachel` | `ELEVENLABS_API_KEY` | Ultra-realistic, emotional nuance |
137
138
  | `openai` | OpenAI Audio | `gpt-4o-mini-tts` / `tts-1` | `alloy` | `OPENAI_API_KEY` | High-quality industry standard |
138
- | `edge` | Microsoft Edge Online | `ru-RU-SvetlanaNeural` | `ru-RU-SvetlanaNeural` | *None* | **Free, high-fidelity neural TTS without API keys** |
139
+ | `edge` | Microsoft Edge Online | `en-US-AriaNeural` | `en-US-AriaNeural` | *None* | **Free, high-fidelity neural TTS without API keys** |
139
140
  | `siliconflow` | SiliconFlow CosyVoice | `FunAudioLLM/CosyVoice2-0.5B` | Default | `SILICONFLOW_API_KEY` | State-of-the-art CosyVoice2 neural engine |
140
141
  | `deepinfra` | DeepInfra Kokoro | `hexgrad/Kokoro-82M` | Default | `DEEPINFRA_API_KEY` | Fast open-weights Kokoro synthesis |
141
142
  | `fireworks` | Fireworks AI | `kokoro` | Default | `FIREWORKS_API_KEY` | Ultra-low latency Kokoro inference |
@@ -173,7 +174,7 @@ dsh plugin --profile web add @goodandready/dsh-tts
173
174
 
174
175
  ---
175
176
 
176
- ## ⚙️ Configuration Recipes (`settings.yaml`)
177
+ ## ⚙️ Configuration Recipes (DSH Settings UI)
177
178
 
178
179
  ```yaml
179
180
  dsh-tts:
@@ -212,7 +213,7 @@ The plugin settings card (**Settings → Plugins → Plugin settings**) exposes
212
213
 
213
214
  Provider **API keys are never stored in plugin settings**. Paste them in the chain editor; values go to the DSH credential store via `PUT /dsh-tts/credential`.
214
215
 
215
- Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). They only rename the credential slot the plugin looks up. Change them in `settings.yaml` if you must rebind a key name; the defaults match the usual environment variable names.
216
+ Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). They only rename the credential slot the plugin looks up. Change them in the profile settings if you must rebind a key name; the defaults match the usual environment variable names.
216
217
 
217
218
 
218
219
  ## 🤖 HTTP Endpoints Reference
@@ -225,6 +226,10 @@ Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyE
225
226
  * `DELETE /dsh-tts/models/delete` — `{ engine: 'kokoro' | 'f5' }` → Removes local model files.
226
227
  * `GET /dsh-tts/integrations` — Status of sibling plugins (`dsh-voice`, `dsh-messenger-gateway`).
227
228
  * `GET /dsh-tts/status` — Returns active chain state, cache statistics, and engine readiness.
229
+ * `DELETE /dsh-tts/cache` — Clears cached audio files.
230
+ * `GET /dsh-tts/config` & `PUT /dsh-tts/config` — Retrieve and update settings revision.
231
+ * `GET /dsh-tts/stats` & `DELETE /dsh-tts/stats` — Performance statistics and metrics reset.
232
+ * `PUT /dsh-tts/credential` & `DELETE /dsh-tts/credential` — Securely store or remove cloud provider API keys.
228
233
 
229
234
  ---
230
235
 
package/README.ru.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- <h3>Многопровайдерная озвучка ответов агента: облачные и системные offline-движки, потоковый звук, IT-словарь и интеграция с мессенджерами для DeepSeek Harness</h3>
5
+ <h3>Многопровайдерная озвучка ответов агента: облачные, локальные и системные движки, потоковый звук, IT-словарь и интеграция с мессенджерами для DeepSeek Harness</h3>
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/@goodandready/dsh-tts"><img src="https://img.shields.io/npm/v/@goodandready/dsh-tts.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -37,11 +37,11 @@
37
37
 
38
38
  ## ⚡ Обзор
39
39
 
40
- > **Честно про локальные нейросети:** Kokoro/F5 — только статус и загрузка весов. Inference **не входит** в пакет; эти провайдеры падают с понятной причиной, цепочка идёт дальше. Рабочий offline: Edge TTS, Piper, eSpeak.
40
+ > **Честно про локальные нейросети:** Kokoro/F5 — только статус и загрузка весов. Inference **не входит** в пакет; эти провайдеры падают с понятной причиной, цепочка идёт дальше. Рабочий offline: Piper, eSpeak (Edge TTS — бесплатный онлайн-сервис без ключей).
41
41
 
42
42
  **`dsh-tts`** обеспечивает качественное голосовое озвучивание ответов ассистента в веб-интерфейсе **DeepSeek Harness**. При включённой опции **«Читать ответы агента»**, каждая готовая реплика или потоковый фрагмент синтезируется на хосте и мгновенно воспроизводится в браузере.
43
43
 
44
- API-ключи никогда не передаются в браузер: синтез аудио выполняется на стороне хоста через **независимые цепочки отказоустойчивости (фолбеков)**, включая системные offline-движки (Edge TTS, Piper, eSpeak). Kokoro/F5 — только загрузка весов; inference не входит в пакет.
44
+ API-ключи никогда не передаются в браузер: синтез аудио выполняется на стороне хоста через **независимые цепочки отказоустойчивости (фолбеков)**, включая локальные и системные движки (Edge TTS — онлайн без ключей, Piper и eSpeak — offline). Kokoro/F5 — только загрузка весов; inference не входит в пакет.
45
45
 
46
46
  ```mermaid
47
47
  graph LR
@@ -51,7 +51,7 @@ graph LR
51
51
 
52
52
  subgraph Stream [Потоковый звук < 300 мс]
53
53
  Scrub --> SSE[SSE /dsh-tts/stream]
54
- SSE --> Worklet[AudioWorklet PCM процессор]
54
+ SSE --> Player[Web Audio & HTML Audio плеер]
55
55
  end
56
56
 
57
57
  subgraph Cache [Слой производительности]
@@ -61,7 +61,7 @@ graph LR
61
61
 
62
62
  subgraph Fallback [Цепочка фолбеков TTS]
63
63
  LRU -->|Промах кэша| Chain{Активная цепочка}
64
- Chain -->|Offline| P1[Edge TTS / Piper / eSpeak]
64
+ Chain -->|Online / Local| P1[Edge TTS / Piper / eSpeak]
65
65
  Chain -.->|Cloud| P2[OpenAI / ElevenLabs / Google / Azure / Groq]
66
66
  Chain -.->|Бесплатные облака| P3[EdgeTTS / SiliconFlow]
67
67
  Chain -.->|Системный фолбек| P4[Локальный Piper / eSpeak NG]
@@ -87,13 +87,14 @@ graph LR
87
87
 
88
88
  ## 🚀 Ключевые возможности
89
89
 
90
- ### 1. 📴 Offline-системные движки и честный статус нейросетей
91
- * **Edge TTS / Piper / eSpeak**: рабочий offline/системный синтез без облачных API-ключей.
90
+ ### 1. 📴 Локальные движки и честный статус нейросетей
91
+ * **Piper / eSpeak**: полностью автономный локальный оффлайн-синтез.
92
+ * **Edge TTS**: бесплатный онлайн-синтез через Microsoft без API-ключей (требует интернет и CLI `edge-tts`).
92
93
  * **Kokoro-82M / F5-TTS**: только UI и загрузка весов. **Inference не входит в пакет** — провайдер падает с понятной причиной, цепочка идёт дальше.
93
94
  * **ModelManager в UI**: Ручная загрузка моделей прямо из настроек плагина с отображением процентов и прогресс-бара, проверкой SHA-256 и кнопкой удаления. Никаких скрытых или автозагрузок гигабайтных весов.
94
95
 
95
96
  ### 2. ⚡ Потоковое воспроизведение звука с задержкой < 300 мс
96
- * **AudioWorklet (`TTSWorklet`)**: Высокопроизводительный процессор веб-аудио с бесшовным кольцевым буфером для воспроизведения PCM-чанков (24 кГц) без щелчков и пауз.
97
+ * **HTML Audio & Web Audio плеер**: Бесшовное воспроизведение аудио-очереди с предбуферизацией, поддержкой перебивания речи (barge-in), регулировкой скорости и отказоустойчивым fallback на синтез браузера.
97
98
  * **Server-Sent Events (SSE)**: Роут `/dsh-tts/stream` передаёт синтезированные куски речи сразу в браузер, исключая задержки традиционного полинга.
98
99
 
99
100
  ### 3. 🎙️ Голосовой диалог и VAD Barge-In (в связке с `@goodandready/dsh-voice`)
@@ -156,7 +157,7 @@ dsh plugin --profile web add @goodandready/dsh-tts
156
157
 
157
158
  ---
158
159
 
159
- ## ⚙️ Пример конфигурации (`settings.yaml`)
160
+ ## ⚙️ Пример конфигурации (Интерфейс настроек DSH)
160
161
 
161
162
  ```yaml
162
163
  dsh-tts:
@@ -195,7 +196,7 @@ dsh-tts:
195
196
 
196
197
  **API-ключи не хранятся в настройках плагина.** Вставляйте их в редакторе цепочки — значение уходит в credential store DSH через `PUT /dsh-tts/credential`.
197
198
 
198
- Только в конфиге (нет в карточке): поля `*KeyEnv` (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). Они задают **имя** credential-слота; по умолчанию совпадают с привычными именами env-переменных. Меняйте в `settings.yaml`, если нужно перевязать имя ключа.
199
+ Только в конфиге (нет в карточке): поля `*KeyEnv` (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). Они задают **имя** credential-слота; по умолчанию совпадают с привычными именами env-переменных. Меняйте в настройках профиля, если нужно перевязать имя ключа.
199
200
 
200
201
 
201
202
  ## 🤖 HTTP роуты API
package/README.zh.md CHANGED
@@ -47,9 +47,9 @@ graph LR
47
47
  Reply[💬 智能体生成文本] --> Scrub[智能过滤 & IT术语词典]
48
48
  end
49
49
 
50
- subgraph Stream [低延迟流式音频 < 300ms]
50
+ subgraph Stream [实时流式音频播放]
51
51
  Scrub --> SSE[SSE /dsh-tts/stream]
52
- SSE --> Worklet[AudioWorklet PCM 处理器]
52
+ SSE --> Player[Web Audio & HTML Audio 播放器]
53
53
  end
54
54
 
55
55
  subgraph Cache [性能缓存层]
@@ -90,8 +90,8 @@ graph LR
90
90
  * **Kokoro-82M / F5-TTS**:仅权重下载与状态展示。**推理未捆绑在本包中**,provider 会明确失败并继续 fallback。
91
91
  * **ModelManager 管理界面**:在设置中手动安装模型,实时显示下载进度条、SHA-256 校验和一键删除。无任何静默或自动下载。
92
92
 
93
- ### 2. ⚡ 实时流式音频播放(延迟 < 300ms)
94
- * **AudioWorklet (`TTSWorklet`)**:高性能 Web Audio Worklet 处理器,以 24kHz 采样率无缝播放 Float32Array PCM 数据块,无可感知的爆音或缓冲区欠载。
93
+ ### 2. ⚡ 实时流式音频播放
94
+ * **HTML Audio & Web Audio 播放器**:无缝队列音频播放,支持预缓冲、麦克风打断响应 (barge-in)、播放倍速控制以及浏览器原生语音故障回退。
95
95
  * **Server-Sent Events (SSE)**:专用 `/dsh-tts/stream` 路由将合成音频片段即时推送至浏览器。
96
96
 
97
97
  ### 3. 🎙️ 语音双工对话与 VAD 打断(配合 `@goodandready/dsh-voice`)
@@ -165,7 +165,7 @@ dsh plugin --profile web add @goodandready/dsh-tts
165
165
  API 密钥不写入插件设置,只在链编辑器粘贴,经 `PUT /dsh-tts/credential` 写入 DSH credential store。
166
166
 
167
167
 
168
- ## ⚙️ 配置示例(`settings.yaml`)
168
+ ## ⚙️ 配置说明(DSH 设置面板)
169
169
 
170
170
  ```yaml
171
171
  dsh-tts:
package/lib/cache.js CHANGED
@@ -41,56 +41,78 @@ export function createSpeechCache({ root, maxBytes }) {
41
41
  }
42
42
  }
43
43
 
44
- async function listEntries() {
44
+ // In-memory index of disk cache entries to avoid O(N) readdir/stat/readFile per write (#190)
45
+ const diskEntries = new Map() // key -> { size, at }
46
+ let diskTotalBytes = 0
47
+ let indexLoaded = false
48
+
49
+ async function ensureIndex() {
45
50
  await ensure()
46
- const out = []
47
- for (const name of await fs.promises.readdir(dir)) {
51
+ if (indexLoaded) return
52
+ diskEntries.clear()
53
+ diskTotalBytes = 0
54
+ let files = []
55
+ try {
56
+ files = await fs.promises.readdir(dir)
57
+ } catch {
58
+ files = []
59
+ }
60
+ for (const name of files) {
48
61
  if (!name.endsWith(AUDIO_EXT)) continue
62
+ const key = name.slice(0, -AUDIO_EXT.length)
49
63
  const full = path.join(dir, name)
50
- let st
51
- try {
52
- st = await fs.promises.stat(full)
53
- } catch {
54
- continue
55
- }
56
- let at = st.mtimeMs
57
64
  try {
58
- const meta = JSON.parse(await fs.promises.readFile(path.join(dir, name.slice(0, -AUDIO_EXT.length) + '.json'), 'utf8'))
59
- if (meta && typeof meta.at === 'number') at = meta.at
60
- } catch (noMeta) { /* legacy entry without meta — evict as first */ }
61
- out.push({ full, meta: name.slice(0, -AUDIO_EXT.length) + '.json', size: st.size, at })
65
+ const st = await fs.promises.stat(full)
66
+ let at = st.mtimeMs
67
+ try {
68
+ const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
69
+ if (meta && typeof meta.at === 'number') at = meta.at
70
+ } catch { /* no meta */ }
71
+ diskEntries.set(key, { size: st.size, at })
72
+ diskTotalBytes += st.size
73
+ } catch { /* skip unreadable */ }
62
74
  }
63
- return out
75
+ indexLoaded = true
64
76
  }
65
77
 
66
78
  async function evict() {
79
+ await ensureIndex()
67
80
  const limit = typeof maxBytes === 'function' ? maxBytes() : Number(maxBytes)
68
- if (!(limit > 0)) return
69
- const rawEntries = await listEntries()
70
- const entries = rawEntries.map((e) => {
71
- const k = path.basename(e.full, AUDIO_EXT)
72
- const l1 = l1Map.get(k)
73
- return { ...e, key: k, at: (l1 && typeof l1.at === 'number') ? l1.at : e.at }
74
- }).sort((a, b) => a.at - b.at)
75
-
76
- let total = entries.reduce((acc, e) => acc + e.size, 0)
77
- for (const e of entries) {
78
- if (total <= limit) break
79
- await fs.promises.rm(e.full, { force: true })
80
- await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
81
- l1Map.delete(e.key)
82
- total -= e.size
81
+ if (!(limit > 0) || diskTotalBytes <= limit) return
82
+
83
+ // Sort in-memory entries by timestamp ascending (oldest first)
84
+ const sorted = Array.from(diskEntries.entries()).sort((a, b) => {
85
+ const atA = l1Map.get(a[0])?.at ?? a[1].at
86
+ const atB = l1Map.get(b[0])?.at ?? b[1].at
87
+ return atA - atB
88
+ })
89
+
90
+ for (const [key, meta] of sorted) {
91
+ if (diskTotalBytes <= limit) break
92
+ await fs.promises.rm(path.join(dir, key + AUDIO_EXT), { force: true }).catch(() => {})
93
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
94
+ diskEntries.delete(key)
95
+ diskTotalBytes -= meta.size
96
+ l1Map.delete(key)
83
97
  }
84
98
  }
85
99
 
86
100
  return {
87
101
  async put(key, mime, audio) {
88
- await ensure()
102
+ await ensureIndex()
89
103
  const buf = Buffer.isBuffer(audio) ? audio : Buffer.from(audio)
90
104
  putL1(key, mime, buf)
91
105
  await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
92
- await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: Date.now() }))
93
- await evict()
106
+ const now = Date.now()
107
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: now }))
108
+ const prev = diskEntries.get(key)
109
+ if (prev) diskTotalBytes -= prev.size
110
+ diskEntries.set(key, { size: buf.length, at: now })
111
+ diskTotalBytes += buf.length
112
+ const limit = typeof maxBytes === 'function' ? maxBytes() : Number(maxBytes)
113
+ if (limit > 0 && diskTotalBytes > limit) {
114
+ await evict()
115
+ }
94
116
  },
95
117
 
96
118
  async get(key) {
@@ -100,17 +122,22 @@ export function createSpeechCache({ root, maxBytes }) {
100
122
  const now = Date.now()
101
123
  l1Map.delete(key) // refresh LRU order
102
124
  l1Map.set(key, { ...hit, at: now })
125
+ const d = diskEntries.get(key)
126
+ if (d) d.at = now
103
127
  fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now })).catch(() => {})
104
128
  return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
105
129
  }
106
130
 
107
131
  // 2. L2 Disk Hit
132
+ await ensureIndex()
108
133
  try {
109
134
  const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
110
135
  const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
111
136
  putL1(key, meta.mime, audio)
112
- // Touch LRU asynchronously so reads are never blocked.
113
- fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: Date.now() })).catch(() => {})
137
+ const now = Date.now()
138
+ const d = diskEntries.get(key)
139
+ if (d) d.at = now
140
+ fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now })).catch(() => {})
114
141
  return { mime: meta.mime || 'audio/mpeg', audio }
115
142
  } catch (miss) {
116
143
  return null
@@ -119,12 +146,15 @@ export function createSpeechCache({ root, maxBytes }) {
119
146
 
120
147
  async clear() {
121
148
  l1Map.clear()
122
- const entries = await listEntries()
123
- for (const e of entries) {
124
- await fs.promises.rm(e.full, { force: true })
125
- await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
149
+ await ensureIndex()
150
+ const count = diskEntries.size
151
+ for (const key of diskEntries.keys()) {
152
+ await fs.promises.rm(path.join(dir, key + AUDIO_EXT), { force: true }).catch(() => {})
153
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
126
154
  }
127
- return entries.length
155
+ diskEntries.clear()
156
+ diskTotalBytes = 0
157
+ return count
128
158
  },
129
159
  }
130
160
  }