mirror of
https://github.com/danialkalbasi/obsidian-vox.git
synced 2026-07-22 06:50:02 +00:00
Refine provider model and playback UI
This commit is contained in:
parent
fbb2fe2a28
commit
faeb322ce0
10 changed files with 183 additions and 72 deletions
11
README.md
11
README.md
|
|
@ -1,10 +1,9 @@
|
|||

|
||||
<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
BIN
assets/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 536 KiB |
152
src/main.ts
152
src/main.ts
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue