Refine provider model and playback UI

This commit is contained in:
Danial Kalbasi 2026-05-02 17:02:00 +02:00
parent fbb2fe2a28
commit faeb322ce0
10 changed files with 183 additions and 72 deletions

View file

@ -1,10 +1,9 @@
![Vox banner](assets/readme-banner.png)
<h1 align="center">Vox</h1>
**Vox** reads your Obsidian notes aloud. Hearing your writing is different from reading it.
Each folder can have its own voice. Hover the sidebar icon to pick one and it starts reading immediately.
Three providers: **ElevenLabs** (best quality), **OpenAI** (solid, easier to start), **Browser** (free, no account needed).
<p align="center">
Listen to your notes with your favorite voices.<br /><br />
<img src="assets/demo.gif" alt="Vox demo" width="86%" style="border: 0; width: 86%; min-width: 240px; max-width: 100%;" />
</p>
## Features

BIN
assets/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

View file

@ -10,7 +10,7 @@ import {
import { DEFAULT_SETTINGS, VoxSettings, VoxSettingTab } from "./settings";
import { Player, PlayerState } from "./player";
import { stripMarkdown } from "./markdown";
import { createBackend, TtsBackend } from "./tts/backend";
import { createProvider, TtsProvider } from "./tts/provider";
import { OPENAI_VOICES, SPEED_LIMITS } from "./constants";
/**
@ -92,7 +92,7 @@ const ICONS: Record<string, string> = {
export default class VoxPlugin extends Plugin {
settings!: VoxSettings;
player!: Player;
private backend!: TtsBackend;
private provider!: TtsProvider;
// UI elements whose appearance depends on player state. Held as fields
// so the state listener can mutate them without re-querying.
@ -116,8 +116,8 @@ export default class VoxPlugin extends Plugin {
addIcon(id, svg.trim());
}
this.backend = createBackend(this.settings, this);
this.player = new Player(this.backend);
this.provider = createProvider(this.settings, this);
this.player = new Player(this.provider);
// ── Ribbon icons ────────────────────────────────────────────
// Single icon: click reads when idle; during playback, controls live
@ -180,8 +180,18 @@ export default class VoxPlugin extends Plugin {
async onunload() {
this.unsubscribeState?.();
this.player?.stop();
if (this.voicePickerCloseTimer !== null) {
window.clearTimeout(this.voicePickerCloseTimer);
this.voicePickerCloseTimer = null;
}
if (this.speedSaveTimer !== null) {
window.clearTimeout(this.speedSaveTimer);
this.speedSaveTimer = null;
await this.saveData(this.settings);
}
this.stopTimer();
this.stopDevReload();
this.closeVoicePicker();
this.stopSuppressingRibbonTooltip();
}
@ -326,6 +336,120 @@ export default class VoxPlugin extends Plugin {
this.timerEl = null;
}
private normalizeSettings(
raw: (Record<string, unknown> & {
defaultVoice?: string;
folderVoices?: Record<string, string>;
folderVoicesByEngine?: Partial<
Record<VoxSettings["engine"], Record<string, string>>
>;
}) | null,
): VoxSettings & {
defaultVoice?: string;
folderVoices?: Record<string, string>;
} {
const merged = Object.assign(
{},
DEFAULT_SETTINGS,
raw ?? {},
) as VoxSettings & {
defaultVoice?: string;
folderVoices?: Record<string, string>;
};
const engines: VoxSettings["engine"][] = ["browser", "elevenlabs", "openai"];
const isEngine = (value: unknown): value is VoxSettings["engine"] =>
typeof value === "string" && engines.includes(value as VoxSettings["engine"]);
const isOpenAiVoice = (
value: unknown,
): value is (typeof OPENAI_VOICES)[number] =>
typeof value === "string" &&
OPENAI_VOICES.includes(value as (typeof OPENAI_VOICES)[number]);
const asVoiceMap = (value: unknown): Record<string, string> => {
if (!value || typeof value !== "object") return {};
return Object.fromEntries(
Object.entries(value).filter(
([key, voice]) => typeof key === "string" && typeof voice === "string",
),
);
};
merged.engine = isEngine(raw?.engine) ? raw.engine : DEFAULT_SETTINGS.engine;
const [minSpeed, maxSpeed] = SPEED_LIMITS[merged.engine];
const rate =
typeof raw?.rate === "number" && Number.isFinite(raw.rate)
? raw.rate
: DEFAULT_SETTINGS.rate;
merged.rate = Math.min(maxSpeed, Math.max(minSpeed, rate));
merged.voiceBrowser =
typeof raw?.voiceBrowser === "string"
? raw.voiceBrowser
: DEFAULT_SETTINGS.voiceBrowser;
merged.voiceOpenai =
isOpenAiVoice(raw?.voiceOpenai)
? raw.voiceOpenai
: DEFAULT_SETTINGS.voiceOpenai;
merged.voiceElevenlabs =
typeof raw?.voiceElevenlabs === "string"
? raw.voiceElevenlabs
: DEFAULT_SETTINGS.voiceElevenlabs;
merged.elevenlabsApiKey =
typeof raw?.elevenlabsApiKey === "string"
? raw.elevenlabsApiKey
: DEFAULT_SETTINGS.elevenlabsApiKey;
merged.openaiApiKey =
typeof raw?.openaiApiKey === "string"
? raw.openaiApiKey
: DEFAULT_SETTINGS.openaiApiKey;
merged.elevenlabsModel =
raw?.elevenlabsModel === "eleven_turbo_v2_5" ||
raw?.elevenlabsModel === "eleven_multilingual_v2"
? raw.elevenlabsModel
: DEFAULT_SETTINGS.elevenlabsModel;
merged.openaiModel =
raw?.openaiModel === "tts-1" || raw?.openaiModel === "tts-1-hd"
? raw.openaiModel
: DEFAULT_SETTINGS.openaiModel;
merged.openaiInstructions =
typeof raw?.openaiInstructions === "string"
? raw.openaiInstructions
: DEFAULT_SETTINGS.openaiInstructions;
merged.showStartNotice =
typeof raw?.showStartNotice === "boolean"
? raw.showStartNotice
: DEFAULT_SETTINGS.showStartNotice;
merged.cacheEnabled =
typeof raw?.cacheEnabled === "boolean"
? raw.cacheEnabled
: DEFAULT_SETTINGS.cacheEnabled;
merged.devReloadEnabled =
typeof raw?.devReloadEnabled === "boolean"
? raw.devReloadEnabled
: DEFAULT_SETTINGS.devReloadEnabled;
merged.elevenlabsVoices = Array.isArray(raw?.elevenlabsVoices)
? raw.elevenlabsVoices.flatMap((voice) =>
voice &&
typeof voice === "object" &&
typeof voice.name === "string" &&
typeof voice.id === "string"
? [{ name: voice.name, id: voice.id }]
: [],
)
: DEFAULT_SETTINGS.elevenlabsVoices;
merged.folderVoicesByEngine = {
browser: asVoiceMap(raw?.folderVoicesByEngine?.browser),
elevenlabs: asVoiceMap(raw?.folderVoicesByEngine?.elevenlabs),
openai: asVoiceMap(raw?.folderVoicesByEngine?.openai),
};
return merged;
}
/**
* Repaint the ribbon icon to reflect player state. Called synchronously by
* the Player's state-change event.
@ -637,7 +761,7 @@ export default class VoxPlugin extends Plugin {
/**
* Persona voice selection: folder prefix (longest wins) frontmatter
* `voice:` key global default. Centralised here so backends stay
* `voice:` key global default. Centralised here so providers stay
* voice-agnostic.
*/
private resolveVoice(file: TFile): string {
@ -677,19 +801,7 @@ export default class VoxPlugin extends Plugin {
>;
})
| null;
const merged = Object.assign(
{},
DEFAULT_SETTINGS,
raw ?? {},
) as VoxSettings & {
defaultVoice?: string;
folderVoices?: Record<string, string>;
};
merged.folderVoicesByEngine = {
...DEFAULT_SETTINGS.folderVoicesByEngine,
...(raw?.folderVoicesByEngine ?? {}),
};
const merged = this.normalizeSettings(raw);
if (
typeof merged.defaultVoice === "string" &&
@ -726,8 +838,8 @@ export default class VoxPlugin extends Plugin {
async saveSettings() {
await this.saveData(this.settings);
this.backend = createBackend(this.settings, this);
this.player.setBackend(this.backend);
this.provider = createProvider(this.settings, this);
this.player.setProvider(this.provider);
this.player.setRate(this.settings.rate);
await this.configureDevReload();
}

View file

@ -59,7 +59,7 @@ export function stripMarkdown(md: string): string {
/**
* Split cleaned prose into sentence-ish chunks. Used by the player to
* feed the TTS backend incrementally so streaming / first-audio latency
* feed the TTS provider incrementally so streaming / first-audio latency
* stays low, and so pause/skip has sensible granularity.
*
* Not a linguist-grade sentence splitter handles the common cases

View file

@ -1,5 +1,5 @@
import { splitIntoSentences } from "./markdown";
import type { TtsBackend } from "./tts/backend";
import type { TtsProvider } from "./tts/provider";
export type PlayerState = "idle" | "playing" | "paused";
@ -10,14 +10,14 @@ export type PlayerStateListener = (state: PlayerState) => void;
*
* Responsibilities:
* - Segment cleaned text into sentences.
* - Ask the backend for each sentence's audio (URL for cloud engines,
* direct `speak()` for the browser SpeechSynthesis backend).
* - Ask the provider for each sentence's audio (URL for cloud engines,
* direct `speak()` for the browser SpeechSynthesis provider).
* - Maintain a queue so the user can pause, resume, and stop.
* - Emit state transitions (`idle` `playing` `paused`) so the UI
* layer (ribbon icon, popover controls) can stay in sync without polling.
*/
export class Player {
private backend: TtsBackend;
private provider: TtsProvider;
private queue: string[] = [];
private cursor = 0;
private audio: HTMLAudioElement | null = null;
@ -31,12 +31,12 @@ export class Player {
private state: PlayerState = "idle";
private listeners: Set<PlayerStateListener> = new Set();
constructor(backend: TtsBackend) {
this.backend = backend;
constructor(provider: TtsProvider) {
this.provider = provider;
}
setBackend(backend: TtsBackend) {
this.backend = backend;
setProvider(provider: TtsProvider) {
this.provider = provider;
}
setRate(rate: number) {
@ -105,12 +105,12 @@ export class Player {
this.cursor = 0;
if (this.queue.length === 0) return;
if (this.backend.kind === "synth") {
if (this.provider.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, () => {
// completion; the provider will emit that callback for us.
await this.provider.speakAll(this.queue, voice, this.rate, () => {
if (this.isActive(token)) this.setState("idle");
});
return;
@ -134,17 +134,17 @@ export class Player {
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.
// Natural end of queue — only fires for URL providers. Synth
// provider signals completion via the callback passed to speakAll.
this.releaseCurrentAudioUrl();
this.audio = null;
if (this.isActive(token)) this.setState("idle");
return;
}
if (!this.audio || this.backend.kind !== "url") return;
if (!this.audio || this.provider.kind !== "url") return;
const sentence = this.queue[this.cursor++];
const url = await this.backend.synthesizeToUrl(sentence, voice, this.rate);
const url = await this.provider.synthesizeToUrl(sentence, voice, this.rate);
if (!this.isActive(token)) {
URL.revokeObjectURL(url);
return;
@ -175,14 +175,14 @@ export class Player {
pause() {
if (this.state !== "playing") return;
this.audio?.pause();
if (this.backend.kind === "synth") this.backend.pause();
if (this.provider.kind === "synth") this.provider.pause();
this.setState("paused");
}
resume() {
if (this.state !== "paused") return;
this.audio?.play().catch(() => {});
if (this.backend.kind === "synth") this.backend.resume();
if (this.provider.kind === "synth") this.provider.resume();
this.setState("playing");
}
@ -203,7 +203,7 @@ export class Player {
this.audio = null;
}
this.releaseCurrentAudioUrl();
if (this.backend.kind === "synth") this.backend.stopAll();
if (this.provider.kind === "synth") this.provider.stopAll();
this.queue = [];
this.cursor = 0;
this.setState("idle");

View file

@ -7,7 +7,7 @@ import { VoiceBrowserModal } from "./voice-browser";
export type TtsEngine = "browser" | "elevenlabs" | "openai";
export interface VoxSettings {
/** Active backend — only one runs at a time. */
/** Active provider — only one runs at a time. */
engine: TtsEngine;
/** Playback rate for `<audio>` and browser synth (1.0 = natural). */

View file

@ -1,7 +1,7 @@
import type { SynthBackend } from "./backend";
import type { SynthProvider } from "./provider";
/**
* Browser SpeechSynthesis backend uses the Web Speech API's built-in
* Browser SpeechSynthesis provider uses the Web Speech API's built-in
* voice synthesizer. Zero cost, no network, works offline. Quality
* varies by OS (macOS "Samantha" is decent; Windows SAPI voices are
* notoriously robotic).
@ -9,7 +9,7 @@ import type { SynthBackend } from "./backend";
* Used as the default on first install so the plugin has something
* functional even before the user configures API keys.
*/
export class BrowserSynthBackend implements SynthBackend {
export class BrowserSynthProvider implements SynthProvider {
readonly kind = "synth" as const;
private utterances: SpeechSynthesisUtterance[] = [];

View file

@ -1,11 +1,11 @@
import { requestUrl, type Plugin } from "obsidian";
import type { VoxSettings } from "../settings";
import type { UrlBackend } from "./backend";
import type { UrlProvider } from "./provider";
import { AudioCache, type AudioCacheParts } from "./cache";
import { SPEED_LIMITS } from "../constants";
/**
* ElevenLabs TTS backend (`/v1/text-to-speech/{voice_id}`).
* ElevenLabs TTS provider (`/v1/text-to-speech/{voice_id}`).
*
* Uses the non-streaming endpoint for simplicity (ElevenLabs also
* offers a streamed variant that returns audio in chunks; worth
@ -18,7 +18,7 @@ import { SPEED_LIMITS } from "../constants";
* faster and cheaper than `eleven_multilingual_v2` with minimal
* quality difference for English prose.
*/
export class ElevenLabsBackend implements UrlBackend {
export class ElevenLabsProvider implements UrlProvider {
readonly kind = "url" as const;
private settings: VoxSettings;
private cache: AudioCache;

View file

@ -1,10 +1,10 @@
import { requestUrl, type Plugin } from "obsidian";
import type { VoxSettings } from "../settings";
import type { UrlBackend } from "./backend";
import type { UrlProvider } from "./provider";
import { AudioCache, type AudioCacheParts } from "./cache";
/**
* OpenAI TTS backend (`/v1/audio/speech`).
* OpenAI TTS provider (`/v1/audio/speech`).
*
* Endpoint returns the full synthesized audio as a single MP3 response.
* No per-sentence streaming, but latency is low enough (~300-800ms for
@ -16,7 +16,7 @@ import { AudioCache, type AudioCacheParts } from "./cache";
* Cost reference (2025): tts-1 = $15 / 1M chars, tts-1-hd = $30 / 1M.
* A typical 2000-char note costs $0.03-0.06.
*/
export class OpenAIBackend implements UrlBackend {
export class OpenAIProvider implements UrlProvider {
readonly kind = "url" as const;
private settings: VoxSettings;
private cache: AudioCache;

View file

@ -1,24 +1,24 @@
import type { Plugin } from "obsidian";
import type { VoxSettings } from "../settings";
import { BrowserSynthBackend } from "./browser";
import { OpenAIBackend } from "./openai";
import { ElevenLabsBackend } from "./elevenlabs";
import { BrowserSynthProvider } from "./browser";
import { OpenAIProvider } from "./openai";
import { ElevenLabsProvider } from "./elevenlabs";
/**
* TTS backend discriminated union.
* TTS provider discriminated union.
*
* Two kinds are supported:
* - `synth`: backend speaks directly (e.g. SpeechSynthesisAPI). It
* - `synth`: provider speaks directly (e.g. SpeechSynthesisAPI). It
* owns its own queue, pause/resume, and stop.
* - `url`: backend synthesises audio per utterance and returns an
* - `url`: provider synthesises audio per utterance and returns an
* object URL. The Player plays it through a shared <audio> element.
*
* Adding a new engine: implement one of the two shapes, export it, and
* wire it into `createBackend`.
* wire it into `createProvider`.
*/
export type TtsBackend = SynthBackend | UrlBackend;
export type TtsProvider = SynthProvider | UrlProvider;
export interface SynthBackend {
export interface SynthProvider {
kind: "synth";
/**
* Speak every sentence in order. `onDone` (if supplied) fires when
@ -37,7 +37,7 @@ export interface SynthBackend {
stopAll(): void;
}
export interface UrlBackend {
export interface UrlProvider {
kind: "url";
/**
* Synthesise one sentence. Should resolve to an object URL pointing
@ -48,21 +48,21 @@ export interface UrlBackend {
}
/**
* Factory: pick the concrete backend based on user settings. Called
* Factory: pick the concrete provider based on user settings. Called
* from `VoxPlugin.onload` and again from `saveSettings` so the
* live Player always has a fresh backend that reflects the latest
* live Player always has a fresh provider that reflects the latest
* API keys / engine choice without a full plugin reload.
*/
export function createBackend(
export function createProvider(
settings: VoxSettings,
plugin: Plugin,
): TtsBackend {
): TtsProvider {
switch (settings.engine) {
case "browser":
return new BrowserSynthBackend();
return new BrowserSynthProvider();
case "openai":
return new OpenAIBackend(settings, plugin);
return new OpenAIProvider(settings, plugin);
case "elevenlabs":
return new ElevenLabsBackend(settings, plugin);
return new ElevenLabsProvider(settings, plugin);
}
}