diff --git a/manifest.json b/manifest.json index d87e76a..f5b0c21 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-tts", "name": "Text to Speech", - "version": "0.5.3", + "version": "0.5.4", "minAppVersion": "1.4.0", "description": "Hear your notes.", "author": "Johannes Theiner", diff --git a/package.json b/package.json index 9678def..23a2ee8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@cospired/i18n-iso-languages": "^3.1.1", "@types/node": "^16.11.6", "builtin-modules": "^3.2.0", - "obsidian": "0.15.2", + "obsidian": "1.4.11", "tslib": "2.3.1", "typescript": "4.4.4", "tinyld": "1.2.3", diff --git a/src/LanguageVoiceModal.ts b/src/LanguageVoiceModal.ts index 406ca7d..c4b8060 100644 --- a/src/LanguageVoiceModal.ts +++ b/src/LanguageVoiceModal.ts @@ -6,6 +6,7 @@ import languages from "@cospired/i18n-iso-languages"; export class LanguageVoiceModal extends Modal { plugin: TTSPlugin; + id: string; language: string; voice: string; @@ -16,6 +17,7 @@ export class LanguageVoiceModal extends Modal { this.plugin = plugin; if(map) { + this.id = map.id; this.language = map.language; this.voice = map.voice; } @@ -24,6 +26,8 @@ export class LanguageVoiceModal extends Modal { async display() : Promise { const { contentEl } = this; + const voices = await this.plugin.serviceManager.getVoices(); + contentEl.empty(); //not know to rollup and webstorm, but exists in obsidian @@ -55,14 +59,14 @@ export class LanguageVoiceModal extends Modal { new Setting(contentEl) .setName("Voice") .addDropdown(async (dropdown) => { - const voices = window.speechSynthesis.getVoices(); for (const voice of voices) { - dropdown.addOption(voice.name, voice.name + " - " + languageNames.of(voice.lang)); + dropdown.addOption(voice.service + "-" + voice.id, voice.name + " - " + languageNames.of(voice.languages[0])); } dropdown - .setValue(this.voice) + .setValue(this.id) .onChange(async (value) => { - this.voice = value; + this.id = value; + this.voice = voices.filter(voice => voice.service + "-" + voice.id === value).first().name; }); }).addExtraButton(button => { button @@ -72,7 +76,7 @@ export class LanguageVoiceModal extends Modal { const input = new TextInputPrompt(this.app, "What do you want to hear?", "", "Hello world this is Text to speech running in obsidian", "Hello world this is Text to speech running in obsidian"); await input.openAndGetValue((async value => { if (value.getValue().length === 0) return; - await this.plugin.ttsService.sayWithVoice(value.getValue(), this.voice); + await this.plugin.serviceManager.sayWithVoice(value.getValue(), this.id); })); diff --git a/src/ServiceManager.ts b/src/ServiceManager.ts new file mode 100644 index 0000000..6cf63e2 --- /dev/null +++ b/src/ServiceManager.ts @@ -0,0 +1,80 @@ +import {TTSService} from "./services/TTSService"; +import TTSPlugin from "./main"; +import {SpeechSynthesis} from "./services/SpeechSynthesis"; +import {Notice} from "obsidian"; + +export class ServiceManager { + private readonly plugin: TTSPlugin; + private services: TTSService[] = []; + + constructor(plugin: TTSPlugin) { + this.plugin = plugin; + this.services.push(new SpeechSynthesis(this.plugin)); + //this.services.push(new OpenAI(this)); + } + + public getServices(): TTSService[] { + return this.services; + } + + public isSpeaking(): boolean { + return this.services.some(service => service.isSpeaking()); + } + + public isPaused(): boolean { + return this.services.every(service => service.isPaused()); + } + + stop() : void { + for (const service of this.services) { + if(service.isSpeaking() || service.isPaused()) { + service.stop(); + } + } + } + + pause() : void { + for (const service of this.services) { + if(service.isSpeaking()) { + service.pause(); + } + } + } + + resume(): void { + for (const service of this.services) { + if(service.isPaused()) { + service.resume(); + } + } + } + + async sayWithVoice(text: string, voice: string): Promise { + const service = this.services.filter(service => voice.startsWith(service.id)).first(); + const split = voice.split("-"); + split.shift(); + voice = split.join("-"); + if(!service) { + new Notice("No service found for voice" + voice); + } + await service.sayWithVoice(text, voice); + + } + + async getVoices() { + const voices = []; + for (const service of this.services) { + for (const voice of await service.getVoices()) { + voices.push({ + service: service.id, + id: voice.id, + name: voice.name, + languages: voice.languages + }); + } + } + return voices; + } + + +} diff --git a/src/main.ts b/src/main.ts index a0d2e54..42fe540 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,29 +1,26 @@ import { - addIcon, + addIcon, MarkdownFileInfo, MarkdownView, Menu, Notice, Platform, Plugin, setIcon, TFile } from 'obsidian'; import {DEFAULT_SETTINGS, LanguageVoiceMap, TTSSettings, TTSSettingsTab} from "./settings"; -import {SpeechSynthesis} from "./services/SpeechSynthesis"; import {registerAPI} from "@vanakat/plugin-api"; -import {TTSService} from "./services/TTSService"; import {detect} from "tinyld"; +import {ServiceManager} from "./ServiceManager"; export default class TTSPlugin extends Plugin { - ttsService: TTSService; settings: TTSSettings; statusbar: HTMLElement; - services: TTSService[] = []; + serviceManager: ServiceManager; + async onload(): Promise { // from https://github.com/phosphor-icons/core addIcon('tts-play-pause', ''); - this.services.push(new SpeechSynthesis(this)); - //this.services.push(new OpenAI(this)); - this.ttsService = this.services[0]; + console.log("loading tts plugin"); @@ -35,15 +32,19 @@ export default class TTSPlugin extends Plugin { await this.loadSettings(); + this.serviceManager = new ServiceManager(this); + + await this.migrateSettings(); + this.addCommand({ id: 'start-tts-playback', name: 'Start playback', icon: 'play', checkCallback: (checking: boolean) => { - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (!checking && markdownView) - this.play(markdownView); - return !!markdownView; + const info = this.app.workspace.activeEditor; + if (!checking) + this.play(info); + return !!info; } }); @@ -52,9 +53,10 @@ export default class TTSPlugin extends Plugin { name: 'Stop playback', icon: 'stop', checkCallback: (checking: boolean) => { - if (!checking) - this.ttsService.stop(); - return this.ttsService.isSpeaking(); + if (!checking) { + this.serviceManager.stop(); + } + return this.serviceManager.isSpeaking(); } }); @@ -64,9 +66,10 @@ export default class TTSPlugin extends Plugin { name: 'pause playback', icon: 'pause', checkCallback: (checking: boolean) => { - if (!checking) - this.ttsService.pause(); - return this.ttsService.isSpeaking(); + if (!checking) { + this.serviceManager.pause(); + } + return this.serviceManager.isSpeaking(); } }); @@ -76,8 +79,8 @@ export default class TTSPlugin extends Plugin { icon: 'play-audio-glyph', checkCallback: (checking: boolean) => { if (!checking) - this.ttsService.resume(); - return this.ttsService.isPaused(); + this.serviceManager.resume(); + return this.serviceManager.isPaused(); } }); @@ -88,10 +91,10 @@ export default class TTSPlugin extends Plugin { checkCallback: (checking) => { const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!checking && markdownView) { - if (this.ttsService.isPaused()) { - this.ttsService.resume(); - } else if (this.ttsService.isSpeaking()) { - this.ttsService.pause(); + if (this.serviceManager.isPaused()) { + this.serviceManager.resume(); + } else if (this.serviceManager.isSpeaking()) { + this.serviceManager.pause(); } else { this.play(markdownView); } @@ -101,17 +104,17 @@ export default class TTSPlugin extends Plugin { } }); - this.addCommand({ + /*this.addCommand({ id: 'cursor', name: 'after cursor', editorCallback: (editor, _) => { console.log(editor.getRange(editor.getCursor("from"), {line: editor.lastLine(), ch: editor.getLine(editor.lastLine()).length})); } - }) + })*/ //clear statusbar text if not speaking this.registerInterval(window.setInterval(() => { - if (!this.ttsService.isSpeaking()) { + if (!this.serviceManager.isSpeaking()) { this.statusbar.empty(); setIcon(this.statusbar, 'audio-file'); } @@ -148,7 +151,7 @@ export default class TTSPlugin extends Plugin { this.registerEvent(this.app.workspace.on('layout-change', (() => { if (this.settings.stopPlaybackWhenNoteChanges) { - this.ttsService.stop(); + this.serviceManager.stop(); } }))); @@ -162,13 +165,13 @@ export default class TTSPlugin extends Plugin { await this.createMenu(event); }); - registerAPI("tts", this.ttsService, this); + registerAPI("tts", this.serviceManager, this); } async createMenu(event: MouseEvent): Promise { - const menu = new Menu(this.app); + const menu = new Menu(); - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); + const markdownView = this.app.workspace.activeEditor; if (markdownView) { if (window.speechSynthesis.speaking) { menu.addItem((item) => { @@ -191,7 +194,7 @@ export default class TTSPlugin extends Plugin { .setIcon("stop-audio-glyph") .setTitle("Stop") .onClick(async () => { - this.ttsService.stop(); + this.serviceManager.stop(); }); }); @@ -202,7 +205,7 @@ export default class TTSPlugin extends Plugin { .setIcon("play-audio-glyph") .setTitle("Resume") .onClick(async () => { - this.ttsService.resume(); + this.serviceManager.resume(); }); }); } else { @@ -211,7 +214,7 @@ export default class TTSPlugin extends Plugin { .setIcon("paused") .setTitle("Pause") .onClick(async () => { - this.ttsService.pause(); + this.serviceManager.pause(); }); }); } @@ -228,15 +231,17 @@ export default class TTSPlugin extends Plugin { async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - //migration + } + + async migrateSettings(): Promise { let migrate = false; - if (!this.settings.defaultVoice.includes('-')) { + if (!this.serviceManager.getServices().some(service => this.settings.defaultVoice.includes(service.id))) { this.settings.defaultVoice = 'speechSynthesis-' + this.settings.defaultVoice; migrate = true; } for (const languageVoice of this.settings.languageVoices) { - if (!languageVoice.voice.includes('-')) { - languageVoice.voice = 'speechSynthesis-' + languageVoice.voice; + if (!this.serviceManager.getServices().some(service => languageVoice.voice.includes(service.id))) { + languageVoice.id = 'speechSynthesis-' + languageVoice.voice; migrate = true; } } @@ -270,9 +275,11 @@ export default class TTSPlugin extends Plugin { } } const split = usedVoice.split(/-(.*)/s); - const service = this.services.filter(service => service.id === split[0] && service.isConfigured() && service.isValid()).first(); + const service = this.serviceManager.getServices().filter(service => service.id === split[0] && service.isConfigured() && service.isValid()).first(); + if (service === undefined) { new Notice("TTS: Could not use configured language, please check your settings.\nUsing default voice"); + await this.serviceManager.sayWithVoice(text, this.settings.defaultVoice); return; } @@ -296,7 +303,6 @@ export default class TTSPlugin extends Plugin { //regex from https://stackoverflow.com/a/37462442/5589264 content = content.replace(/(?:__|[*#])|\[(.*?)]\(.*?\)/gm, '$1'); content = content.replace(/http[s]:\/\/[^\s]*/gm, ''); - console.log(content); } if (!this.settings.speakCodeblocks) { content = content.replace(/```[\s\S]*?```/g, ''); @@ -325,24 +331,20 @@ export default class TTSPlugin extends Plugin { return content; } - async play(view: MarkdownView): Promise { - const isPreview = view.getMode() === "preview"; + async play(info: MarkdownFileInfo): Promise { - const previewText = view.previewMode.containerEl.innerText; + const selectedText = info.editor.getSelection().length > 0 ? info.editor.getSelection() : activeWindow.getSelection().toString(); - - const selectedText = view.editor.getSelection().length > 0 ? view.editor.getSelection() : window.getSelection().toString(); - let content = selectedText.length > 0 ? selectedText : view.getViewData(); - if (isPreview) { - content = previewText; - } - let language = this.getLanguageFromFrontmatter(view); + let content = selectedText.length > 0 ? selectedText : await this.app.vault.cachedRead(info.file); + let language = this.getLanguageFromFrontmatter(info.file); if (language === "") { language = detect(content); } + content = this.prepareText(selectedText.length > 0 ? '' : info.file.name, content); + if (!this.settings.speakFrontmatter) { - if (!isPreview) { + if (selectedText.length === 0) { content = content.replace("---", ""); content = content.substring(content.indexOf("---") + 1); } @@ -351,9 +353,8 @@ export default class TTSPlugin extends Plugin { } - getLanguageFromFrontmatter(view: MarkdownView): string { - if(view.file) { - return this.app.metadataCache.getFileCache(view.file).frontmatter?.lang; - } + getLanguageFromFrontmatter(file: TFile): string { + return this.app.metadataCache.getFileCache(file).frontmatter?.lang; } + } diff --git a/src/services/OpenAI.ts b/src/services/OpenAI.ts index a12d2d8..3846a05 100644 --- a/src/services/OpenAI.ts +++ b/src/services/OpenAI.ts @@ -56,10 +56,12 @@ export class OpenAI implements TTSService { } isPaused(): boolean { + if(!this.source) return true; return this.source.context.state === "suspended"; } isSpeaking(): boolean { + if(!this.source) return false; return this.source.context.state === "running"; } diff --git a/src/services/TTSService.ts b/src/services/TTSService.ts index 2928542..d41f5c1 100644 --- a/src/services/TTSService.ts +++ b/src/services/TTSService.ts @@ -1,3 +1,9 @@ +export interface Voice { + id: string; + name: string; + languages: string[]; +} + export interface TTSService { id: string; @@ -32,7 +38,7 @@ export interface TTSService { isValid(): boolean; - getVoices() : Promise<{id: string, name: string, languages: string[]}[]>; + getVoices() : Promise; /** * @internal diff --git a/src/settings.ts b/src/settings.ts index d6af017..e266695 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,6 +4,7 @@ import TTSPlugin from "./main"; import {LanguageVoiceModal} from "./LanguageVoiceModal"; export interface LanguageVoiceMap { + id: string; language: string; voice: string; } @@ -58,7 +59,7 @@ export class TTSSettingsTab extends PluginSettingTab { this.plugin = plugin; } - display(): void { + async display(): Promise { const {containerEl} = this; containerEl.empty(); @@ -67,7 +68,7 @@ export class TTSSettingsTab extends PluginSettingTab { .setName("Default voice") .addDropdown(async (dropdown) => { const voices = []; - const services = this.plugin.services; + const services = this.plugin.serviceManager.getServices(); for (const service of services) { if (service.isConfigured() && service.isValid()) { for (const voice of await service.getVoices()) { @@ -100,7 +101,7 @@ export class TTSSettingsTab extends PluginSettingTab { const input = new TextInputPrompt(this.app, "What do you want to hear?", "", "Hello world this is Text to speech running in obsidian", "Hello world this is Text to speech running in obsidian"); await input.openAndGetValue((async value => { if (value.getValue().length === 0) return; - await this.plugin.say('', value.getValue()); + await this.plugin.serviceManager.sayWithVoice(value.getValue(), this.plugin.settings.defaultVoice); })); @@ -140,12 +141,13 @@ export class TTSSettingsTab extends PluginSettingTab { modal.onClose = async () => { if (modal.saved) { this.plugin.settings.languageVoices.push({ + id: modal.id, language: modal.language, voice: modal.voice }); await this.plugin.saveSettings(); - this.display(); + await this.display(); } }; @@ -162,7 +164,7 @@ export class TTSSettingsTab extends PluginSettingTab { const displayNames = new Intl.DisplayNames([languageVoice.language], {type: 'language', fallback: 'none'}); const setting = new Setting(voicesDiv); setting.setName(displayNames.of(languageVoice.language) + " - " + languageVoice.language); - setting.setDesc(languageVoice.voice); + setting.setDesc(languageVoice.voice); setting .addExtraButton((b) => { @@ -174,7 +176,7 @@ export class TTSSettingsTab extends PluginSettingTab { modal.onClose = async () => { if (modal.saved) { const setting = this.plugin.settings.languageVoices.filter(value => value.language !== modal.language); - setting.push({language: modal.language, voice: modal.voice}); + setting.push({id: modal.id, language: modal.language, voice: modal.voice}); this.plugin.settings.languageVoices = setting; await this.plugin.saveSettings(); diff --git a/versions.json b/versions.json index 00d26b8..f52c457 100644 --- a/versions.json +++ b/versions.json @@ -9,5 +9,6 @@ "0.5.0": "0.12.0", "0.5.1": "0.12.0", "0.5.2": "0.12.0", - "0.5.3": "1.4.0" + "0.5.3": "1.4.0", + "0.5.4": "1.4.0" }