From bd783b57b4fe4a79b41498b5a38f0c94b7172eb5 Mon Sep 17 00:00:00 2001 From: Johannes Theiner Date: Wed, 24 Nov 2021 19:14:16 +0100 Subject: [PATCH] + speak only selected text(closes #5) + add pauses on empty lines(closes #6) ~ better link filtering --- README.md | 44 ++++--- src/LanguageVoiceModal.ts | 2 +- src/TTSService.ts | 129 ++++++++++++++++++ src/main.ts | 267 ++++++++++++++------------------------ src/settings.ts | 18 ++- 5 files changed, 276 insertions(+), 184 deletions(-) create mode 100644 src/TTSService.ts diff --git a/README.md b/README.md index 9eac121..f5367af 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Text to Speech + Plugin for [Obsidian](https://obsidian.md) ![GitHub package.json version](https://img.shields.io/github/package-json/v/joethei/obsidian-tts) @@ -8,35 +9,48 @@ Plugin for [Obsidian](https://obsidian.md) --- **This plugin is currently in beta** -You can create language specific voices, which will be used when you have a note -with +You can create language specific voices, which will be used when you have a note with ```lang: {languageCode}``` -in the [Frontmatter](https://help.obsidian.md/Advanced+topics/YAML+front+matter). -The language code can be seen in the settings and is a two letter [ISO 639-1](https://www.loc.gov/standards/iso639-2/php/English_list.php) code. +in the [Frontmatter](https://help.obsidian.md/Advanced+topics/YAML+front+matter). The language code can be seen in the +settings and is a two letter [ISO 639-1](https://www.loc.gov/standards/iso639-2/php/English_list.php) code. +This plugin will **NOT** work on android due +to [this bug in the Webview](https://bugs.chromium.org/p/chromium/issues/detail?id=487255). -This plugin will **NOT** work on android due to [this bug in the Webview](https://bugs.chromium.org/p/chromium/issues/detail?id=487255). +## Adding languages +This plugin uses the native API of your Operating System, to add a new language reference the documentation accordingly: -## Adding languages -This plugin uses the native API of your Operating System, -to add a new language reference the documentation accordingly: - [Windows 10](https://support.microsoft.com/en-us/topic/how-to-download-text-to-speech-languages-for-windows-10-d5a6b612-b3ae-423f-afa5-4f6caf1ec5d3) - [MacOS](https://support.apple.com/guide/mac-help/change-the-system-language-mh26684/mac) - [iOS](https://support.apple.com/guide/iphone/change-the-language-and-region-iphce20717a3/ios) + +## Installing the plugin + + + +- Using the [Beta Reviewers Auto-update Tester](https://github.com/TfTHacker/obsidian42-brat) plugin with the repo + path: `joethei/obsidian-tts` +- Copy over `main.js`, `styles.css`, `manifest.json` from the releases to your + vault `VaultFolder/.obsidian/plugins/obsidian-tts/`. + ## API + You can use this plugins API to add Text to Speech capabilities to your plugin. ```js //@ts-ignore -if(this.app.plugins.plugins["obsidian-tts"]) {//check if the plugin is loaded - //@ts-ignore - await this.app.plugins.plugins["obsidian-tts"].say(title, text, language);//language is optional +if (this.app.plugins.plugins["obsidian-tts"]) {//check if the plugin is loaded + //@ts-ignore + const tts = this.app.plugins.plugins["obsidian-tts"]; + await tts.say(title, text, language);//language is optional, use a ISO 639-1 code + tts.pause(); + tts.resume(); + tts.stop(); + tts.isSpeaking(); + tts.isPaused(); + } ``` -Parameters: -- title: Title of your text, will only be spoken if the the user has the setting enabled -- text -- language(optional): language code according to the [ISO 639-1](https://www.loc.gov/standards/iso639-2/php/English_list.php), if there is no voice configured for that language, the plugin will use the default voice. diff --git a/src/LanguageVoiceModal.ts b/src/LanguageVoiceModal.ts index d1d8ed6..255a983 100644 --- a/src/LanguageVoiceModal.ts +++ b/src/LanguageVoiceModal.ts @@ -72,7 +72,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.sayWithVoice('', value.getValue(), this.voice); + await this.plugin.ttsService.sayWithVoice('', value.getValue(), this.voice); })); diff --git a/src/TTSService.ts b/src/TTSService.ts new file mode 100644 index 0000000..72b2162 --- /dev/null +++ b/src/TTSService.ts @@ -0,0 +1,129 @@ +import {MarkdownView, Notice, parseYaml} from "obsidian"; +import {LanguageVoiceMap} from "./settings"; +import TTSPlugin from "./main"; + +export class TTSService { + plugin: TTSPlugin; + + constructor(plugin: TTSPlugin) { + this.plugin = plugin; + } + + stop(): void { + if(!this.isSpeaking()) return; + window.speechSynthesis.cancel(); + } + + pause(): void { + if(!this.isSpeaking()) return; + window.speechSynthesis.pause(); + } + + resume(): void { + if(!this.isSpeaking()) return; + window.speechSynthesis.resume(); + } + + isSpeaking() : boolean { + return window.speechSynthesis.speaking; + } + + isPaused() : boolean { + return window.speechSynthesis.paused; + } + + async sayWithVoice(title: string, text: string, voice: string): Promise { + let content = text; + if (!this.plugin.settings.speakSyntax) { + content = content.replace(/#/g, ""); + content = content.replace(/-/g, ""); + content = content.replace(/_/g, ""); + content = content.replace(/\*/g, ""); + } + if (!this.plugin.settings.speakLinks) { + //regex from https://stackoverflow.com/a/37462442/5589264 + content = content.replace(/(?:__|[*#])|\[(.*?)]\(.*?\)/gm, '$1'); + } + if (!this.plugin.settings.speakCodeblocks) { + content = content.replace(/```[\s\S]*?```/g, ''); + } + + if (this.plugin.settings.speakTitle) { + content = title + " " + content; + } + + //add pauses, taken from https://stackoverflow.com/a/50944593/5589264 + content = content.replace(/\n/g, " ! "); + + + //only speak link aliases. + content = content.replace(/\[\[(.*\|)(.*)]]/gm, '$2'); + + + const msg = new SpeechSynthesisUtterance(); + msg.text = content; + msg.volume = this.plugin.settings.volume; + msg.rate = this.plugin.settings.rate; + msg.pitch = this.plugin.settings.pitch; + msg.voice = window.speechSynthesis.getVoices().filter(otherVoice => otherVoice.name === voice)[0]; + window.speechSynthesis.speak(msg); + + this.plugin.statusbar.setText("TTS: speaking"); + } + + + getVoice(languageCode: string): string { + const filtered = this.plugin.settings.languageVoices.filter((lang: LanguageVoiceMap) => lang.language === languageCode); + if (filtered.length === 0) return null; + return filtered[0].voice; + } + + async say(title: string, text: string, languageCode?: string): Promise { + let usedVoice = this.plugin.settings.defaultVoice; + if (languageCode && languageCode.length !== 0) { + const voice = this.getVoice(languageCode); + if (voice) { + usedVoice = voice; + } else { + new Notice("TTS: could not find voice for language " + languageCode + ". Using default voice."); + } + } + await this.sayWithVoice(title, text, usedVoice); + } + + + async play(view: MarkdownView): Promise { + let content = view.getViewData(); + let language: string; + + //check if any language is defined in frontmatter + const frontmatter = content.match(/---[\s\S]*?---/); + if (frontmatter && frontmatter[0]) { + const parsedFrontmatter = parseYaml(frontmatter[0].replace(/---/g, '')); + if (parsedFrontmatter['lang']) { + language = parsedFrontmatter['lang']; + } + } + if (!this.plugin.settings.speakFrontmatter) + if (content.startsWith("---")) { + content = content.replace("---", ""); + content = content.substring(content.indexOf("---") + 1); + } + await this.say(view.getDisplayText(), content, language); + + } + + getLanguageFromFrontmatter(view: MarkdownView) : string { + let language = ""; + //check if any language is defined in frontmatter + const frontmatter = view.getViewData().match(/---[\s\S]*?---/); + if (frontmatter && frontmatter[0]) { + const parsedFrontmatter = parseYaml(frontmatter[0].replace(/---/g, '')); + if (parsedFrontmatter['lang']) { + language = parsedFrontmatter['lang']; + } + } + return language; + } + +} diff --git a/src/main.ts b/src/main.ts index 39548ab..20c6b30 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,15 +1,19 @@ import { - MarkdownView, Menu, Notice, parseYaml, Platform, + MarkdownView, Menu, Notice, Platform, Plugin } from 'obsidian'; import {DEFAULT_SETTINGS, TTSSettings, TTSSettingsTab} from "./settings"; +import {TTSService} from "./TTSService"; export default class TTSPlugin extends Plugin { + ttsService: TTSService; settings: TTSSettings; statusbar: HTMLElement; async onload(): Promise { + this.ttsService = new TTSService(this); + console.log("loading tts plugin"); //https://bugs.chromium.org/p/chromium/issues/detail?id=487255 @@ -25,32 +29,20 @@ export default class TTSPlugin extends Plugin { name: 'Start playback', checkCallback: (checking: boolean) => { const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - if (!checking) { - this.play(markdownView); - } - return true; - } - } - }); + if(checking) + return markdownView !== undefined; + this.ttsService.play(markdownView); - //clear statusbar text if not speaking - this.registerInterval(window.setInterval(() => { - if (!window.speechSynthesis.speaking) { - this.statusbar.setText("TTS"); } - }, 1000 * 10)); + }); this.addCommand({ id: 'cancel-tts-playback', name: 'Stop playback', checkCallback: (checking: boolean) => { - if (window.speechSynthesis.speaking) { - if (!checking) { - window.speechSynthesis.cancel(); - } - return true; - } + if(checking) + return this.ttsService.isSpeaking(); + this.ttsService.stop(); } }); @@ -58,12 +50,9 @@ export default class TTSPlugin extends Plugin { id: 'pause-tts-playback', name: 'pause playback', checkCallback: (checking: boolean) => { - if (window.speechSynthesis.speaking) { - if (!checking) { - window.speechSynthesis.pause(); - } - return true; - } + if(checking) + return this.ttsService.isSpeaking(); + this.ttsService.pause(); } }); @@ -71,22 +60,40 @@ export default class TTSPlugin extends Plugin { id: 'resume-tts-playback', name: 'Resume playback', checkCallback: (checking: boolean) => { - if (window.speechSynthesis.speaking) { - if (!checking) { - window.speechSynthesis.resume(); - } - return true; - } + if(checking) + return this.ttsService.isPaused(); + this.ttsService.resume(); } }); - this.addRibbonIcon("audio-file", "Text to Speech", async () => { - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) - await this.play(markdownView); - else new Notice("No file active"); + //clear statusbar text if not speaking + this.registerInterval(window.setInterval(() => { + if (!this.ttsService.isSpeaking()) { + this.statusbar.setText("TTS"); + } + }, 1000 * 10)); + + this.addRibbonIcon("audio-file", "Text to Speech", async (event) => { + await this.createMenu(event); }); + this.registerEvent(this.app.workspace.on('editor-menu', ((menu, editor, markdownView) => { + menu.addItem((item) => { + item + .setTitle("Say selected text") + .setIcon("audio-file") + .onClick(() => { + this.ttsService.say("", editor.getSelection(), this.ttsService.getLanguageFromFrontmatter(markdownView)); + }); + }); + }))); + + this.registerEvent(this.app.workspace.on('layout-change', (() => { + if(this.settings.stopPlaybackWhenNoteClosed) { + this.ttsService.stop(); + } + }))); + this.addSettingTab(new TTSSettingsTab(this)); this.statusbar = this.addStatusBarItem(); this.statusbar.setText("TTS"); @@ -94,143 +101,71 @@ export default class TTSPlugin extends Plugin { this.statusbar.setAttribute("aria-label", "Text to Speech"); this.statusbar.setAttribute("aria-label-position", "top"); this.statusbar.onClickEvent(async (event) => { - const menu = new Menu(this.app); - - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - if (window.speechSynthesis.speaking) { - menu.addItem((item) => { - item - .setIcon("play-audio-glyph") - .setTitle("Add to playback queue") - .onClick((async () => { - await this.play(markdownView); - })); - }); - } else { - menu.addItem((item) => { - item - .setIcon("play-audio-glyph") - .setTitle("Play") - .onClick((async () => { - await this.play(markdownView); - })); - }); - } - } - - if (window.speechSynthesis.speaking) { - menu.addItem((item) => { - item - .setIcon("stop-audio-glyph") - .setTitle("Stop") - .onClick(async () => { - window.speechSynthesis.cancel(); - }); - }); - - - if (window.speechSynthesis.paused) { - menu.addItem((item) => { - item - .setIcon("play-audio-glyph") - .setTitle("Resume") - .onClick(async () => { - window.speechSynthesis.resume(); - }); - }); - } else { - menu.addItem((item) => { - item - .setIcon("paused") - .setTitle("Pause") - .onClick(async () => { - window.speechSynthesis.pause(); - }); - }); - } - } - - - menu.showAtPosition({x: event.x, y: event.y}); + await this.createMenu(event); }); } - async sayWithVoice(title: string, text: string, voice: string): Promise { - console.log("saying " + voice); - let content = text; - if (!this.settings.speakSyntax) { - content = content.replace(/#/g, ""); - content = content.replace("-", ""); - content = content.replace("_", ""); - } - if (!this.settings.speakLinks) { - content = content.replace(/(?:https?|ftp|file|data:):\/\/[\n\S]+/g, ''); - } - if(!this.settings.speakCodeblocks) { - content = content.replace(/```[\s\S]*?```/g, ''); + async createMenu(event: MouseEvent) : Promise { + const menu = new Menu(this.app); + + const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (markdownView) { + if (window.speechSynthesis.speaking) { + menu.addItem((item) => { + item + .setIcon("play-audio-glyph") + .setTitle("Add to playback queue") + .onClick((async () => { + await this.ttsService.play(markdownView); + })); + }); + } else { + menu.addItem((item) => { + item + .setIcon("play-audio-glyph") + .setTitle("Play") + .onClick((async () => { + await this.ttsService.play(markdownView); + })); + }); + } } - if (this.settings.speakTitle) { - content = title + " " + content; - } - - - - const msg = new SpeechSynthesisUtterance(); - msg.text = content; - msg.volume = this.settings.volume; - msg.rate = this.settings.rate; - msg.pitch = this.settings.pitch; - msg.voice = window.speechSynthesis.getVoices().filter(otherVoice => otherVoice.name === voice)[0]; - window.speechSynthesis.speak(msg); - this.statusbar.setText("TTS: playing"); - } - - - getVoice(languageCode: string) : string { - const filtered = this.settings.languageVoices.filter((lang) => lang.language === languageCode); - if (filtered.length === 0) return null; - return filtered[0].voice; - } - - async say(title: string, text: string, languageCode?: string): Promise { - let usedVoice = this.settings.defaultVoice; - if (languageCode) { - const voice = this.getVoice(languageCode); - if (voice) { - usedVoice = voice; - } else { - new Notice("TTS: could not find voice for language " + languageCode + ". Using default voice."); + if (window.speechSynthesis.speaking) { + menu.addItem((item) => { + item + .setIcon("stop-audio-glyph") + .setTitle("Stop") + .onClick(async () => { + this.ttsService.stop(); + }); + }); + + + if (window.speechSynthesis.paused) { + menu.addItem((item) => { + item + .setIcon("play-audio-glyph") + .setTitle("Resume") + .onClick(async () => { + this.ttsService.resume(); + }); + }); + } else { + menu.addItem((item) => { + item + .setIcon("paused") + .setTitle("Pause") + .onClick(async () => { + this.ttsService.pause(); + }); + }); } - } - - await this.sayWithVoice(title, text, usedVoice); - } - - - async play(view: MarkdownView): Promise { - let content = view.getViewData(); - let language: string; - - //check if any language is defined in frontmatter - const frontmatter = content.match(/---[\s\S]*?---/); - if (frontmatter && frontmatter[0]) { - const parsedFrontmatter = parseYaml(frontmatter[0].replace(/---/g, '')); - if (parsedFrontmatter['lang']) { - language = parsedFrontmatter['lang']; - } - } - - if (!this.settings.speakFrontmatter) - if (content.startsWith("---")) { - content = content.replace("---", ""); - content = content.substring(content.indexOf("---") + 1); - } + } - await this.say(view.getDisplayText(), content, language); - } + menu.showAtPosition({x: event.x, y: event.y}); + } async onunload(): Promise { console.log("unloading tts plugin"); diff --git a/src/settings.ts b/src/settings.ts index 6dceb55..d97b95b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -19,6 +19,7 @@ export interface TTSSettings { speakCodeblocks: boolean; speakTitle: boolean; languageVoices: LanguageVoiceMap[]; + stopPlaybackWhenNoteChanges: boolean; } export const DEFAULT_SETTINGS: TTSSettings = { @@ -31,7 +32,8 @@ export const DEFAULT_SETTINGS: TTSSettings = { speakSyntax: false, speakTitle: true, speakCodeblocks: false, - languageVoices: [] + languageVoices: [], + stopPlaybackWhenNoteChanges: false, } export class TTSSettingsTab extends PluginSettingTab { @@ -70,7 +72,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.ttsService.say('', value.getValue()); })); @@ -270,5 +272,17 @@ export class TTSSettingsTab extends PluginSettingTab { await this.plugin.saveSettings(); }); }); + + containerEl.createEl("h2", {text: "Misc"}); + new Setting(containerEl) + .setName("Stop playback when a note is closed/new note is opened") + .addToggle(async (toggle) => { + toggle + .setValue(this.plugin.settings.stopPlaybackWhenNoteChanges) + .onChange(async (value) => { + this.plugin.settings.stopPlaybackWhenNoteChanges = value; + await this.plugin.saveSettings(); + }); + }); } }