mirror of
https://github.com/danialkalbasi/obsidian-vox.git
synced 2026-07-22 06:50:02 +00:00
Remove Piper TTS; expand settings, main flow, and styles
- Drop piper backend; adjust OpenAI and ElevenLabs TTS integration - Rework settings UI and plugin entry points; update player and styles - Add VOICES.md; refresh README, manifest, package metadata, and .gitignore Made-with: Cursor
This commit is contained in:
parent
5b13cf7f6f
commit
7593f0bd7f
15 changed files with 689 additions and 397 deletions
32
.gitignore
vendored
32
.gitignore
vendored
|
|
@ -1,6 +1,32 @@
|
|||
node_modules/
|
||||
# Build output
|
||||
main.js
|
||||
*.log
|
||||
.DS_Store
|
||||
main.js.map
|
||||
*.js.map
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Obsidian plugin data
|
||||
data.json
|
||||
cache/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
48
README.md
48
README.md
|
|
@ -1,20 +1,20 @@
|
|||
# Vox
|
||||
|
||||
Vox reads your Obsidian notes aloud with neural text-to-speech. Different folders can have different voices, so your philosophy notes can sound different from your dream journal.
|
||||
Vox reads your Obsidian notes aloud with neural text-to-speech. Different folders can have different voices, so your philosophy notes can sound different from your journal.
|
||||
|
||||
## Features
|
||||
|
||||
- **Ribbon button** — morphs between speaker / pause / play while audio is active; a separate stop button appears during playback
|
||||
- **Hover voice picker** — hover the ribbon icon to pick a voice on the fly without touching settings
|
||||
- **Status bar indicator** — clickable pill shows "Reading" / "Paused"; hidden when idle
|
||||
- **Command palette** — Read active note / Stop / Toggle play–pause
|
||||
- **File menu** — right-click any note → Vox: read aloud
|
||||
- **Three TTS backends** — Browser (free, offline), OpenAI, ElevenLabs
|
||||
- **Per-engine speed control** — each backend has its own range
|
||||
- **Per-folder persona voices** — longest prefix wins; stored per-engine
|
||||
- **Ribbon button**: morphs between speaker / pause / play while audio is active; a separate stop button appears during playback
|
||||
- **Hover voice picker**: hover the ribbon icon to pick a voice on the fly without touching settings
|
||||
- **Status bar indicator**: clickable pill shows "Reading" / "Paused"; hidden when idle
|
||||
- **Command palette**: Read active note / Stop / Toggle play-pause
|
||||
- **File menu**: right-click any note -> Vox: read aloud
|
||||
- **Three TTS backends**: Browser (free, offline), OpenAI, ElevenLabs
|
||||
- **Per-engine speed control**: each backend has its own range
|
||||
- **Per-folder persona voices**: longest prefix wins; stored per-engine
|
||||
- **Per-note voice override** via frontmatter `voice: <id>`
|
||||
- **Tone control** (OpenAI) — preset delivery styles (calm, conversational, storytelling, …)
|
||||
- **Voice library** (ElevenLabs) — save name + ID pairs as clickable chips; click to set default, × to remove
|
||||
- **Tone control** (OpenAI): preset delivery styles (calm, conversational, storytelling, ...)
|
||||
- **Voice library** (ElevenLabs): save name + ID pairs as clickable chips; click to set default, x to remove
|
||||
|
||||
## Backends
|
||||
|
||||
|
|
@ -28,19 +28,19 @@ Vox reads your Obsidian notes aloud with neural text-to-speech. Different folder
|
|||
alloy · ash · ballad · cedar · coral · echo · fable · marin · nova · onyx · sage · shimmer · verse
|
||||
|
||||
### ElevenLabs models
|
||||
- **Turbo v2.5** — low latency, English-first
|
||||
- **Multilingual v2** — broader language support
|
||||
- **Turbo v2.5**: low latency, English-first
|
||||
- **Multilingual v2**: broader language support
|
||||
|
||||
## Configuration
|
||||
|
||||
**Settings → Vox:**
|
||||
**Settings -> Vox:**
|
||||
|
||||
- **Engine** — browser / openai / elevenlabs
|
||||
- **Speed** — slider (range varies by engine)
|
||||
- **Voice / API key** — shown only for the active engine
|
||||
- **Tone** (OpenAI) — delivery style preset
|
||||
- **Voices** (ElevenLabs) — add name + voice ID pairs
|
||||
- **Folder voices** — map a folder prefix to a voice id for the active engine
|
||||
- **Engine**: browser / openai / elevenlabs
|
||||
- **Speed**: slider (range varies by engine)
|
||||
- **Voice / API key**: shown only for the active engine
|
||||
- **Tone** (OpenAI): delivery style preset
|
||||
- **Voices** (ElevenLabs): add name + voice ID pairs
|
||||
- **Folder voices**: map a folder prefix to a voice id for the active engine
|
||||
|
||||
Per-note override via YAML frontmatter:
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ npm install
|
|||
npm run dev
|
||||
```
|
||||
|
||||
The plugin should live inside your vault at `.obsidian/plugins/vox/`. Enable it under **Settings → Community plugins**.
|
||||
The plugin should live inside your vault at `.obsidian/plugins/vox/`. Enable it under **Settings -> Community plugins**.
|
||||
|
||||
Build for release:
|
||||
|
||||
|
|
@ -73,6 +73,6 @@ npm run typecheck
|
|||
|
||||
## Roadmap
|
||||
|
||||
- **Audio caching** — hash-keyed cache so unchanged notes don't re-bill cloud APIs
|
||||
- **Highlight as reads** — colour the current sentence during playback
|
||||
- **Section-level playback** — click a paragraph to start reading from there
|
||||
- **Audio caching**: hash-keyed cache so unchanged notes don't re-bill cloud APIs
|
||||
- **Highlight as reads**: colour the current sentence during playback
|
||||
- **Section-level playback**: click a paragraph to start reading from there
|
||||
|
|
|
|||
37
VOICES.md
Normal file
37
VOICES.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Voice Prompts
|
||||
|
||||
A collection of voice design prompts for use with ElevenLabs Voice Design and similar tools.
|
||||
Paste these into the voice generation prompt field to create a voice that fits your reading style.
|
||||
|
||||
---
|
||||
|
||||
## Epictetus
|
||||
|
||||
> An older male voice, weathered and direct. Speaks slowly and with weight, as if each sentence has been earned through hardship. No softness, no flattery — just plain truth delivered with patience and quiet authority. A slight roughness to the voice, grounded and still.
|
||||
|
||||
---
|
||||
|
||||
## Nassim Taleb
|
||||
|
||||
> A deep, accented male voice — Lebanese, educated in France and the US. Unhurried and self-assured, as if the idea is already settled in his mind and he is simply letting you catch up. Slightly gravelly. Speaks in bursts — a long pause, then a dense sentence. Amused rather than angry. The tone of someone who has stopped trying to convince people and is now just stating things.
|
||||
|
||||
---
|
||||
|
||||
## Tony Robbins
|
||||
|
||||
> A deep, powerful male voice with tremendous presence. Speaks with conviction and urgency, building energy through each sentence. Warm but commanding — like someone who genuinely believes what they are saying and wants you to feel it too. Full chest, no hesitation.
|
||||
|
||||
---
|
||||
|
||||
## David Attenborough
|
||||
|
||||
> A refined older British male voice, soft and full of quiet wonder. Unhurried, almost reverential. Pauses briefly to let ideas breathe. Feels like someone who has seen extraordinary things and is moved by all of them. Gentle authority — never loud, always compelling.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- On **ElevenLabs**, paste the prompt into Voice Design → Description field.
|
||||
- Adjust **stability** (higher = more consistent) and **clarity** (higher = more distinct) after generating.
|
||||
- A good starting point: stability 0.5, similarity boost 0.75.
|
||||
- Try the same prompt with different base voices — the description shapes personality, the base voice shapes timbre.
|
||||
|
|
@ -3,7 +3,7 @@ import process from "process";
|
|||
import builtins from "builtin-modules";
|
||||
|
||||
const banner = `/*
|
||||
Rhapsode — reads your notes aloud.
|
||||
Vox — reads your notes aloud.
|
||||
Bundled by esbuild; do not edit main.js directly.
|
||||
*/`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "rhapsode",
|
||||
"name": "Rhapsode",
|
||||
"id": "vox",
|
||||
"name": "Vox",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Reads your notes aloud with neural text-to-speech. Supports per-folder persona voices, local Piper TTS, and streaming cloud voices (ElevenLabs, OpenAI).",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "rhapsode",
|
||||
"name": "obsidian-vox",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rhapsode",
|
||||
"name": "obsidian-vox",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "rhapsode",
|
||||
"name": "obsidian-vox",
|
||||
"version": "0.1.0",
|
||||
"description": "Reads your Obsidian notes aloud with neural text-to-speech.",
|
||||
"main": "main.js",
|
||||
|
|
@ -8,7 +8,13 @@
|
|||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"typecheck": "tsc -noEmit -skipLibCheck"
|
||||
},
|
||||
"keywords": ["obsidian", "tts", "text-to-speech", "voice", "piper", "elevenlabs"],
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"tts",
|
||||
"text-to-speech",
|
||||
"voice",
|
||||
"elevenlabs"
|
||||
],
|
||||
"author": "danialkalbasi",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
|
|
@ -18,5 +24,6 @@
|
|||
"obsidian": "^1.7.2",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
|
|||
247
src/main.ts
247
src/main.ts
|
|
@ -6,62 +6,66 @@ import {
|
|||
addIcon,
|
||||
setIcon,
|
||||
} from "obsidian";
|
||||
import { DEFAULT_SETTINGS, RhapsodeSettings, RhapsodeSettingTab } from "./settings";
|
||||
import { DEFAULT_SETTINGS, VoxSettings, VoxSettingTab } from "./settings";
|
||||
import { Player, PlayerState } from "./player";
|
||||
import { stripMarkdown } from "./markdown";
|
||||
import { createBackend, TtsBackend } from "./tts/backend";
|
||||
|
||||
/**
|
||||
* Rhapsode plugin entry point.
|
||||
* Vox plugin entry point.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Register UI affordances: read / pause-resume / stop ribbon icons,
|
||||
* editor menu item, command palette commands.
|
||||
* 2. Hold the singleton TTS `Player` and a status-bar indicator that
|
||||
* mirrors its state.
|
||||
* 3. Load/save `RhapsodeSettings`, including per-folder voice overrides.
|
||||
* 3. Load/save `VoxSettings`, including per-folder voice overrides.
|
||||
*
|
||||
* The heavy lifting lives in `player.ts`, `markdown.ts`, and `tts/`.
|
||||
* This file just wires things together.
|
||||
*/
|
||||
|
||||
const ICON_READ = "rhapsode-read";
|
||||
const ICON_PAUSE = "rhapsode-pause";
|
||||
const ICON_PLAY = "rhapsode-play";
|
||||
const ICON_STOP = "rhapsode-stop";
|
||||
const ICON_READ = "vox-read";
|
||||
const ICON_PAUSE = "vox-pause";
|
||||
const ICON_PLAY = "vox-play";
|
||||
const ICON_STOP = "vox-stop";
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
[ICON_READ]: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 22 C 28 22 40 28 50 36 C 60 28 72 22 90 22 L 90 76 C 72 76 60 82 50 90 C 40 82 28 76 10 76 Z"/>
|
||||
<path d="M50 36 L 50 90"/>
|
||||
<svg viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0052 4.65867C11.4562 3.36887 13.75 4.39892 13.75 6.34034V17.6598C13.75 19.6012 11.4562 20.6313 10.0052 19.3415L6.80862 16.5001H4.75C3.50736 16.5001 2.5 15.4927 2.5 14.2501V9.75009C2.5 8.50745 3.50736 7.50009 4.75 7.50009H6.80858L10.0052 4.65867ZM12.25 6.34034C12.25 5.6932 11.4854 5.34985 11.0017 5.77978L7.592 8.81064C7.45472 8.93268 7.27742 9.00009 7.09373 9.00009H4.75C4.33579 9.00009 4 9.33587 4 9.75009V14.2501C4 14.6643 4.33579 15.0001 4.75 15.0001H7.09377C7.27745 15.0001 7.45475 15.0675 7.59204 15.1895L11.0017 18.2204C11.4854 18.6503 12.25 18.3069 12.25 17.6598V6.34034Z" fill="currentColor"/>
|
||||
<path d="M17.0769 15.1644C18.6384 13.4253 18.6384 10.5756 17.0769 8.83653C16.8001 8.52833 16.8256 8.05414 17.1338 7.7774C17.442 7.50066 17.9162 7.52617 18.193 7.83437C20.2664 10.1435 20.2664 13.8574 18.193 16.1665C17.9162 16.4747 17.442 16.5002 17.1338 16.2235C16.8256 15.9468 16.8001 15.4726 17.0769 15.1644Z" fill="currentColor"/>
|
||||
<path d="M14.9853 10.6534C15.6729 11.4179 15.6729 12.5831 14.9853 13.3476C14.7084 13.6556 14.7335 14.1298 15.0415 14.4068C15.3495 14.6838 15.8237 14.6586 16.1007 14.3506C17.3011 13.0157 17.3011 10.9852 16.1007 9.65036C15.8237 9.34236 15.3495 9.31721 15.0415 9.59418C14.7335 9.87115 14.7084 10.3454 14.9853 10.6534Z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
[ICON_PAUSE]: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="34" y1="20" x2="34" y2="80"/>
|
||||
<line x1="66" y1="20" x2="66" y2="80"/>
|
||||
<svg viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 3.25C5.75736 3.25 4.75 4.25736 4.75 5.5V18.4999C4.75 19.7426 5.75736 20.75 7 20.75H8.75C9.99264 20.75 11 19.7426 11 18.4999V5.5C11 4.25736 9.99264 3.25 8.75 3.25H7ZM6.25 5.5C6.25 5.08579 6.58579 4.75 7 4.75H8.75C9.16421 4.75 9.5 5.08579 9.5 5.5V18.4999C9.5 18.9142 9.16421 19.2499 8.75 19.2499H7C6.58579 19.2499 6.25 18.9142 6.25 18.4999V5.5Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.25 3.25C15.0074 3.25 14 4.25736 14 5.5V18.4999C14 19.7426 15.0074 20.75 16.25 20.75H18C19.2426 20.75 20.25 19.7426 20.25 18.4999V5.5C20.25 4.25736 19.2426 3.25 18 3.25H16.25ZM15.5 5.5C15.5 5.08579 15.8358 4.75 16.25 4.75H18C18.4142 4.75 18.75 5.08579 18.75 5.5V18.4999C18.75 18.9142 18.4142 19.2499 18 19.2499H16.25C15.8358 19.2499 15.5 18.9142 15.5 18.4999V5.5Z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
[ICON_PLAY]: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M28 18 L 82 50 L 28 82 Z" fill="currentColor"/>
|
||||
<svg viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.4357 13.9174C20.8659 13.0392 20.8659 10.9608 19.4357 10.0826L9.55234 4.01389C8.05317 3.09335 6.125 4.17205 6.125 5.93128L6.125 18.0688C6.125 19.828 8.05317 20.9067 9.55234 19.9861L19.4357 13.9174ZM18.6508 11.3609C19.1276 11.6536 19.1276 12.3464 18.6508 12.6391L8.76745 18.7079C8.26772 19.0147 7.625 18.6552 7.625 18.0688L7.625 5.93128C7.625 5.34487 8.26772 4.9853 8.76745 5.29215L18.6508 11.3609Z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
[ICON_STOP]: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="22" y="22" width="56" height="56" rx="6" fill="currentColor"/>
|
||||
<svg viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 7.75C9.25736 7.75 8.25 8.75736 8.25 10V14C8.25 15.2426 9.25736 16.25 10.5 16.25H14.5C15.7426 16.25 16.75 15.2426 16.75 14V10C16.75 8.75736 15.7426 7.75 14.5 7.75H10.5ZM9.75 10C9.75 9.58579 10.0858 9.25 10.5 9.25H14.5C14.9142 9.25 15.25 9.58579 15.25 10V14C15.25 14.4142 14.9142 14.75 14.5 14.75H10.5C10.0858 14.75 9.75 14.4142 9.75 14V10Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5 2C6.97715 2 2.5 6.47715 2.5 12C2.5 17.5228 6.97715 22 12.5 22C18.0228 22 22.5 17.5228 22.5 12C22.5 6.47715 18.0228 2 12.5 2ZM4 12C4 7.30558 7.80558 3.5 12.5 3.5C17.1944 3.5 21 7.30558 21 12C21 16.6944 17.1944 20.5 12.5 20.5C7.80558 20.5 4 16.6944 4 12Z" fill="currentColor"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
export default class RhapsodePlugin extends Plugin {
|
||||
settings!: RhapsodeSettings;
|
||||
export default class VoxPlugin extends Plugin {
|
||||
settings!: VoxSettings;
|
||||
player!: Player;
|
||||
private backend!: TtsBackend;
|
||||
|
||||
// UI elements whose appearance depends on player state. Held as
|
||||
// fields so the state listener can mutate them without re-querying.
|
||||
private statusEl: HTMLElement | null = null;
|
||||
private pauseRibbonEl: HTMLElement | null = null;
|
||||
private readRibbonEl: HTMLElement | null = null;
|
||||
private stopRibbonEl: HTMLElement | null = null;
|
||||
private unsubscribeState: (() => void) | null = null;
|
||||
private pendingVoice: string | null = null;
|
||||
private voicePickerEl: HTMLElement | null = null;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
|
@ -74,32 +78,31 @@ export default class RhapsodePlugin extends Plugin {
|
|||
this.player = new Player(this.backend);
|
||||
|
||||
// ── Ribbon icons ────────────────────────────────────────────
|
||||
this.addRibbonIcon(ICON_READ, "Rhapsode: read current note", async () => {
|
||||
await this.readActiveNote();
|
||||
// Single morphing icon: speaker (idle) → pause (playing) → play (paused).
|
||||
// A separate stop icon appears only while active.
|
||||
this.readRibbonEl = this.addRibbonIcon(ICON_READ, "Vox: read current note", () => {
|
||||
if (this.player.getState() === "idle") {
|
||||
this.readActiveNote();
|
||||
} else {
|
||||
this.player.togglePause();
|
||||
}
|
||||
});
|
||||
|
||||
this.pauseRibbonEl = this.addRibbonIcon(
|
||||
ICON_PAUSE,
|
||||
"Rhapsode: pause / resume",
|
||||
() => this.player.togglePause(),
|
||||
);
|
||||
// Hidden until playback starts — these controls have no meaning
|
||||
// when the player is idle, and an always-visible pause button in
|
||||
// the ribbon is visual clutter.
|
||||
this.pauseRibbonEl.style.display = "none";
|
||||
this.readRibbonEl.addClass("vox-ribbon");
|
||||
this.registerVoicePicker(this.readRibbonEl);
|
||||
|
||||
this.stopRibbonEl = this.addRibbonIcon(
|
||||
ICON_STOP,
|
||||
"Rhapsode: stop reading",
|
||||
"Vox: stop reading",
|
||||
() => this.player.stop(),
|
||||
);
|
||||
this.stopRibbonEl.addClass("vox-ribbon");
|
||||
this.stopRibbonEl.style.display = "none";
|
||||
|
||||
// ── Status bar ──────────────────────────────────────────────
|
||||
// Single clickable pill that shows current playback state.
|
||||
// Click behaviour: toggle pause when playing/paused, no-op idle.
|
||||
this.statusEl = this.addStatusBarItem();
|
||||
this.statusEl.addClass("rhapsode-status");
|
||||
this.statusEl.addClass("vox-status");
|
||||
this.statusEl.addEventListener("click", () => {
|
||||
if (this.player.getState() !== "idle") this.player.togglePause();
|
||||
});
|
||||
|
|
@ -131,7 +134,7 @@ export default class RhapsodePlugin extends Plugin {
|
|||
if (!(file instanceof TFile) || file.extension !== "md") return;
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("Rhapsode: read aloud")
|
||||
.setTitle("Vox: read aloud")
|
||||
.setIcon(ICON_READ)
|
||||
.onClick(async () => {
|
||||
await this.readFile(file);
|
||||
|
|
@ -145,7 +148,7 @@ export default class RhapsodePlugin extends Plugin {
|
|||
this.renderState(s),
|
||||
);
|
||||
|
||||
this.addSettingTab(new RhapsodeSettingTab(this.app, this));
|
||||
this.addSettingTab(new VoxSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async onunload() {
|
||||
|
|
@ -160,10 +163,11 @@ export default class RhapsodePlugin extends Plugin {
|
|||
private renderState(state: PlayerState) {
|
||||
const active = state !== "idle";
|
||||
|
||||
if (this.pauseRibbonEl) {
|
||||
this.pauseRibbonEl.style.display = active ? "" : "none";
|
||||
// Swap pause↔play icon so the button's meaning is obvious.
|
||||
setIcon(this.pauseRibbonEl, state === "paused" ? ICON_PLAY : ICON_PAUSE);
|
||||
if (this.readRibbonEl) {
|
||||
const icon = state === "idle" ? ICON_READ : state === "playing" ? ICON_PAUSE : ICON_PLAY;
|
||||
const label = state === "idle" ? "Vox: read current note" : state === "playing" ? "Vox: pause" : "Vox: resume";
|
||||
setIcon(this.readRibbonEl, icon);
|
||||
this.readRibbonEl.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
if (this.stopRibbonEl) {
|
||||
|
|
@ -178,10 +182,10 @@ export default class RhapsodePlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
this.statusEl.style.display = "";
|
||||
const icon = this.statusEl.createSpan({ cls: "rhapsode-status-icon" });
|
||||
const icon = this.statusEl.createSpan({ cls: "vox-status-icon" });
|
||||
setIcon(icon, state === "playing" ? ICON_PAUSE : ICON_PLAY);
|
||||
this.statusEl.createSpan({
|
||||
cls: "rhapsode-status-text",
|
||||
cls: "vox-status-text",
|
||||
text: state === "playing" ? " Reading" : " Paused",
|
||||
});
|
||||
}
|
||||
|
|
@ -191,7 +195,7 @@ export default class RhapsodePlugin extends Plugin {
|
|||
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
const file = view?.file;
|
||||
if (!file) {
|
||||
new Notice("Rhapsode: no active note to read.");
|
||||
new Notice("Vox: no active note to read.");
|
||||
return;
|
||||
}
|
||||
await this.readFile(file);
|
||||
|
|
@ -203,28 +207,103 @@ export default class RhapsodePlugin extends Plugin {
|
|||
const raw = await this.app.vault.cachedRead(file);
|
||||
const text = stripMarkdown(raw);
|
||||
if (!text.trim()) {
|
||||
new Notice("Rhapsode: note is empty after stripping markdown.");
|
||||
new Notice("Vox: note is empty after stripping markdown.");
|
||||
return;
|
||||
}
|
||||
|
||||
const voice = this.resolveVoice(file);
|
||||
const voice = this.pendingVoice ?? this.resolveVoice(file);
|
||||
this.pendingVoice = null;
|
||||
|
||||
try {
|
||||
this.player.setRate(this.settings.rate);
|
||||
await this.player.play(text, voice);
|
||||
new Notice(`Rhapsode: reading “${file.basename}”…`);
|
||||
new Notice(`Vox: reading "${file.basename}"...`);
|
||||
} catch (err) {
|
||||
console.error("Rhapsode playback failed:", err);
|
||||
new Notice(`Rhapsode: ${(err as Error).message}`);
|
||||
console.error("Vox playback failed:", err);
|
||||
new Notice(`Vox: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private registerVoicePicker(anchor: HTMLElement) {
|
||||
let closeTimer: number;
|
||||
|
||||
const getVoices = (): { label: string; id: string }[] => {
|
||||
const { engine, elevenlabsVoices } = this.settings;
|
||||
if (engine === "elevenlabs") {
|
||||
return elevenlabsVoices.map((v) => ({ label: v.name, id: v.id }));
|
||||
}
|
||||
if (engine === "openai") {
|
||||
return ["alloy","ash","ballad","cedar","coral","echo","fable","marin","nova","onyx","sage","shimmer","verse"]
|
||||
.map((v) => ({ label: v, id: v }));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const closePicker = () => {
|
||||
this.voicePickerEl?.remove();
|
||||
this.voicePickerEl = null;
|
||||
const label = anchor.getAttribute("data-vox-label");
|
||||
if (label) anchor.setAttribute("aria-label", label);
|
||||
};
|
||||
|
||||
const openPicker = () => {
|
||||
if (this.player.getState() !== "idle") return;
|
||||
const voices = getVoices();
|
||||
if (voices.length === 0) return;
|
||||
|
||||
// Stash the current aria-label and clear it so Obsidian's native
|
||||
// tooltip doesn't appear alongside the picker.
|
||||
if (!anchor.getAttribute("data-vox-label")) {
|
||||
anchor.setAttribute("data-vox-label", anchor.getAttribute("aria-label") ?? "");
|
||||
}
|
||||
anchor.removeAttribute("aria-label");
|
||||
|
||||
closePicker();
|
||||
const picker = document.body.createEl("div", { cls: "vox-voice-picker" });
|
||||
this.voicePickerEl = picker;
|
||||
|
||||
const activeId = this.pendingVoice ?? this.settings.voiceElevenlabs ?? this.settings.voiceOpenai;
|
||||
for (const voice of voices) {
|
||||
const item = picker.createEl("div", {
|
||||
cls: "vox-voice-picker-item" + (voice.id === activeId ? " vox-voice-picker-item--active" : ""),
|
||||
text: voice.label,
|
||||
});
|
||||
item.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
this.pendingVoice = voice.id;
|
||||
closePicker();
|
||||
this.readActiveNote();
|
||||
});
|
||||
}
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
picker.style.left = `${rect.right + 4}px`;
|
||||
picker.style.top = `${rect.top}px`;
|
||||
|
||||
picker.addEventListener("mouseenter", () => clearTimeout(closeTimer));
|
||||
picker.addEventListener("mouseleave", () => {
|
||||
closeTimer = window.setTimeout(closePicker, 150);
|
||||
});
|
||||
};
|
||||
|
||||
anchor.addEventListener("mouseenter", () => {
|
||||
clearTimeout(closeTimer);
|
||||
openPicker();
|
||||
});
|
||||
anchor.addEventListener("mouseleave", () => {
|
||||
closeTimer = window.setTimeout(closePicker, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persona voice selection: folder prefix (longest wins) → frontmatter
|
||||
* `voice:` key → global default. Centralised here so backends stay
|
||||
* voice-agnostic.
|
||||
*/
|
||||
private resolveVoice(file: TFile): string {
|
||||
const overrides = Object.entries(this.settings.folderVoices).sort(
|
||||
const overrides = Object.entries(
|
||||
this.settings.folderVoicesByEngine[this.settings.engine] ?? {},
|
||||
).sort(
|
||||
(a, b) => b[0].length - a[0].length,
|
||||
);
|
||||
for (const [prefix, voice] of overrides) {
|
||||
|
|
@ -235,18 +314,80 @@ export default class RhapsodePlugin extends Plugin {
|
|||
const fmVoice = fm?.voice;
|
||||
if (typeof fmVoice === "string" && fmVoice.trim()) return fmVoice.trim();
|
||||
|
||||
return this.settings.defaultVoice;
|
||||
switch (this.settings.engine) {
|
||||
case "browser":
|
||||
return this.settings.voiceBrowser;
|
||||
case "openai":
|
||||
return this.settings.voiceOpenai;
|
||||
case "elevenlabs":
|
||||
return this.settings.voiceElevenlabs;
|
||||
}
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
const raw = (await this.loadData()) as
|
||||
| (Record<string, unknown> & {
|
||||
defaultVoice?: string;
|
||||
folderVoices?: Record<string, string>;
|
||||
folderVoicesByEngine?: Partial<
|
||||
Record<
|
||||
VoxSettings["engine"],
|
||||
Record<string, string>
|
||||
>
|
||||
>;
|
||||
})
|
||||
| null;
|
||||
const merged = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
raw ?? {},
|
||||
) as VoxSettings & {
|
||||
defaultVoice?: string;
|
||||
folderVoices?: Record<string, string>;
|
||||
};
|
||||
|
||||
merged.folderVoicesByEngine = {
|
||||
...DEFAULT_SETTINGS.folderVoicesByEngine,
|
||||
...(raw?.folderVoicesByEngine ?? {}),
|
||||
};
|
||||
|
||||
if (
|
||||
typeof merged.defaultVoice === "string" &&
|
||||
merged.defaultVoice.length > 0
|
||||
) {
|
||||
const eng = merged.engine;
|
||||
const leg = merged.defaultVoice;
|
||||
const isEmpty = (v: string | undefined) => !v || v.length === 0;
|
||||
if (eng === "browser" && isEmpty(merged.voiceBrowser))
|
||||
merged.voiceBrowser = leg;
|
||||
if (eng === "openai" && isEmpty(merged.voiceOpenai))
|
||||
merged.voiceOpenai = leg;
|
||||
if (eng === "elevenlabs" && isEmpty(merged.voiceElevenlabs))
|
||||
merged.voiceElevenlabs = leg;
|
||||
}
|
||||
delete merged.defaultVoice;
|
||||
|
||||
if (
|
||||
raw?.folderVoices &&
|
||||
typeof raw.folderVoices === "object" &&
|
||||
Object.keys(raw.folderVoices).length > 0
|
||||
) {
|
||||
const engine = merged.engine;
|
||||
const existing = merged.folderVoicesByEngine[engine] ?? {};
|
||||
merged.folderVoicesByEngine[engine] = {
|
||||
...raw.folderVoices,
|
||||
...existing,
|
||||
};
|
||||
}
|
||||
delete merged.folderVoices;
|
||||
|
||||
this.settings = merged;
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
// Rebuild backend so the live Player picks up engine/API-key
|
||||
// changes without a full plugin reload.
|
||||
this.backend = createBackend(this.settings, this);
|
||||
this.player.setBackend(this.backend);
|
||||
this.player.setRate(this.settings.rate);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ export class Player {
|
|||
private queue: string[] = [];
|
||||
private cursor = 0;
|
||||
private audio: HTMLAudioElement | null = null;
|
||||
private cancelled = false;
|
||||
private rate = 1.0;
|
||||
private tokenCounter = 0;
|
||||
private activeToken = 0;
|
||||
|
||||
private state: PlayerState = "idle";
|
||||
private listeners: Set<PlayerStateListener> = new Set();
|
||||
|
|
@ -37,7 +38,6 @@ export class Player {
|
|||
|
||||
setRate(rate: number) {
|
||||
this.rate = rate;
|
||||
if (this.audio) this.audio.playbackRate = rate;
|
||||
}
|
||||
|
||||
getState(): PlayerState {
|
||||
|
|
@ -60,60 +60,63 @@ export class Player {
|
|||
for (const l of this.listeners) l(next);
|
||||
}
|
||||
|
||||
private isActive(token: number): boolean {
|
||||
return token !== 0 && token === this.activeToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playing the given text with the given voice. Stops any
|
||||
* currently-playing audio first.
|
||||
*/
|
||||
async play(text: string, voice: string): Promise<void> {
|
||||
this.stop();
|
||||
this.cancelled = false;
|
||||
const token = ++this.tokenCounter;
|
||||
this.activeToken = token;
|
||||
|
||||
this.queue = splitIntoSentences(text);
|
||||
this.cursor = 0;
|
||||
if (this.queue.length === 0) return;
|
||||
|
||||
this.setState("playing");
|
||||
|
||||
if (this.backend.kind === "synth") {
|
||||
this.setState("playing");
|
||||
// Browser SpeechSynthesis queues utterances internally. We hook
|
||||
// the last one's `onend` to transition back to `idle` on natural
|
||||
// completion; the backend will emit that callback for us.
|
||||
await this.backend.speakAll(this.queue, voice, this.rate, () => {
|
||||
if (!this.cancelled) this.setState("idle");
|
||||
if (this.isActive(token)) this.setState("idle");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.audio = new Audio();
|
||||
this.audio.playbackRate = this.rate;
|
||||
// Keep state in sync with underlying element events: pausing the
|
||||
// <audio> outside our API (e.g. OS media-key interception) still
|
||||
// updates our state cleanly.
|
||||
this.audio.onpause = () => {
|
||||
if (!this.cancelled && this.audio && !this.audio.ended) {
|
||||
if (this.isActive(token) && this.audio && !this.audio.ended) {
|
||||
this.setState("paused");
|
||||
}
|
||||
};
|
||||
this.audio.onplay = () => {
|
||||
if (!this.cancelled) this.setState("playing");
|
||||
if (this.isActive(token)) this.setState("playing");
|
||||
};
|
||||
await this.playNext(voice);
|
||||
await this.playNext(token, voice);
|
||||
}
|
||||
|
||||
private async playNext(voice: string): Promise<void> {
|
||||
if (this.cancelled) return;
|
||||
private async playNext(token: number, voice: string): Promise<void> {
|
||||
if (!this.isActive(token)) return;
|
||||
if (this.cursor >= this.queue.length) {
|
||||
// Natural end of queue — only fires for URL backends. Synth
|
||||
// backend signals completion via the callback passed to speakAll.
|
||||
this.audio = null;
|
||||
this.setState("idle");
|
||||
if (this.isActive(token)) this.setState("idle");
|
||||
return;
|
||||
}
|
||||
if (!this.audio || this.backend.kind !== "url") return;
|
||||
|
||||
const sentence = this.queue[this.cursor++];
|
||||
const url = await this.backend.synthesizeToUrl(sentence, voice);
|
||||
if (this.cancelled) {
|
||||
const url = await this.backend.synthesizeToUrl(sentence, voice, this.rate);
|
||||
if (!this.isActive(token)) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
|
@ -122,13 +125,15 @@ export class Player {
|
|||
|
||||
this.audio.onended = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
this.playNext(voice).catch((err) => {
|
||||
console.error("Rhapsode: playback error", err);
|
||||
if (!this.isActive(token)) return;
|
||||
this.playNext(token, voice).catch((err) => {
|
||||
console.error("Vox: playback error", err);
|
||||
this.setState("idle");
|
||||
});
|
||||
};
|
||||
|
||||
await this.audio.play();
|
||||
if (this.isActive(token)) this.setState("playing");
|
||||
}
|
||||
|
||||
pause() {
|
||||
|
|
@ -152,8 +157,11 @@ export class Player {
|
|||
|
||||
stop() {
|
||||
if (this.state === "idle" && !this.audio && this.queue.length === 0) return;
|
||||
this.cancelled = true;
|
||||
this.activeToken = ++this.tokenCounter;
|
||||
if (this.audio) {
|
||||
this.audio.onpause = null;
|
||||
this.audio.onplay = null;
|
||||
this.audio.onended = null;
|
||||
this.audio.pause();
|
||||
this.audio.src = "";
|
||||
this.audio = null;
|
||||
|
|
|
|||
436
src/settings.ts
436
src/settings.ts
|
|
@ -1,288 +1,342 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import type RhapsodePlugin from "./main";
|
||||
import type VoxPlugin from "./main";
|
||||
|
||||
export type TtsEngine = "browser" | "elevenlabs" | "openai" | "piper";
|
||||
export type TtsEngine = "browser" | "elevenlabs" | "openai";
|
||||
|
||||
export interface RhapsodeSettings {
|
||||
/** Which TTS backend to use. `browser` uses the free built-in
|
||||
* SpeechSynthesis API (works offline, low quality). */
|
||||
export interface VoxSettings {
|
||||
/** Active backend — only one runs at a time. */
|
||||
engine: TtsEngine;
|
||||
|
||||
/** Default voice identifier, meaning depends on the engine:
|
||||
* - browser: a voice `name` from speechSynthesis.getVoices()
|
||||
* - elevenlabs: a voice_id (e.g. "21m00Tcm4TlvDq8ikWAM")
|
||||
* - openai: one of "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer"
|
||||
* - piper: a voice model filename (e.g. "en_US-lessac-medium") */
|
||||
defaultVoice: string;
|
||||
|
||||
/** Playback rate multiplier (1.0 = natural). Applied on the <audio>
|
||||
* element as `playbackRate`. */
|
||||
/** Playback rate for `<audio>` and browser synth (1.0 = natural). */
|
||||
rate: number;
|
||||
|
||||
/** API keys for cloud engines. Stored in data.json inside the plugin
|
||||
* folder; treat as you would any other local secret. */
|
||||
/** Per-engine default voices — never mix with API keys. */
|
||||
voiceBrowser: string;
|
||||
voiceOpenai: string;
|
||||
voiceElevenlabs: string;
|
||||
|
||||
elevenlabsApiKey: string;
|
||||
openaiApiKey: string;
|
||||
|
||||
/** Model tier when using elevenlabs (monolingual vs multilingual). */
|
||||
elevenlabsModel: string;
|
||||
|
||||
/** OpenAI TTS model — "tts-1" (fast) or "tts-1-hd" (better quality). */
|
||||
/** Named voice library for ElevenLabs. */
|
||||
elevenlabsVoices: Array<{ name: string; id: string }>;
|
||||
|
||||
openaiModel: "tts-1" | "tts-1-hd";
|
||||
openaiInstructions: string;
|
||||
|
||||
/**
|
||||
* URL of a running Piper HTTP server, e.g. http://localhost:5000.
|
||||
* Start one with `python3 -m piper.http_server -m <voice>`.
|
||||
*/
|
||||
piperServerUrl: string;
|
||||
folderVoicesByEngine: Record<TtsEngine, Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Folder-prefix → voice-id map for persona overrides.
|
||||
* Example: { "Philosophy/": "epictetus-voice-id" }
|
||||
* Longest prefix wins when multiple match.
|
||||
*/
|
||||
folderVoices: Record<string, string>;
|
||||
|
||||
/** Cache generated audio to disk (by note-hash) to avoid re-billing. */
|
||||
cacheEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: RhapsodeSettings = {
|
||||
export const DEFAULT_SETTINGS: VoxSettings = {
|
||||
engine: "browser",
|
||||
defaultVoice: "",
|
||||
rate: 1.0,
|
||||
voiceBrowser: "",
|
||||
voiceOpenai: "alloy",
|
||||
voiceElevenlabs: "",
|
||||
elevenlabsApiKey: "",
|
||||
openaiApiKey: "",
|
||||
elevenlabsModel: "eleven_turbo_v2_5",
|
||||
elevenlabsVoices: [],
|
||||
openaiModel: "tts-1",
|
||||
piperServerUrl: "http://localhost:5000",
|
||||
folderVoices: {},
|
||||
cacheEnabled: true,
|
||||
openaiInstructions: "",
|
||||
folderVoicesByEngine: {
|
||||
browser: {},
|
||||
elevenlabs: {},
|
||||
openai: {},
|
||||
},
|
||||
cacheEnabled: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings tab — reachable from Obsidian's Community Plugins settings.
|
||||
* Layout is intentionally flat (one vertical list of controls) rather
|
||||
* than tabbed sub-sections; the surface area is small enough that
|
||||
* extra structure would just add friction.
|
||||
*/
|
||||
export class RhapsodeSettingTab extends PluginSettingTab {
|
||||
private plugin: RhapsodePlugin;
|
||||
function section(containerEl: HTMLElement, title: string, desc?: string) {
|
||||
containerEl.createEl("h3", { text: title });
|
||||
if (desc) {
|
||||
containerEl.createEl("p", { text: desc, cls: "setting-item-description" });
|
||||
}
|
||||
}
|
||||
|
||||
constructor(app: App, plugin: RhapsodePlugin) {
|
||||
export class VoxSettingTab extends PluginSettingTab {
|
||||
private plugin: VoxPlugin;
|
||||
|
||||
constructor(app: App, plugin: VoxPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
const s = this.plugin.settings;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl("h2", { text: "Rhapsode" });
|
||||
containerEl.createEl("p", {
|
||||
text: "Reads your notes aloud. Pick a backend, drop in an API key if needed, and assign persona voices per folder.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
containerEl.createEl("h2", { text: "Vox" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Engine")
|
||||
.setDesc(
|
||||
"Which text-to-speech backend to use. Browser = free, offline, robotic. ElevenLabs / OpenAI = paid API, natural voices. Piper = local neural TTS (not yet wired up).",
|
||||
)
|
||||
.setDesc("Which service reads your notes aloud.")
|
||||
.addDropdown((dd) =>
|
||||
dd
|
||||
.addOption("browser", "Browser SpeechSynthesis (free)")
|
||||
.addOption("openai", "OpenAI TTS")
|
||||
.addOption("elevenlabs", "ElevenLabs")
|
||||
.addOption("piper", "Piper (local, desktop only)")
|
||||
.setValue(this.plugin.settings.engine)
|
||||
.addOption("openai", "OpenAI")
|
||||
.addOption("browser", "Browser (free, no account needed)")
|
||||
.setValue(s.engine)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.engine = value as TtsEngine;
|
||||
s.engine = value as TtsEngine;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
const speedLimits: Record<TtsEngine, [number, number, number]> = {
|
||||
elevenlabs: [0.7, 1.2, 0.05],
|
||||
openai: [0.25, 4.0, 0.05],
|
||||
browser: [0.6, 2.0, 0.05],
|
||||
};
|
||||
const [minSpeed, maxSpeed, step] = speedLimits[s.engine];
|
||||
const clampedRate = Math.min(maxSpeed, Math.max(minSpeed, s.rate));
|
||||
new Setting(containerEl)
|
||||
.setName("Default voice")
|
||||
.setDesc(this.voiceHintForEngine(this.plugin.settings.engine))
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder(this.voicePlaceholder(this.plugin.settings.engine))
|
||||
.setValue(this.plugin.settings.defaultVoice)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.defaultVoice = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Playback rate")
|
||||
.setDesc("1.0 is natural. 1.5 is brisk. 0.8 for contemplative reading.")
|
||||
.addSlider((s) =>
|
||||
s
|
||||
.setLimits(0.6, 2.0, 0.05)
|
||||
.setValue(this.plugin.settings.rate)
|
||||
.setName("Speed")
|
||||
.setDesc(`Playback speed. 1.0 is normal. Range: ${minSpeed}x – ${maxSpeed}x.`)
|
||||
.addSlider((sl) =>
|
||||
sl
|
||||
.setLimits(minSpeed, maxSpeed, step)
|
||||
.setValue(clampedRate)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.rate = v;
|
||||
s.rate = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
// ── API keys (only shown when relevant) ───────────────────────
|
||||
if (this.plugin.settings.engine === "elevenlabs") {
|
||||
// ── Provider-specific settings ────────────────────────────────
|
||||
if (s.engine === "browser") {
|
||||
section(containerEl, "Browser");
|
||||
new Setting(containerEl)
|
||||
.setName("ElevenLabs API key")
|
||||
.setDesc("From elevenlabs.io → Profile → API Key.")
|
||||
.setName("Voice")
|
||||
.setDesc("Name of the voice to use, e.g. Samantha or Alex on macOS. Leave blank for the system default.")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder("sk_...")
|
||||
.setValue(this.plugin.settings.elevenlabsApiKey)
|
||||
.setPlaceholder("Samantha")
|
||||
.setValue(s.voiceBrowser)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.elevenlabsApiKey = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
new Setting(containerEl)
|
||||
.setName("ElevenLabs model")
|
||||
.addDropdown((dd) =>
|
||||
dd
|
||||
.addOption("eleven_turbo_v2_5", "Turbo v2.5 (fast, cheap)")
|
||||
.addOption("eleven_multilingual_v2", "Multilingual v2 (best quality)")
|
||||
.setValue(this.plugin.settings.elevenlabsModel)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.elevenlabsModel = v;
|
||||
s.voiceBrowser = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.plugin.settings.engine === "piper") {
|
||||
if (s.engine === "openai") {
|
||||
section(containerEl, "OpenAI");
|
||||
new Setting(containerEl)
|
||||
.setName("Piper server URL")
|
||||
.setDesc(
|
||||
"Rhapsode talks to a local Piper HTTP server. One-time setup: `pip install piper-tts[http]`, then in any terminal run `python3 -m piper.download_voices en_US-lessac-medium` and `python3 -m piper.http_server -m en_US-lessac-medium`. Leave that terminal running. Default URL works if you started the server on the default port.",
|
||||
)
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder("http://localhost:5000")
|
||||
.setValue(this.plugin.settings.piperServerUrl)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.piperServerUrl = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.plugin.settings.engine === "openai") {
|
||||
new Setting(containerEl)
|
||||
.setName("OpenAI API key")
|
||||
.setDesc("From platform.openai.com → API keys.")
|
||||
.setName("API key")
|
||||
.setDesc("Your OpenAI secret key. Find it at platform.openai.com.")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder("sk-...")
|
||||
.setValue(this.plugin.settings.openaiApiKey)
|
||||
.setValue(s.openaiApiKey)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.openaiApiKey = v;
|
||||
s.openaiApiKey = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
new Setting(containerEl)
|
||||
.setName("OpenAI TTS model")
|
||||
.setName("Model")
|
||||
.setDesc("tts-1 is faster, tts-1-hd sounds better.")
|
||||
.addDropdown((dd) =>
|
||||
dd
|
||||
.addOption("tts-1", "tts-1 (fast, cheap)")
|
||||
.addOption("tts-1-hd", "tts-1-hd (better quality)")
|
||||
.setValue(this.plugin.settings.openaiModel)
|
||||
.addOption("tts-1", "tts-1")
|
||||
.addOption("tts-1-hd", "tts-1-hd")
|
||||
.setValue(s.openaiModel)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.openaiModel = v as "tts-1" | "tts-1-hd";
|
||||
s.openaiModel = v as "tts-1" | "tts-1-hd";
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
new Setting(containerEl)
|
||||
.setName("Voice")
|
||||
.setDesc("The character of the voice.")
|
||||
.addDropdown((dd) => {
|
||||
for (const v of ["alloy","ash","ballad","cedar","coral","echo","fable","marin","nova","onyx","sage","shimmer","verse"]) {
|
||||
dd.addOption(v, v);
|
||||
}
|
||||
return dd
|
||||
.setValue(s.voiceOpenai)
|
||||
.onChange(async (v) => {
|
||||
s.voiceOpenai = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
new Setting(containerEl)
|
||||
.setName("Tone")
|
||||
.setDesc("How the voice delivers the text.")
|
||||
.addDropdown((dd) => {
|
||||
const tones: Record<string, string> = {
|
||||
"": "(none)",
|
||||
"Speak in a calm and warm tone.": "Calm and warm",
|
||||
"Speak in a natural, conversational tone.": "Conversational",
|
||||
"Speak like a professional news anchor.": "News anchor",
|
||||
"Speak in a storytelling tone, with natural pacing and expression.": "Storytelling",
|
||||
"Speak in an energetic and upbeat tone.": "Energetic",
|
||||
};
|
||||
for (const [val, label] of Object.entries(tones)) {
|
||||
dd.addOption(val, label);
|
||||
}
|
||||
return dd
|
||||
.setValue(s.openaiInstructions)
|
||||
.onChange(async (v) => {
|
||||
s.openaiInstructions = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (s.engine === "elevenlabs") {
|
||||
section(containerEl, "ElevenLabs");
|
||||
new Setting(containerEl)
|
||||
.setName("API key")
|
||||
.setDesc("Your ElevenLabs API key. Find it in your profile settings.")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder("xi-api-key / sk_...")
|
||||
.setValue(s.elevenlabsApiKey)
|
||||
.onChange(async (v) => {
|
||||
s.elevenlabsApiKey = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
new Setting(containerEl)
|
||||
.setName("Model")
|
||||
.setDesc("Turbo v2.5 is faster. Multilingual v2 supports more languages.")
|
||||
.addDropdown((dd) =>
|
||||
dd
|
||||
.addOption("eleven_turbo_v2_5", "Turbo v2.5")
|
||||
.addOption("eleven_multilingual_v2", "Multilingual v2")
|
||||
.setValue(s.elevenlabsModel)
|
||||
.onChange(async (v) => {
|
||||
s.elevenlabsModel = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
// Add voice row
|
||||
section(containerEl, "Voices");
|
||||
let newVoiceName = "";
|
||||
let newVoiceId = "";
|
||||
const addVoiceSetting = new Setting(containerEl)
|
||||
.setName("Add voice")
|
||||
.setDesc("Name + ElevenLabs voice ID.")
|
||||
.addText((t) =>
|
||||
t.setPlaceholder("Name").onChange((v) => (newVoiceName = v)),
|
||||
)
|
||||
.addText((t) =>
|
||||
t.setPlaceholder("Voice ID").onChange((v) => (newVoiceId = v)),
|
||||
)
|
||||
.addButton((b) =>
|
||||
b.setButtonText("Add").onClick(async () => {
|
||||
if (!newVoiceName.trim() || !newVoiceId.trim()) return;
|
||||
s.elevenlabsVoices.push({ name: newVoiceName.trim(), id: newVoiceId.trim() });
|
||||
if (!s.voiceElevenlabs) s.voiceElevenlabs = newVoiceId.trim();
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
// Added voices as tags — click to set as default, × to remove
|
||||
if (s.elevenlabsVoices.length > 0) {
|
||||
const tags = addVoiceSetting.settingEl.createEl("div", { cls: "vox-voice-tags" });
|
||||
for (const voice of s.elevenlabsVoices) {
|
||||
const isDefault = s.voiceElevenlabs === voice.id;
|
||||
const chip = tags.createEl("span", {
|
||||
cls: "vox-voice-chip" + (isDefault ? " vox-voice-chip--active" : ""),
|
||||
title: isDefault ? "Default" : "Click to set as default",
|
||||
});
|
||||
chip.createEl("span", { text: voice.name, cls: "vox-voice-chip-name" });
|
||||
chip.addEventListener("click", async (e) => {
|
||||
if ((e.target as HTMLElement).closest(".vox-voice-chip-remove")) return;
|
||||
s.voiceElevenlabs = voice.id;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
const x = chip.createEl("span", { cls: "vox-voice-chip-remove", text: "×" });
|
||||
x.addEventListener("click", async () => {
|
||||
s.elevenlabsVoices = s.elevenlabsVoices.filter((v) => v.id !== voice.id);
|
||||
if (s.voiceElevenlabs === voice.id) s.voiceElevenlabs = s.elevenlabsVoices[0]?.id ?? "";
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cache ────────────────────────────────────────────────────
|
||||
if (s.engine === "openai" || s.engine === "elevenlabs") {
|
||||
new Setting(containerEl)
|
||||
.setName("Cache audio")
|
||||
.setDesc("Save generated audio so unchanged notes are not re-billed. Coming soon.")
|
||||
.addToggle((tg) =>
|
||||
tg.setValue(s.cacheEnabled).onChange(async (v) => {
|
||||
s.cacheEnabled = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Caching ───────────────────────────────────────────────────
|
||||
new Setting(containerEl)
|
||||
.setName("Cache generated audio")
|
||||
.setDesc(
|
||||
"Saves generated audio to .obsidian/plugins/rhapsode/cache/ keyed by note content hash. Prevents re-billing cloud TTS when the note hasn't changed.",
|
||||
)
|
||||
.addToggle((t) =>
|
||||
t.setValue(this.plugin.settings.cacheEnabled).onChange(async (v) => {
|
||||
this.plugin.settings.cacheEnabled = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
// ── Folder personas ──────────────────────────────────────────
|
||||
section(containerEl, "Folder voices");
|
||||
|
||||
// ── Persona voices per folder ─────────────────────────────────
|
||||
containerEl.createEl("h3", { text: "Persona voices per folder" });
|
||||
containerEl.createEl("p", {
|
||||
text: "Assign a different voice to each folder prefix. The longest matching prefix wins. You can also set a `voice:` key in a note's frontmatter to override per-note.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
const folderVoices = s.folderVoicesByEngine[s.engine];
|
||||
const elevenlabsVoiceOptions = s.engine === "elevenlabs" && s.elevenlabsVoices.length > 0
|
||||
? s.elevenlabsVoices
|
||||
: null;
|
||||
|
||||
const folderVoices = this.plugin.settings.folderVoices;
|
||||
for (const prefix of Object.keys(folderVoices)) {
|
||||
new Setting(containerEl)
|
||||
.setName(prefix)
|
||||
.addText((t) =>
|
||||
const setting = new Setting(containerEl).setName(prefix);
|
||||
if (elevenlabsVoiceOptions) {
|
||||
setting.addDropdown((dd) => {
|
||||
for (const v of elevenlabsVoiceOptions) dd.addOption(v.id, v.name);
|
||||
return dd.setValue(folderVoices[prefix]).onChange(async (v) => {
|
||||
folderVoices[prefix] = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setting.addText((t) =>
|
||||
t.setValue(folderVoices[prefix]).onChange(async (v) => {
|
||||
folderVoices[prefix] = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
)
|
||||
.addExtraButton((b) =>
|
||||
b
|
||||
.setIcon("trash")
|
||||
.setTooltip("Remove")
|
||||
.onClick(async () => {
|
||||
delete folderVoices[prefix];
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let newPrefix = "";
|
||||
let newVoice = "";
|
||||
new Setting(containerEl)
|
||||
.setName("Add folder mapping")
|
||||
.addText((t) => t.setPlaceholder("Philosophy/").onChange((v) => (newPrefix = v)))
|
||||
.addText((t) => t.setPlaceholder("voice-id").onChange((v) => (newVoice = v)))
|
||||
.addButton((b) =>
|
||||
b.setButtonText("Add").onClick(async () => {
|
||||
if (!newPrefix || !newVoice) return;
|
||||
folderVoices[newPrefix] = newVoice;
|
||||
}
|
||||
setting.addExtraButton((b) =>
|
||||
b.setIcon("trash").setTooltip("Remove").onClick(async () => {
|
||||
delete folderVoices[prefix];
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private voiceHintForEngine(engine: TtsEngine): string {
|
||||
switch (engine) {
|
||||
case "browser":
|
||||
return "System voice name, e.g. 'Samantha', 'Alex', 'Daniel'. Leave blank for the system default.";
|
||||
case "elevenlabs":
|
||||
return "ElevenLabs voice_id (22-char string from your ElevenLabs voice library).";
|
||||
case "openai":
|
||||
return "One of: alloy, echo, fable, onyx, nova, shimmer.";
|
||||
case "piper":
|
||||
return "Voice name (e.g. en_US-lessac-medium). Leave blank to use whatever voice the Piper server was launched with.";
|
||||
}
|
||||
}
|
||||
|
||||
private voicePlaceholder(engine: TtsEngine): string {
|
||||
switch (engine) {
|
||||
case "browser":
|
||||
return "Samantha";
|
||||
case "elevenlabs":
|
||||
return "21m00Tcm4TlvDq8ikWAM";
|
||||
case "openai":
|
||||
return "alloy";
|
||||
case "piper":
|
||||
return "en_US-lessac-medium";
|
||||
let newPrefix = "";
|
||||
let newVoice = elevenlabsVoiceOptions?.[0]?.id ?? "";
|
||||
const addFolderSetting = new Setting(containerEl)
|
||||
.setName("Add folder mapping")
|
||||
.addText((t) => t.setPlaceholder("Philosophy/").onChange((v) => (newPrefix = v)));
|
||||
if (elevenlabsVoiceOptions) {
|
||||
addFolderSetting.addDropdown((dd) => {
|
||||
for (const v of elevenlabsVoiceOptions) dd.addOption(v.id, v.name);
|
||||
return dd.setValue(newVoice).onChange((v) => (newVoice = v));
|
||||
});
|
||||
} else {
|
||||
addFolderSetting.addText((t) => t.setPlaceholder("voice id").onChange((v) => (newVoice = v)));
|
||||
}
|
||||
addFolderSetting.addButton((b) =>
|
||||
b.setButtonText("Add").onClick(async () => {
|
||||
if (!newPrefix || !newVoice) return;
|
||||
folderVoices[newPrefix] = newVoice;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import type { Plugin } from "obsidian";
|
||||
import type { RhapsodeSettings } from "../settings";
|
||||
import type { VoxSettings } from "../settings";
|
||||
import { BrowserSynthBackend } from "./browser";
|
||||
import { OpenAIBackend } from "./openai";
|
||||
import { ElevenLabsBackend } from "./elevenlabs";
|
||||
import { PiperBackend } from "./piper";
|
||||
|
||||
/**
|
||||
* TTS backend discriminated union.
|
||||
|
|
@ -45,17 +44,17 @@ export interface UrlBackend {
|
|||
* to a playable audio blob (MP3/OGG/WAV). The Player is responsible
|
||||
* for revoking it after playback ends.
|
||||
*/
|
||||
synthesizeToUrl(sentence: string, voice: string): Promise<string>;
|
||||
synthesizeToUrl(sentence: string, voice: string, rate: number): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: pick the concrete backend based on user settings. Called
|
||||
* from `RhapsodePlugin.onload` and again from `saveSettings` so the
|
||||
* from `VoxPlugin.onload` and again from `saveSettings` so the
|
||||
* live Player always has a fresh backend that reflects the latest
|
||||
* API keys / engine choice without a full plugin reload.
|
||||
*/
|
||||
export function createBackend(
|
||||
settings: RhapsodeSettings,
|
||||
settings: VoxSettings,
|
||||
plugin: Plugin,
|
||||
): TtsBackend {
|
||||
switch (settings.engine) {
|
||||
|
|
@ -65,7 +64,5 @@ export function createBackend(
|
|||
return new OpenAIBackend(settings, plugin);
|
||||
case "elevenlabs":
|
||||
return new ElevenLabsBackend(settings, plugin);
|
||||
case "piper":
|
||||
return new PiperBackend(settings, plugin);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { requestUrl, type Plugin } from "obsidian";
|
||||
import type { RhapsodeSettings } from "../settings";
|
||||
import type { VoxSettings } from "../settings";
|
||||
import type { UrlBackend } from "./backend";
|
||||
|
||||
/**
|
||||
|
|
@ -18,16 +18,16 @@ import type { UrlBackend } from "./backend";
|
|||
*/
|
||||
export class ElevenLabsBackend implements UrlBackend {
|
||||
readonly kind = "url" as const;
|
||||
private settings: RhapsodeSettings;
|
||||
private settings: VoxSettings;
|
||||
|
||||
constructor(settings: RhapsodeSettings, _plugin: Plugin) {
|
||||
constructor(settings: VoxSettings, _plugin: Plugin) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
async synthesizeToUrl(sentence: string, voice: string): Promise<string> {
|
||||
async synthesizeToUrl(sentence: string, voice: string, rate = 1.0): Promise<string> {
|
||||
if (!this.settings.elevenlabsApiKey) {
|
||||
throw new Error(
|
||||
"ElevenLabs API key not set — add one in Rhapsode settings.",
|
||||
"ElevenLabs API key not set — add one in Vox settings.",
|
||||
);
|
||||
}
|
||||
if (!voice) {
|
||||
|
|
@ -51,6 +51,7 @@ export class ElevenLabsBackend implements UrlBackend {
|
|||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
speed: Math.min(1.2, Math.max(0.7, rate)),
|
||||
},
|
||||
}),
|
||||
throw: false,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { requestUrl, type Plugin } from "obsidian";
|
||||
import type { RhapsodeSettings } from "../settings";
|
||||
import type { VoxSettings } from "../settings";
|
||||
import type { UrlBackend } from "./backend";
|
||||
|
||||
/**
|
||||
|
|
@ -17,15 +17,15 @@ import type { UrlBackend } from "./backend";
|
|||
*/
|
||||
export class OpenAIBackend implements UrlBackend {
|
||||
readonly kind = "url" as const;
|
||||
private settings: RhapsodeSettings;
|
||||
private settings: VoxSettings;
|
||||
|
||||
constructor(settings: RhapsodeSettings, _plugin: Plugin) {
|
||||
constructor(settings: VoxSettings, _plugin: Plugin) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
async synthesizeToUrl(sentence: string, voice: string): Promise<string> {
|
||||
async synthesizeToUrl(sentence: string, voice: string, rate = 1.0): Promise<string> {
|
||||
if (!this.settings.openaiApiKey) {
|
||||
throw new Error("OpenAI API key not set — add one in Rhapsode settings.");
|
||||
throw new Error("OpenAI API key not set — add one in Vox settings.");
|
||||
}
|
||||
|
||||
const res = await requestUrl({
|
||||
|
|
@ -40,6 +40,10 @@ export class OpenAIBackend implements UrlBackend {
|
|||
voice: voice || "alloy",
|
||||
input: sentence,
|
||||
format: "mp3",
|
||||
speed: rate,
|
||||
...(this.settings.openaiInstructions?.trim()
|
||||
? { instructions: this.settings.openaiInstructions.trim() }
|
||||
: {}),
|
||||
}),
|
||||
throw: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
import { requestUrl, type Plugin } from "obsidian";
|
||||
import type { RhapsodeSettings } from "../settings";
|
||||
import type { UrlBackend } from "./backend";
|
||||
|
||||
/**
|
||||
* Piper TTS backend — local neural TTS via Piper's HTTP server.
|
||||
*
|
||||
* WHY HTTP AND NOT CLI:
|
||||
* The `piper` CLI reloads the voice model on every invocation —
|
||||
* ~300-500ms per sentence on M1, audible as gaps between sentences.
|
||||
* The HTTP server loads the model once and responds in ~50-100ms
|
||||
* thereafter. Plugin code also collapses to a single POST.
|
||||
*
|
||||
* USER SETUP (one time):
|
||||
* pip install piper-tts[http]
|
||||
* python3 -m piper.download_voices en_US-lessac-medium
|
||||
* python3 -m piper.http_server -m en_US-lessac-medium
|
||||
*
|
||||
* The server listens on http://localhost:5000 by default. Keep it
|
||||
* running in a terminal (or a launchd/systemd unit) — Rhapsode hits
|
||||
* it on demand.
|
||||
*
|
||||
* PER-REQUEST PARAMS (POSTed to `/`):
|
||||
* - text required
|
||||
* - voice optional; overrides the server's default voice
|
||||
* - length_scale optional; speaking speed, 1 = natural
|
||||
*
|
||||
* We use Obsidian's `requestUrl` rather than `fetch` so the same code
|
||||
* works on mobile when the server is reachable over the LAN, and so
|
||||
* CORS headers from localhost never become a surprise.
|
||||
*/
|
||||
export class PiperBackend implements UrlBackend {
|
||||
readonly kind = "url" as const;
|
||||
private settings: RhapsodeSettings;
|
||||
|
||||
constructor(settings: RhapsodeSettings, _plugin: Plugin) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
async synthesizeToUrl(sentence: string, voice: string): Promise<string> {
|
||||
const serverUrl = (this.settings.piperServerUrl || "http://localhost:5000")
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
// Voice override is optional — if the server was started with
|
||||
// `-m <voice>` and we send no voice field, it uses that default.
|
||||
const body: Record<string, string | number> = {
|
||||
text: sentence,
|
||||
length_scale: Math.max(0.25, Math.min(4, 1 / (this.settings.rate || 1))),
|
||||
};
|
||||
if (voice) body.voice = voice;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await requestUrl({
|
||||
url: serverUrl,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
throw: false,
|
||||
});
|
||||
} catch (err) {
|
||||
// Network-level failure (ECONNREFUSED etc.) surfaces here as a
|
||||
// thrown error rather than a status code — make it actionable.
|
||||
throw new Error(
|
||||
`Piper server unreachable at ${serverUrl}. Start it with: python3 -m piper.http_server -m <voice>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(
|
||||
`Piper HTTP server returned ${res.status}: ${res.text?.slice(0, 200) || ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = new Blob([res.arrayBuffer], { type: "audio/wav" });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
}
|
||||
105
styles.css
105
styles.css
|
|
@ -1,4 +1,8 @@
|
|||
.rhapsode-status {
|
||||
.vox-ribbon .svg-icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.vox-status {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -6,20 +10,111 @@
|
|||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.rhapsode-status:hover {
|
||||
.vox-status:hover {
|
||||
color: var(--text-accent-hover);
|
||||
}
|
||||
|
||||
.rhapsode-status-icon {
|
||||
.vox-status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhapsode-status-icon svg {
|
||||
.vox-status-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.rhapsode-status-text {
|
||||
.vox-status-text {
|
||||
font-size: var(--status-bar-font-size);
|
||||
}
|
||||
|
||||
.vox-voice-picker {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
padding: 4px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.vox-voice-picker-item {
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.vox-voice-picker-item:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.vox-voice-picker-item--active {
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vox-voice-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 10px 0 2px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.setting-item:has(.vox-voice-tags) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vox-voice-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
|
||||
.vox-voice-chip:hover {
|
||||
background: var(--background-modifier-active-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.vox-voice-chip--active {
|
||||
background: hsla(var(--accent-h), var(--accent-s), var(--accent-l), 0.15);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.vox-voice-chip--active:hover {
|
||||
background: hsla(var(--accent-h), var(--accent-s), var(--accent-l), 0.25);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.vox-voice-chip-name {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
.vox-voice-chip-remove {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vox-voice-chip-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue