From 53d057ab1b279b196f8356440801bd4abfa50632 Mon Sep 17 00:00:00 2001 From: manishEMS47 Date: Tue, 16 Jun 2026 18:25:58 +0530 Subject: [PATCH] Added 60dB integration --- doc/60db-integration.md | 130 +++++++++ package-lock.json | 470 -------------------------------- public/icons/logos/60db.svg | 10 + src/tts/README.md | 37 +++ src/tts/SpeechModel.ts | 8 + test/tts/AudioProviders.spec.ts | 11 + 6 files changed, 196 insertions(+), 470 deletions(-) create mode 100644 doc/60db-integration.md create mode 100644 public/icons/logos/60db.svg diff --git a/doc/60db-integration.md b/doc/60db-integration.md new file mode 100644 index 0000000000..73f1e258fd --- /dev/null +++ b/doc/60db-integration.md @@ -0,0 +1,130 @@ +# Adding 60dB as a TTS/STT engine (alongside ElevenLabs) + +This document specifies how 60dB (`api.60db.ai`) is integrated **consistently** with +the existing ElevenLabs/OpenAI/Pi voice engines. + +## Design decision: server-side proxy + +The extension **never** calls a TTS/STT vendor directly. ElevenLabs, OpenAI and +the Pi value‑tier voices are all proxied by the SayPi backend (`api.saypi.ai`); +the content script only ever speaks SayPi's own contract: + +| Capability | Client call (unchanged) | +| --- | --- | +| List voices | `GET {apiServerUrl}/voices?app=` | +| Create + stream TTS | `POST {apiServerUrl}/speak/{uuid}/stream?voice_id=&lang=&app=` then `PUT {apiServerUrl}/speak/{uuid}/stream` per text chunk | +| Transcribe (STT) | `POST {apiServerUrl}/transcribe?app=&language=` (multipart `file`) | + +60dB is therefore added the **same way ElevenLabs and OpenAI were** (cf. +issue #215/#92): as another engine **behind** these endpoints. This keeps the +client maximally consistent — voices from every engine share one unified menu, +one billing model, and one audio pipeline. See +[`src/tts/SpeechModel.ts`](../src/tts/SpeechModel.ts) `audioProviders.retrieveProviderByEngine`. + +``` +content script ──SayPi contract──► api.saypi.ai ──proxies──► api.60db.ai + (no change) (all 60dB work) (vendor) +``` + +--- + +## Backend work (api.saypi.ai — not in this repo) + +### 1. Voices: surface 60dB voices through `/voices` + +Map each 60dB voice from `GET https://api.60db.ai/myvoices` +(auth: `Authorization: Bearer <60dB key>`) into SayPi's +`SpeechSynthesisVoiceRemote` shape. 60dB key fields: + +| 60dB (`/myvoices`) | SayPi voice field | +| --- | --- | +| `voice_id` (UUID) | `id` / `voiceURI` (`{apiServerUrl}/voices/{id}`) | +| `name` | `name` | +| `labels.language` (ISO, e.g. `en`) | `lang` | +| `labels.language_name` | (display only) | +| `labels.gender` (`male`/`female`) | `gender` (`"M"`/`"F"`) | +| `labels.accent` (American/British/Indian/Neutral) | `accent` | +| `description` | `description` | +| — (set by SayPi) | `powered_by: "60dB"` **(exact literal — see client note)** | +| — (set by SayPi pricing) | `price_per_thousand_chars_in_usd`, `price_per_thousand_chars_in_credits` | +| — | `default: false`, `localService: false` | + +60dB TTS pricing is `$0.00002/char` = **$0.02 / 1000 chars**; convert to your +credit basis and set `price_per_thousand_chars_in_credits` accordingly so the +unified menu shows a correct cost (the client multiplies chars × this field). + +### 2. TTS: serve `/speak/{uuid}/stream` from 60dB + +When a `voice_id` belongs to a 60dB voice, synthesise via 60dB. The SayPi +streaming contract (one initial `POST` with a `" "` start‑marker, then a `PUT` +per text chunk carrying a monotonic `sequenceNumber`, with `totalChunks` set on +the final chunk — see [`TextToSpeechService.ts`](../src/tts/TextToSpeechService.ts)) +maps directly onto 60dB's **WebSocket context API**, which is the recommended +low‑latency path for incremental LLM output: + +| SayPi stream lifecycle (client → SayPi) | 60dB WebSocket (SayPi → `wss://api.60db.ai/ws/tts?apiKey=…`) | +| --- | --- | +| `POST …/speak/{uuid}/stream` (open, `" "`) | `create_context` `{ context_id, voice_id, audio_config:{audio_encoding:"LINEAR16", sample_rate_hertz:24000}, speed, stability, similarity }` | +| `PUT …/speak/{uuid}/stream` chunk `{text, sequenceNumber}` | `send_text` `{ context_id, text }` (accumulates; up to 50 000 chars) | +| chunk with `totalChunks` (final) | `flush_context` then `close_context` | +| (audio streamed back to client) | server `audio_chunk` `{ audioContent: }` → decode → re‑emit on SayPi's existing binary audio stream | + +`audioContent` for `LINEAR16`/`PCM` chunks concatenates directly. (Avoid +`OGG_OPUS` for incremental streaming — each chunk is a standalone Ogg file and +cannot be naively concatenated.) + +Non‑WebSocket alternative: `POST https://api.60db.ai/tts-stream` (NDJSON, objects +`{type:"chunk"|"complete"|"error", result:{audioContent}}`) or the one‑shot +`POST /tts-synthesize` (`{audio_base64, sample_rate, …}`) for short, non‑streamed +utterances. + +**Error mapping (keep the client's existing semantics):** +- 60dB insufficient credits / 402 → return **HTTP 429** on `/speak` so the client + yields a `FailedSpeechUtterance(InsufficientCredit)` (see + [`SpeechFailureReason`](../src/tts/SpeechFailureReason.ts) and `TextToSpeechService.createSpeech`). +- Upstream 5xx → non‑2xx so the client surfaces a generic synthesis failure. + +### 3. STT: route `/transcribe` to 60dB + +`POST {apiServerUrl}/transcribe` already uploads multipart audio plus `language`, +`app`, optional `prefer` and `removeFillerWords` +([`TranscriptionModule.ts`](../src/TranscriptionModule.ts)). Map to +`POST https://api.60db.ai/stt` (multipart `file`, `language` (`"auto"` for +detection), optional `diarize`, `context`, `keywords`, `return_timestamps`). +Map the 60dB response back to SayPi's transcription JSON — at minimum `text` +(the client reads `responseJson.text`). 60dB error codes map naturally: +`401→401`, `INSUFFICIENT_CREDITS/402→402`, `STT_*_RATE_LIMIT/429→429` (honour +`Retry-After`). + +--- + +## Client work (this repo — done) + +Because routing is server‑side, the client change is minimal and only ensures +60dB voices flow through the **unified voice menu** cleanly: + +1. **Engine routing** — `src/tts/SpeechModel.ts`: added `case "60dB":` to + `audioProviders.retrieveProviderByEngine`, mapping it to the SayPi provider + (same branch as `ElevenLabs`/`OpenAI`). Without this, 60dB voices would still + work via the default fallback but log an "unrecognised provider" warning. +2. **Engine logo** — `public/icons/logos/60db.svg`: the menu builds the logo path + as `icons/logos/.svg` + ([`TTSControlsModule.createTtsLogo`](../src/tts/TTSControlsModule.ts)), so the + `powered_by` literal **must** lowercase to `60db` to match this file. A + placeholder wordmark is committed — **replace with the official brand SVG.** +3. **Test** — `test/tts/AudioProviders.spec.ts`: asserts `"60dB"` → SayPi with no + warning. + +Everything else (voice name/lang/gender/accent/description, per‑char price, cost +display, audio playback, keep‑alive) is data‑driven from the `/voices` response +and needs no client change. + +### Contract invariant + +The single coupling between client and backend is the **`powered_by` literal**. +It must be exactly `"60dB"` everywhere the backend emits it, because: +- the routing `switch` matches it case‑sensitively, and +- the logo filename is its lowercase form (`60db.svg`). + +If you prefer a different literal, change all three of: the backend `/voices` +output, the `case` in `SpeechModel.ts`, and the SVG filename. diff --git a/package-lock.json b/package-lock.json index 5dd88b91ea..7678657b2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3716,19 +3716,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -3815,330 +3802,6 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@peculiar/asn1-cms": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", @@ -8712,21 +8375,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -11059,15 +10707,6 @@ "dev": true, "license": "MIT" }, - "node_modules/immutable": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", - "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -15552,15 +15191,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -17780,63 +17410,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sass": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.90.0.tgz", - "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/sass/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -18966,49 +18539,6 @@ "dev": true, "license": "MIT" }, - "node_modules/terser": { - "version": "5.43.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", - "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/public/icons/logos/60db.svg b/public/icons/logos/60db.svg new file mode 100644 index 0000000000..f7111c0b64 --- /dev/null +++ b/public/icons/logos/60db.svg @@ -0,0 +1,10 @@ + + + + 60 + dB + diff --git a/src/tts/README.md b/src/tts/README.md index adccc9e495..a0fa0c4ca7 100644 --- a/src/tts/README.md +++ b/src/tts/README.md @@ -2,6 +2,43 @@ This module provides text-to-speech capabilities for the application. +## Audio engines (providers) + +The extension **never calls a TTS vendor directly**. Every engine — ElevenLabs, +OpenAI, 60dB, and the Pi value‑tier voices — is proxied by the SayPi backend +(`api.saypi.ai`). The content script only speaks SayPi's own contract +(`/voices`, `/speak/{uuid}/stream`, `/transcribe`), so all engines share one +unified voice menu, one billing model, and one audio pipeline. + +Each voice the server returns carries a `powered_by` string. `SpeechModel.ts` +(`audioProviders.retrieveProviderByEngine`) maps it to the domain that actually +streams the audio: + +| `powered_by` | Streamed from | Audio provider | +| --- | --- | --- | +| `ElevenLabs` | api.saypi.ai | SayPi | +| `OpenAI` | api.saypi.ai | SayPi | +| `60dB` | api.saypi.ai | SayPi | +| `inflection.ai` | pi.ai | Pi | +| *(anything else)* | api.saypi.ai (assumed) | SayPi (logs a warning) | + +> **Adding an engine** is therefore a one‑line `case` here plus a logo asset at +> `public/icons/logos/.svg` (the voice menu builds the +> logo path from `powered_by`, see `TTSControlsModule.createTtsLogo`). The +> `powered_by` literal is the single coupling between client and backend: the +> routing `switch` matches it **case‑sensitively** and the logo filename is its +> lowercase form. + +### 60dB + +60dB (`api.60db.ai`) is wired on the **client** (routing `case "60dB"`, logo +`public/icons/logos/60db.svg`, regression test in +`test/tts/AudioProviders.spec.ts`). It is fully active only once the +**api.saypi.ai backend** proxies 60dB behind `/voices`, `/speak`, and +`/transcribe`. The end‑to‑end contract (voice‑field mapping, the 60dB WebSocket +`create_context → send_text → flush/close` lifecycle, and STT routing) is +specified in [`doc/60db-integration.md`](../../doc/60db-integration.md). + ## Handling Failed Utterances The TTS service can now handle expected failures (like insufficient credits) by returning a `FailedSpeechUtterance` object instead of throwing an error. Here's an example of how to handle this in a UI component: diff --git a/src/tts/SpeechModel.ts b/src/tts/SpeechModel.ts index e91311366e..0f27bd7975 100644 --- a/src/tts/SpeechModel.ts +++ b/src/tts/SpeechModel.ts @@ -78,6 +78,14 @@ const audioProviders = { // released extension previously threw on powered_by="OpenAI"). case "OpenAI": case "ElevenLabs": + // 60dB voices are likewise synthesised by the api.saypi.ai backend (which + // proxies api.60db.ai server-side) and streamed back over the identical + // ?voice_id= contract, so they are SayPi-served too. The literal must + // match the `powered_by` the /voices endpoint returns: the unified voice + // menu derives the engine logo from it via + // icons/logos/.svg → icons/logos/60db.svg + // (see TTSControlsModule.createTtsLogo). + case "60dB": return audioProviders.SayPi; case "inflection.ai": return audioProviders.Pi; diff --git a/test/tts/AudioProviders.spec.ts b/test/tts/AudioProviders.spec.ts index e2c057384a..713bbad10b 100644 --- a/test/tts/AudioProviders.spec.ts +++ b/test/tts/AudioProviders.spec.ts @@ -33,6 +33,17 @@ describe('audioProviders', () => { expect(audioProviders.retrieveProviderByEngine('inflection.ai')).toBe(audioProviders.Pi); }); + it('maps 60dB voices to the SayPi provider (server-side proxy of api.60db.ai)', () => { + // 60dB TTS is streamed from api.saypi.ai over the same ?voice_id= stream + // contract as ElevenLabs/OpenAI, so 60dB is a SayPi-served voice from the + // client's perspective. Explicit case (not the default fallback) so it + // routes without emitting the "unrecognised provider" warning. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(audioProviders.retrieveProviderByEngine('60dB')).toBe(audioProviders.SayPi); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + it('falls back to SayPi (and warns) instead of throwing on an unrecognised provider', () => { // Regression guard for #215/#92: an unknown powered_by used to throw, // crashing the synchronous voice-selection path. It must now degrade.