diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1f88dca..900c7e5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v1 with: - node-version: "14.x" # You might need to adjust this value to your own version + node-version: "16.x" # You might need to adjust this value to your own version - name: Build id: build run: | @@ -26,7 +26,7 @@ jobs: yarn yarn run build mkdir ${{ env.PLUGIN_NAME }} - cp main.js manifest.json ${{ env.PLUGIN_NAME }} + cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} ls echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" diff --git a/esbuild.config.mjs b/esbuild.config.mjs deleted file mode 100644 index 69ca0f4..0000000 --- a/esbuild.config.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import esbuild from "esbuild"; -import process from "process"; -import builtins from 'builtin-modules' - -const banner = - `/* -THIS IS A GENERATED/BUNDLED FILE BY ESBUILD -if you want to view the source, please visit the github repository of this plugin -https://github.com/joethei/obsidian-tts -*/ -`; - -const prod = (process.argv[2] === 'production'); - -esbuild.build({ - banner: { - js: banner, - }, - entryPoints: ['src/main.ts'], - bundle: true, - external: ['obsidian', 'electron', ...builtins], - format: 'cjs', - watch: !prod, - target: 'es2016', - logLevel: "info", - sourcemap: prod ? false : 'inline', - treeShaking: true, - outfile: 'main.js', -}).catch(() => process.exit(1)); diff --git a/manifest.json b/manifest.json index f5b0c21..25e811c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ -{ - "id": "obsidian-tts", - "name": "Text to Speech", - "version": "0.5.4", - "minAppVersion": "1.4.0", - "description": "Hear your notes.", - "author": "Johannes Theiner", - "authorUrl": "https://github.com/joethei", - "isDesktopOnly": false -} +{ + "id": "obsidian-tts", + "name": "Text to Speech", + "version": "0.5.5", + "minAppVersion": "1.4.0", + "description": "Hear your notes.", + "author": "Johannes Theiner", + "authorUrl": "https://github.com/joethei", + "isDesktopOnly": false +} diff --git a/package.json b/package.json index 23a2ee8..147d09a 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "obsidian-tts", - "version": "0.5.3", + "version": "0.5.5", "description": "Text to speech for Obsidian. Hear your notes.", "main": "main.js", "scripts": { - "dev": "node esbuild.config.mjs", - "build": "node esbuild.config.mjs production", + "dev": "rollup --config rollup.config.dev.mjs -w", + "build": "rollup --config rollup.config.build.mjs", "lint": "eslint . --ext .ts", "docs": "typedoc" }, @@ -13,25 +13,39 @@ "author": "joethei", "license": "GPL-3.0", "dependencies": { - "@cospired/i18n-iso-languages": "^3.1.1", + "@cospired/i18n-iso-languages": "^4.2.0", + "@soundtouchjs/audio-worklet": "^0.1.17", "@types/node": "^16.11.6", + "@vanakat/plugin-api": "0.1.0", "builtin-modules": "^3.2.0", + "microsoft-cognitiveservices-speech-sdk": "^1.40.0", "obsidian": "1.4.11", - "tslib": "2.3.1", - "typescript": "4.4.4", + "regenerator-runtime": "^0.14.1", + "soundtouchjs": "^0.1.30", "tinyld": "1.2.3", - "@vanakat/plugin-api": "0.1.0" + "tslib": "2.3.1", + "typescript": "4.4.4" }, "devDependencies": { + "@colingm/rollup-plugin-web-worker-loader": "^1.6.1", + "@microsoft/tsdoc": "0.14.1", + "@rollup/plugin-commonjs": "^28.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.3.0", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", - "esbuild": "0.13.12", "eslint": "7.32.0", - "@microsoft/tsdoc": "0.14.1", "eslint-plugin-tsdoc": "0.2.16", + "rollup": "^4.24.0", + "rollup-plugin-copy": "^3.5.0", + "rollup-plugin-delete": "^2.1.0", + "rollup-plugin-esbuild": "^6.1.1", "typedoc": "0.22.18" }, "overrides": { - "obsidian" : "$obsidian" + "obsidian": "$obsidian", + "@colingm/rollup-plugin-web-worker-loader": { + "rollup": "$rollup" + } } } diff --git a/rollup.config.build.mjs b/rollup.config.build.mjs new file mode 100644 index 0000000..b8e5229 --- /dev/null +++ b/rollup.config.build.mjs @@ -0,0 +1,52 @@ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import webWorkerLoader from '@colingm/rollup-plugin-web-worker-loader'; +import del from 'rollup-plugin-delete'; +import copy from 'rollup-plugin-copy'; +import esbuild from 'rollup-plugin-esbuild' + +const banner = + `/* +THIS IS A GENERATED/BUNDLED FILE CREATED USING ROLLUP +If you want to view the source, please visit the github repository of this plugin +https://github.com/joethei/obsidian-tts +*/ +`; + +export default { + input: 'src/main.ts', + output: { + dir: '.', + banner: banner, + sourcemap: 'inline', + format: 'cjs', + exports: 'default' + }, + external: ['obsidian', 'electron'], + plugins: [ + nodeResolve({browser: true}), + commonjs(), + esbuild({ target: 'es2015', minify: true }), + json(), + webWorkerLoader({ targetPlatform: 'browser' }), + copy({ + targets: [{ + src: 'node_modules/@soundtouchjs/audio-worklet/dist/soundtouch-worklet.js', + dest: 'src' + }], + hook: 'buildStart' + }), + del({ + targets: 'src/soundtouch-worklet.js', + hook: 'buildEnd' + }) + ], + onwarn: function(warning, warner) { + if (warning.id && /node_modules/.test(warning.id)) return; + if (warning.ids && warning.ids.every((id) => /node_modules/.test(id))) + return; + + warner(warning); + } +}; diff --git a/rollup.config.dev.mjs b/rollup.config.dev.mjs new file mode 100644 index 0000000..38542b4 --- /dev/null +++ b/rollup.config.dev.mjs @@ -0,0 +1,45 @@ +import {nodeResolve} from '@rollup/plugin-node-resolve'; +import json from '@rollup/plugin-json'; +import commonjs from '@rollup/plugin-commonjs'; +import webWorkerLoader from '@colingm/rollup-plugin-web-worker-loader'; +import del from 'rollup-plugin-delete'; +import copy from 'rollup-plugin-copy'; +import esbuild from 'rollup-plugin-esbuild' + +export default { + input: 'src/main.ts', + output: { + dir: './dist/obsidian-tts', + sourcemap: 'inline', + format: 'cjs', + exports: 'default' + }, + external: ['obsidian', 'electron'], + plugins: [ + nodeResolve({browser: true}), + commonjs(), + esbuild({ target: 'es2015', minify: false }), + json(), + webWorkerLoader({ targetPlatform: 'browser' }), + copy({ + targets: [{ + src: 'node_modules/@soundtouchjs/audio-worklet/dist/soundtouch-worklet.js', + dest: 'src' + }, + { src: ['manifest.json'], dest: './dist/obsidian-tts' } + ], + hook: 'buildStart' + }), + del({ + targets: 'src/soundtouch-worklet.js', + hook: 'buildEnd' + }) + ], + onwarn: function(warning, warner) { + if (warning.id && /node_modules/.test(warning.id)) return; + if (warning.ids && warning.ids.every((id) => /node_modules/.test(id))) + return; + + warner(warning); + } +}; diff --git a/src/LanguageVoiceModal.ts b/src/LanguageVoiceModal.ts index c4b8060..83f0eac 100644 --- a/src/LanguageVoiceModal.ts +++ b/src/LanguageVoiceModal.ts @@ -1,5 +1,5 @@ import {Modal, Setting} from "obsidian"; -import {LanguageVoiceMap} from "./settings"; +import { LanguageVoiceMap } from "./DEFAULT_SETTINGS"; import {TextInputPrompt} from "./TextInputPrompt"; import TTSPlugin from "./main"; import languages from "@cospired/i18n-iso-languages"; @@ -73,7 +73,7 @@ export class LanguageVoiceModal extends Modal { .setIcon("play-audio-glyph") .setTooltip("Test voice") .onClick(async() => { - 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"); + 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", "Play", false); await input.openAndGetValue((async value => { if (value.getValue().length === 0) return; await this.plugin.serviceManager.sayWithVoice(value.getValue(), this.id); diff --git a/src/ServiceConfigurationModal.ts b/src/ServiceConfigurationModal.ts index f0aa862..3793d04 100644 --- a/src/ServiceConfigurationModal.ts +++ b/src/ServiceConfigurationModal.ts @@ -1,12 +1,15 @@ import {Modal, Setting} from "obsidian"; import TTSPlugin from "./main"; +import {DEFAULT_SETTINGS, STYLE_OPTIONS, ROLE_OPTIONS, SERVICE_OPTIONS} from "./constants"; export class ServiceConfigurationModal extends Modal { plugin: TTSPlugin; + service: string; - constructor(plugin: TTSPlugin) { + constructor(plugin: TTSPlugin, service?: string) { super(plugin.app); this.plugin = plugin; + this.service = service || ''; } display(service?: string): void { @@ -15,19 +18,22 @@ export class ServiceConfigurationModal extends Modal { contentEl.empty(); - new Setting(contentEl) - .setName('Service') - .setDesc('test') - .addDropdown((dropdown) => { - dropdown.addOption('openai', 'OpenAI'); - dropdown.addOption('microsoft', 'Microsoft Azure'); + if (!service) { + new Setting(contentEl) + .setName('Service') + .setDesc('Add a remote voice service') + .addDropdown((dropdown) => { + Object.entries(SERVICE_OPTIONS).forEach(([key, value]) => { + dropdown.addOption(key, value); + }); - dropdown.setValue(service); + dropdown.setValue(service); - dropdown.onChange(async(value) => { - this.display(value); - }) - }); + dropdown.onChange(async(value) => { + this.display(value); + }) + }); + } if (service === 'openai') { new Setting(contentEl) @@ -40,19 +46,88 @@ export class ServiceConfigurationModal extends Modal { this.plugin.settings.services.openai.key = value; await this.plugin.saveSettings(); }); - }) - ; + }); + } + if (service === 'azure') { + new Setting(contentEl) + .setName('API key') + .setDesc('Azure speech services API key') + .addText(async text => { + text + .setValue(this.plugin.settings.services.azure.key) + .onChange(async value => { + this.plugin.settings.services.azure.key = value; + await this.plugin.saveSettings(); + }); + }); + new Setting(contentEl) + .setName('Speech region') + .setDesc('Azure speech services region') + .addText(async text => { + text + .setValue(this.plugin.settings.services.azure.region) + .onChange(async value => { + this.plugin.settings.services.azure.region = value; + await this.plugin.saveSettings(); + }); + }); + new Setting(contentEl) + .setName('Role') + .addDropdown((dropdown) => { + Object.entries(ROLE_OPTIONS).forEach(([key, value]) => { + dropdown.addOption(key, value); + }); - } + dropdown.setValue(this.plugin.settings.services.azure.role); + dropdown.onChange(async(value) => { + this.plugin.settings.services.azure.role = value; + await this.plugin.saveSettings(); + }) + }); + new Setting(contentEl) + .setName('Style') + .addDropdown((dropdown) => { + Object.entries(STYLE_OPTIONS).forEach(([key, value]) => { + dropdown.addOption(key, value); + }); + dropdown.setValue(this.plugin.settings.services.azure.style); + dropdown.onChange(async(value) => { + this.plugin.settings.services.azure.style = value; + await this.plugin.saveSettings(); + }) + }); + new Setting(contentEl) + .setName("Intensity") + .addSlider(async (slider) => { + slider + .setValue(this.plugin.settings.services.azure.intensity * 100) + .setDynamicTooltip() + .setLimits(0, 200, 1) + .onChange(async (value: number) => { + this.plugin.settings.services.azure.intensity = value / 100; + await this.plugin.saveSettings(); + }); + }).addExtraButton((button) => { + button + .setIcon('reset') + .setTooltip('restore default') + .onClick(async () => { + this.plugin.settings.services.azure.intensity = DEFAULT_SETTINGS.services.azure.intensity; + await this.plugin.saveSettings(); + this.display(); + }); + }); + } } onOpen(): void { //@ts-ignore - this.setTitle('Add new service'); - this.display(); - + if (this.service) this.setTitle(`Configure ${SERVICE_OPTIONS[this.service]} service`); + //@ts-ignore + else this.setTitle('Add new service'); + this.display(this.service); } diff --git a/src/ServiceManager.ts b/src/ServiceManager.ts index 6cf63e2..80f856e 100644 --- a/src/ServiceManager.ts +++ b/src/ServiceManager.ts @@ -1,16 +1,32 @@ import {TTSService} from "./services/TTSService"; import TTSPlugin from "./main"; import {SpeechSynthesis} from "./services/SpeechSynthesis"; -import {Notice} from "obsidian"; +import {Notice, Platform} from "obsidian"; +import { OpenAI } from "./services/OpenAI"; +import { Azure } from "./services/Azure"; +import { cleanText } from "./utils"; +export interface Voice { + service: string; + id: string; + name: string; + languages: string[]; +} export class ServiceManager { private readonly plugin: TTSPlugin; private services: TTSService[] = []; + private activeService: TTSService; constructor(plugin: TTSPlugin) { this.plugin = plugin; - this.services.push(new SpeechSynthesis(this.plugin)); - //this.services.push(new OpenAI(this)); + // Due to a bug in android SpeechSynthesis does not work on this platform + // https://bugs.chromium.org/p/chromium/issues/detail?id=487255 + if (!Platform.isAndroidApp) { + this.services.push(new SpeechSynthesis(this.plugin)); + } + this.services.push(new OpenAI(this.plugin)); + this.services.push(new Azure(this.plugin)); + this.activeService = this.services.find(service => this.plugin.settings.defaultService === service.id); } public getServices(): TTSService[] { @@ -18,35 +34,23 @@ export class ServiceManager { } public isSpeaking(): boolean { - return this.services.some(service => service.isSpeaking()); + return this.activeService.isSpeaking(); } public isPaused(): boolean { - return this.services.every(service => service.isPaused()); + return this.activeService.isPaused(); } stop() : void { - for (const service of this.services) { - if(service.isSpeaking() || service.isPaused()) { - service.stop(); - } - } + this.activeService.stop(); } pause() : void { - for (const service of this.services) { - if(service.isSpeaking()) { - service.pause(); - } - } + this.activeService.pause(); } resume(): void { - for (const service of this.services) { - if(service.isPaused()) { - service.resume(); - } - } + this.activeService.resume(); } async sayWithVoice(text: string, voice: string): Promise { @@ -57,11 +61,10 @@ export class ServiceManager { if(!service) { new Notice("No service found for voice" + voice); } - await service.sayWithVoice(text, voice); - + await service.sayWithVoice(cleanText(text, this.plugin.settings.regexPatternsToIgnore), voice); } - async getVoices() { + async getVoices(): Promise { const voices = []; for (const service of this.services) { for (const voice of await service.getVoices()) { @@ -76,5 +79,12 @@ export class ServiceManager { return voices; } + get progress(): number { + return this.activeService.progress; + } + + seek(progress: number): void { + this.activeService.seek(progress); + } } diff --git a/src/TextInputPrompt.ts b/src/TextInputPrompt.ts index 6c67dd6..2f5705a 100644 --- a/src/TextInputPrompt.ts +++ b/src/TextInputPrompt.ts @@ -5,9 +5,17 @@ export class TextInputPrompt extends Modal { private resolve: (value: TextComponent) => void; private textComponent: TextComponent; - constructor(app: App, private promptText: string, private hint: string, private defaultValue: string, private placeholder: string) { - super(app); - } + constructor( + app: App, + private promptText: string, + private hint: string, + private defaultValue: string, + private placeholder: string, + private buttonText: string, + private autoClose: boolean = true + ) { + super(app); + } onOpen(): void { this.titleEl.setText(this.promptText); @@ -32,9 +40,12 @@ export class TextInputPrompt extends Modal { new Setting(div).addButton((b) => { b - .setButtonText("Play") + .setButtonText(this.buttonText) .onClick(async () => { this.resolve(this.textComponent); + if (this.autoClose) { + this.close(); + } }); return b; }); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..9ea3964 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,170 @@ +import { SpeechSynthesisOutputFormat } from "microsoft-cognitiveservices-speech-sdk"; + +export interface LanguageVoiceMap { + id: string; + language: string; + voice: string; +} + +export interface TTSDefaultVoices { + [service: string]: string; +} + +export interface TTSSettings { + defaultVoice: string, + defaultVoices: TTSDefaultVoices; + defaultService: string, + pitch: number; + rate: number; + volume: number; + speakLinks: boolean; + speakFrontmatter: boolean; + speakSyntax: boolean; + speakCodeblocks: boolean; + speakTitle: boolean; + speakEmoji: boolean; + speakComments: boolean; + languageVoices: LanguageVoiceMap[]; + stopPlaybackWhenNoteChanges: boolean; + regexPatternsToIgnore: string[]; + services: { + openai: { + key: string; + }, + azure: { + key: string; + region: string; + role: string; + style: string; + intensity: number; + } + } +} + +export const DEFAULT_SETTINGS: TTSSettings = { + defaultVoice: "", + defaultVoices: {}, + defaultService: "speechSynthesis", + pitch: 1, + rate: 1, + volume: 1, + speakLinks: false, + speakFrontmatter: false, + speakSyntax: false, + speakTitle: true, + speakCodeblocks: false, + speakEmoji: false, + speakComments: false, + languageVoices: [], + stopPlaybackWhenNoteChanges: false, + regexPatternsToIgnore: [], + services: { + openai: { + key: '', + }, + azure: { + key: '', + region: '', + role: 'OlderAdultFemale', + style: 'chat', + intensity: 1 + } + } +} + +export const VOICE_FORMAT_NAMES = [ + "raw-8khz-8bit-mono-mulaw(.wav)", + "riff-16khz-16kbps-mono-siren(.wav)", + "audio-16khz-16kbps-mono-siren(.wav)", + "audio-16khz-32kbitrate-mono-mp3(.mp3)", + "audio-16khz-128kbitrate-mono-mp3(.mp3)", + "audio-16khz-64kbitrate-mono-mp3(.mp3)", + "raw-16khz-16bit-mono-truesilk(.wav)", + "riff-16khz-16bit-mono-pcm(.wav)", + "riff-24khz-16bit-mono-pcm(.wav)", + "riff-8khz-8bit-mono-mulaw(.wav)", + "raw-16khz-16bit-mono-pcm(.wav)", + "raw-24khz-16bit-mono-pcm(.wav)", + "raw-8khz-16bit-mono-pcm(.wav)", + "ogg-16khz-16bit-mono-opus(.wav)", + "raw-48khz-16bit-mono-pcm(.wav)", + "ogg-48khz-16bit-mono-opus(.wav)", + "webm-16khz-16bit-mono-opus(.wav)", + "webm-24khz-16bit-mono-opus(.wav)", + "raw-24khz-16bit-mono-truesilk(.wav)", + "raw-8khz-8bit-mono-alaw(.wav)", + "riff-8khz-8bit-mono-alaw(.wav)", + "webm-24khz-16bit-24kbps-mono-opus(.wav)", + "audio-16khz-16bit-32kbps-mono-opus(.wav)", + "audio-24khz-16bit-48kbps-mono-opus(.wav)", + "audio-24khz-16bit-24kbps-mono-opus(.wav)", + "raw-22050hz-16bit-mono-pcm(.wav)", + "riff-22050hz-16bit-mono-pcm(.wav)", + "raw-44100hz-16bit-mono-pcm(.wav)", + "riff-44100hz-16bit-mono-pcm(.wav)", +]; + +const VOICE_FORMAT_VASL = Object.keys(SpeechSynthesisOutputFormat).filter( + (val) => isNaN(Number(val)) +); + +export const SERVICE_OPTIONS = { + "openai": "OpenAI", + "azure": "Azure", +}; + +export const STYLE_OPTIONS = { + advertisement_upbeat: "advertisement upbeat", + affectionate: "affectionate", + angry: "angry", + assistant: "assistant", + calm: "calm", + chat: "chat", + cheerful: "cheerful", + customerservice: "customerservice", + depressed: "depressed", + disgruntled: "disgruntled", + "documentary-narration": "documentary narration", + embarrassed: "embarrassed", + empathetic: "empathetic", + envious: "envious", + excited: "excited", + fearful: "fearful", + friendly: "friendly", + gentle: "gentle", + hopeful: "hopeful", + lyrical: "lyrical", + "narration-professional": "narration professional", + "narration-relaxed": "narration relaxed", + newscast: "newscast", + "newscast-casual": "newscast casual", + "newscast-formal": "newscast formal", + "poetry-reading": "poetry reading", + sad: "sad", + serious: "serious", + shouting: "shouting", + sports_commentary: "sports commentary", + sports_commentary_excited: "sports commentary excited", + whispering: "whispering", + terrified: "terrified", + unfriendly: "unfriendly", +}; + +export const ROLE_OPTIONS = { + Girl: "Girl", + Boy: "Boy", + YoungAdultFemale: "YoungAdultFemale", + YoungAdultMale: "YoungAdultMale", + OlderAdultFemale: "OlderAdultFemale", + OlderAdultMale: "OlderAdultMale", + SeniorFemale: "SeniorFemale", + SeniorMale: "SeniorMale", +}; + +export const VOICE_FORMAT_MAP = VOICE_FORMAT_NAMES.reduce( + (res, key, index) => ({ + ...res, + [key]: VOICE_FORMAT_VASL[index], + }), + {} +); diff --git a/src/main.ts b/src/main.ts index 42fe540..4d110a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,35 +1,40 @@ import { addIcon, MarkdownFileInfo, - MarkdownView, Menu, Notice, Platform, + MarkdownView, Notice, Plugin, setIcon, TFile } from 'obsidian'; -import {DEFAULT_SETTINGS, LanguageVoiceMap, TTSSettings, TTSSettingsTab} from "./settings"; +import {TTSSettingsTab} from "./settings"; +import { DEFAULT_SETTINGS, LanguageVoiceMap, TTSSettings } from "./constants"; import {registerAPI} from "@vanakat/plugin-api"; import {detect} from "tinyld"; import {ServiceManager} from "./ServiceManager"; +import { cleanText, resetStatusbar } from './utils'; +import 'regenerator-runtime/runtime'; export default class TTSPlugin extends Plugin { settings: TTSSettings; statusbar: HTMLElement; + menuVisible = false; + statusbarIntervalId: number; + seekbar: HTMLElement; + playButton: HTMLElement; + pauseButton: HTMLElement; + stopButton: HTMLElement; + serviceManager: ServiceManager; + get isPaused(): boolean { + return this.serviceManager.isPaused(); + } async onload(): Promise { // from https://github.com/phosphor-icons/core addIcon('tts-play-pause', ''); - - console.log("loading tts plugin"); - //https://bugs.chromium.org/p/chromium/issues/detail?id=487255 - if (Platform.isAndroidApp) { - new Notice("TTS: due to a bug in android this plugin does not work on this platform"); - this.unload(); - } - await this.loadSettings(); this.serviceManager = new ServiceManager(this); @@ -64,7 +69,7 @@ export default class TTSPlugin extends Plugin { this.addCommand({ id: 'pause-tts-playback', name: 'pause playback', - icon: 'pause', + icon: 'lucide-pause-circle', checkCallback: (checking: boolean) => { if (!checking) { this.serviceManager.pause(); @@ -115,13 +120,12 @@ export default class TTSPlugin extends Plugin { //clear statusbar text if not speaking this.registerInterval(window.setInterval(() => { if (!this.serviceManager.isSpeaking()) { - this.statusbar.empty(); - setIcon(this.statusbar, 'audio-file'); + resetStatusbar(this.statusbar); } }, 1000 * 10)); - this.addRibbonIcon("audio-file", "Text to Speech", async (event) => { - await this.createMenu(event); + this.addRibbonIcon("audio-file", "Text to Speech", async () => { + await this.createMenu(); }); this.registerEvent(this.app.workspace.on('editor-menu', ((menu, _, markdownView) => { @@ -161,67 +165,106 @@ export default class TTSPlugin extends Plugin { this.statusbar.classList.add("mod-clickable"); this.statusbar.setAttribute("aria-label", "Text to Speech"); this.statusbar.setAttribute("aria-label-position", "bottom"); - this.statusbar.onClickEvent(async (event) => { - await this.createMenu(event); + this.statusbar.onClickEvent(async () => { + if (this.menuVisible) { + this.removeMenu(); + return; + } + + await this.createMenu(); }); registerAPI("tts", this.serviceManager, this); } - async createMenu(event: MouseEvent): Promise { - const menu = new Menu(); + removeMenu(): void { + this.menuVisible = false; + clearInterval(this.statusbarIntervalId); + this.seekbar?.remove(); + this.playButton?.remove(); + this.pauseButton?.remove(); + this.stopButton?.remove(); + } - const markdownView = this.app.workspace.activeEditor; - 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 { - await this.play(markdownView); - return; - } + async createMenu(): Promise { + const getSeekbarBackgroundStyle = (value: number): string => { + return 'linear-gradient(to right, var(--interactive-accent) 0%, var(--interactive-accent) ' + value + '%, #fff ' + value + '%, white 100%)'; } - - if (window.speechSynthesis.speaking) { - menu.addItem((item) => { - item - .setIcon("stop-audio-glyph") - .setTitle("Stop") - .onClick(async () => { - this.serviceManager.stop(); - }); + const updateSeekbar = () => { + const value = this.serviceManager.progress; + this.seekbar.querySelector('input').value = value.toString(); + this.seekbar.querySelector('input').style.background = getSeekbarBackgroundStyle(value); + }; + const createPlayButton = () => { + this.playButton = this.addStatusBarItem(); + setIcon(this.playButton, 'play-audio-glyph'); + this.playButton.onClickEvent(() => { + this.serviceManager.resume(); + this.statusbarIntervalId = this.registerInterval(window.setInterval(updateSeekbar, 1000)); + this.playButton.remove(); + createPauseButton(); }); + } + const createPauseButton = () => { + this.pauseButton = this.addStatusBarItem(); + setIcon(this.pauseButton, 'lucide-pause-circle'); + this.pauseButton.onClickEvent(() => { + clearInterval(this.statusbarIntervalId); + this.serviceManager.pause(); + this.pauseButton.remove(); + createPlayButton(); + }); + } + const showControls = () => { + this.menuVisible = true; + // Seekbar + const curProgress = this.serviceManager.progress ? this.serviceManager.progress : 0; + this.seekbar = this.addStatusBarItem(); + const slider = this.seekbar.createEl('input', { type: 'range', attr: { min: '0', max: '100', value: curProgress.toString(), step: '1' } }); + slider.classList.add('seekbar'); + slider.style.background = getSeekbarBackgroundStyle(curProgress); + slider.oninput = function (this: HTMLInputElement) { + const value = parseInt(this.value); + this.style.background = getSeekbarBackgroundStyle(value); + }; + + this.statusbarIntervalId = this.registerInterval(window.setInterval(updateSeekbar, 1000)); + + this.seekbar.querySelector('input').onmousedown = () => { + if (!this.isPaused) { + clearInterval(this.statusbarIntervalId); + } + }; + this.seekbar.querySelector('input').onchange = (e) => { + this.serviceManager.seek(parseInt((e.target as HTMLInputElement).value)); + if (!this.isPaused) { + this.statusbarIntervalId = this.registerInterval(window.setInterval(updateSeekbar, 1000)); + } + }; + // Stop button + this.stopButton = this.addStatusBarItem(); + setIcon(this.stopButton, 'stop-audio-glyph'); + this.stopButton.onClickEvent(() => { + this.serviceManager.stop(); + resetStatusbar(this.statusbar); + this.removeMenu(); + }); - if (window.speechSynthesis.paused) { - menu.addItem((item) => { - item - .setIcon("play-audio-glyph") - .setTitle("Resume") - .onClick(async () => { - this.serviceManager.resume(); - }); - }); + if (this.serviceManager.isPaused()) { + createPlayButton(); } else { - menu.addItem((item) => { - item - .setIcon("paused") - .setTitle("Pause") - .onClick(async () => { - this.serviceManager.pause(); - }); - }); + createPauseButton(); } } - - menu.showAtPosition({x: event.x, y: event.y}); + const markdownView = this.app.workspace.activeEditor; + if (markdownView) { + await this.play(markdownView); + showControls(); + } else if (this.serviceManager.isSpeaking()) { + showControls(); + } } async onunload(): Promise { @@ -274,16 +317,17 @@ export default class TTSPlugin extends Plugin { new Notice("TTS: could not find voice for language " + languageCode + ". Using default voice."); } } + // @ts-ignore const split = usedVoice.split(/-(.*)/s); 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); + await this.serviceManager.sayWithVoice(cleanText(text, this.settings.regexPatternsToIgnore), this.settings.defaultVoice); return; } - await service.sayWithVoice(text, split[1]); + await service.sayWithVoice(cleanText(text, this.settings.regexPatternsToIgnore), split[1]); } prepareText(title: string, text: string): string { diff --git a/src/services/AudioPlayer.ts b/src/services/AudioPlayer.ts new file mode 100644 index 0000000..de76787 --- /dev/null +++ b/src/services/AudioPlayer.ts @@ -0,0 +1,99 @@ +// @ts-ignore +import registerSoundtouchWorklet from "audio-worklet:../soundtouch-worklet"; +import createSoundTouchNode from '@soundtouchjs/audio-worklet'; +import TTSPlugin from "src/main"; +import { resetStatusbar } from "../utils"; + +interface PlayEventDetail { + timePlayed: number, // float representing the current 'playHead' position in seconds + formattedTimePlayed: string, // the 'timePlayed' in 'mm:ss' format + percentagePlayed: number // int representing the percentage of what's been played based on the 'timePlayed' and audio duration +} + +interface Soundtouch { + rate: number; + tempo: number; + pitch: number; + percentagePlayed: number; + playing: boolean; + ready: boolean; + connectToBuffer(): AudioBufferSourceNode; + disconnectFromBuffer(): void; + connect(node: AudioNode): void; + disconnect(): void; + play(): void; + stop(): Promise; + pause(): void; + off(): void; + on(event: string, callback: (detail?: PlayEventDetail) => void): void; +} + +export default class AudioPlayer { + plugin: TTSPlugin; + audioCtx: AudioContext; + gainNode: GainNode; + soundtouch: Soundtouch; + bufferNode: AudioBufferSourceNode; + paused = false; + + constructor(plugin: TTSPlugin) { + this.plugin = plugin; + this.createContext(); + } + + private async createContext() { + this.audioCtx = new AudioContext(); + await registerSoundtouchWorklet(this.audioCtx); + } + + private onEnd = () => { + this.stop(); + } + + private onInitialized() { + this.soundtouch.on('end', () => this.onEnd); + this.soundtouch.tempo = this.plugin.settings.rate; + this.soundtouch.pitch = this.plugin.settings.pitch; + this.play(); + } + + protected async setupSoundtouch(buffer: ArrayBuffer): Promise { + if (this.soundtouch) { + await this.stop(); + } + this.soundtouch = createSoundTouchNode(this.audioCtx, AudioWorkletNode, buffer); + this.soundtouch.on('initialized', () => this.onInitialized()); + } + + protected play(): void { + if (!this.soundtouch.ready) return; + this.bufferNode = this.soundtouch.connectToBuffer(); // AudioBuffer goes to SoundTouchNode + this.gainNode = this.audioCtx.createGain(); + this.soundtouch.connect(this.gainNode); // SoundTouch goes to the GainNode + this.gainNode.connect(this.audioCtx.destination); // GainNode goes to the AudioDestinationNode + + this.soundtouch.play(); + } + + private disconnect(): boolean { + if (!this.bufferNode) return false; + this.gainNode.disconnect(); // disconnect the DestinationNode + this.soundtouch.disconnect(); // disconnect the AudioGainNode + this.soundtouch.disconnectFromBuffer(); // disconnect the SoundTouchNode + return true; + } + + protected async stop(): Promise { + if (!this.disconnect()) return; + this.soundtouch.off(); + await this.soundtouch.stop(); + resetStatusbar(this.plugin.statusbar); + } + + protected pause(): void { + if (!this.disconnect()) return; + this.soundtouch.pause(); + this.paused = true; + } + +} diff --git a/src/services/Azure.ts b/src/services/Azure.ts new file mode 100644 index 0000000..636b92a --- /dev/null +++ b/src/services/Azure.ts @@ -0,0 +1,260 @@ +import {TTSService} from "./TTSService"; +import TTSPlugin from "../main"; +import { + SpeakerAudioDestination, + SpeechConfig, + AudioConfig, + SpeechSynthesizer, + ResultReason +} from "microsoft-cognitiveservices-speech-sdk"; +import { resetStatusbar } from "../utils"; + +export class Azure implements TTSService { + plugin: TTSPlugin; + id = "azure"; + name = "Azure"; + _isPlaying = false; + _duration = 0; + + source: SpeakerAudioDestination | null = null; + + constructor(plugin: TTSPlugin) { + this.plugin = plugin; + } + + languages: []; + + async getVoices(): Promise<{ id: string; name: string; languages: string[] }[]> { + return [ + { + id: "alloy", + name: "Alloy", + languages: this.languages, + }, + { + id: "en-CA-ClaraNeural", + name: "ClaraNeural", + languages: this.languages, + }, + { + id: "en-CA-LiamNeural", + name: "LiamNeural", + languages: this.languages, + }, + { + id: "en-US-AvaNeural", + name: "AvaNeural", + languages: this.languages, + }, + { + id: "en-US-AndrewNeural", + name: "AndrewNeural", + languages: this.languages, + }, + { + id: "en-US-EmmaNeural", + name: "EmmaNeural", + languages: this.languages, + }, + { + id: "en-US-BrianNeural", + name: "BrianNeural", + languages: this.languages, + }, + { + id: "en-US-JennyNeural", + name: "JennyNeural", + languages: this.languages, + }, + { + id: "en-US-GuyNeural", + name: "GuyNeural", + languages: this.languages, + }, + { + id: "en-US-AriaNeural", + name: "AriaNeural", + languages: this.languages, + }, + { + id: "en-US-DavisNeural", + name: "DavisNeural", + languages: this.languages, + }, + { + id: "en-US-JaneNeural", + name: "JaneNeural", + languages: this.languages, + }, + { + id: "en-US-JasonNeural", + name: "JasonNeural", + languages: this.languages, + }, + { + id: "en-US-SaraNeural", + name: "SaraNeural", + languages: this.languages, + }, + { + id: "en-US-TonyNeural", + name: "TonyNeural", + languages: this.languages, + }, + { + id: "en-US-NancyNeural", + name: "NancyNeural", + languages: this.languages, + }, + { + id: "en-US-AmberNeural", + name: "AmberNeural", + languages: this.languages, + }, + { + id: "en-US-AnaNeural", + name: "AnaNeural", + languages: this.languages, + }, + { + id: "en-US-AshleyNeural", + name: "AshleyNeural", + languages: this.languages, + }, + { + id: "en-US-BrandonNeural", + name: "BrandonNeural", + languages: this.languages, + }, + { + id: "en-US-ChristopherNeural", + name: "ChristopherNeural", + languages: this.languages, + }, + { + id: "en-US-CoraNeural", + name: "CoraNeural", + languages: this.languages, + }, + { + id: "en-US-ElizabethNeural", + name: "ElizabethNeural", + languages: this.languages, + }, + { + id: "en-US-EricNeural", + name: "EricNeural", + languages: this.languages, + }, + { + id: "en-US-JacobNeural", + name: "JacobNeural", + languages: this.languages, + }, + ]; + } + + isConfigured(): boolean { + return this.plugin.settings.services.azure.key.length > 0 && + this.plugin.settings.services.azure.region.length > 0; + } + + isPaused(): boolean { + return this.source && !this._isPlaying; + } + + isSpeaking(): boolean { + return this.source ? true : false; + } + + isValid(): boolean { + return this.plugin.settings.services.azure.key.length > 0 && + this.plugin.settings.services.azure.region.length > 0; + } + + pause(): void { + this._isPlaying = false; + this.source.pause(); + } + + resume(): void { + this._isPlaying = true; + this.source.resume(); + } + + async sayWithVoice(text: string, voice: string) : Promise { + if (this.source) { + this.stop(); + resetStatusbar(this.plugin.statusbar); + } + const speechConfig = SpeechConfig.fromSubscription( + this.plugin.settings.services.azure.key, + this.plugin.settings.services.azure.region + ); + speechConfig.speechSynthesisVoiceName = voice; + this.source = new SpeakerAudioDestination(); + // this.source = new AzurePlayer(); + const audioConfig = AudioConfig.fromSpeakerOutput(this.source); + const synthesizer = new SpeechSynthesizer(speechConfig, audioConfig); + + const regionCode = voice.split('-').slice(0, 2).join('-'); + const {role, style, intensity} = this.plugin.settings.services.azure; + + // SSML content + const ssmlContent = text + ? ` + + + + ${text || ""} + + + + ` + : ""; + + // Start the synthesizer and wait for a result. + ssmlContent && + synthesizer.speakSsmlAsync( + ssmlContent, + result => { + if ( + result.reason === + ResultReason.SynthesizingAudioCompleted + ) { + synthesizer.close(); + this._duration = result.audioDuration / 10000000; + this._isPlaying = true; + this.source.onAudioEnd = () => { + this._isPlaying = false; + }; + this.plugin.statusbar.createSpan({text: 'Speaking'}); + } + }, + function (error: string) { + console.error('[Azure]', error); + synthesizer.close(); + } + ); + } + + stop(): void { + this._isPlaying = false; + this.source.pause(); + this.source.close(); + this.source = null; + } + + get progress(): number { + if (!this.source) return 0; + const currentTime = this.source.internalAudio.currentTime; + const progress = currentTime / this._duration; + return Math.max(0, Math.min(100, progress * 100)); + } + + seek(progress: number): void { + if (!this.source) return; + const currentTime = this._duration * (progress / 100); + this.source.internalAudio.currentTime = currentTime; + } +} diff --git a/src/services/OpenAI.ts b/src/services/OpenAI.ts index 3846a05..246d9b6 100644 --- a/src/services/OpenAI.ts +++ b/src/services/OpenAI.ts @@ -1,16 +1,15 @@ import {TTSService} from "./TTSService"; import TTSPlugin from "../main"; import {requestUrl} from "obsidian"; +import AudioPlayer from "./AudioPlayer"; -export class OpenAI implements TTSService { +export class OpenAI extends AudioPlayer implements TTSService { plugin: TTSPlugin; id = "openai"; name = "OpenAI"; - source: AudioBufferSourceNode; - currentTime = 0; - constructor(plugin: TTSPlugin) { + super(plugin); this.plugin = plugin; } @@ -56,13 +55,13 @@ export class OpenAI implements TTSService { } isPaused(): boolean { - if(!this.source) return true; - return this.source.context.state === "suspended"; + if (!this.soundtouch) return false; + return this.paused; } isSpeaking(): boolean { - if(!this.source) return false; - return this.source.context.state === "running"; + if (!this.soundtouch) return false; + return this.soundtouch.playing; } isValid(): boolean { @@ -70,12 +69,11 @@ export class OpenAI implements TTSService { } pause(): void { - this.currentTime = this.source.context.currentTime; - this.source.stop(); + super.pause(); } resume(): void { - this.source.start(this.currentTime); + super.play(); } async sayWithVoice(text: string, voice: string) : Promise { @@ -94,17 +92,20 @@ export class OpenAI implements TTSService { }) }); + await this.setupSoundtouch(audioFile.arrayBuffer); + this.plugin.statusbar.createSpan({text: 'Speaking'}); + } + + stop(): Promise { + return super.stop(); + } - const context = new AudioContext(); - const buffer = await context.decodeAudioData(audioFile.arrayBuffer); - this.source = context.createBufferSource(); - this.source.buffer = buffer; - this.source.connect(context.destination); - this.source.start(); + get progress(): number { + return this.soundtouch.percentagePlayed; } - stop(): void { - this.source.stop(); + seek(position: number): void { + this.soundtouch.percentagePlayed = position; } } diff --git a/src/services/SpeechSynthesis.ts b/src/services/SpeechSynthesis.ts index 32bb1f4..af32671 100644 --- a/src/services/SpeechSynthesis.ts +++ b/src/services/SpeechSynthesis.ts @@ -1,16 +1,32 @@ import {Platform} from "obsidian"; import TTSPlugin from "../main"; import {TTSService} from "./TTSService"; +import { resetStatusbar } from "../utils"; export class SpeechSynthesis implements TTSService { plugin: TTSPlugin; id = 'speechSynthesis'; name = 'Speech Synthesis'; + words: string[] = []; + wordCounter = 0; + voice = ''; + text = ''; constructor(plugin: TTSPlugin) { this.plugin = plugin; } + get progress(): number { + return this.wordCounter / this.words.length * 100; + } + + seek(percent: number): void { + const wordIndex = Math.floor(this.words.length * percent / 100); + const fragment = this.words.slice(wordIndex).join(' '); + this.wordCounter = wordIndex; + this.speak(fragment); + } + stop(): void { if (!this.isSpeaking()) return; window.speechSynthesis.cancel(); @@ -53,15 +69,30 @@ export class SpeechSynthesis implements TTSService { }) } - async sayWithVoice(text: string, voice: string): Promise { + private speak(text: string): void { const msg = new SpeechSynthesisUtterance(); msg.text = text; 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); + msg.voice = window.speechSynthesis.getVoices().filter(otherVoice => otherVoice.name === this.voice)[0]; + msg.onboundary = (event) => { + if (event.name === "word") { + this.wordCounter++; + } + }; + window.speechSynthesis.cancel(); + resetStatusbar(this.plugin.statusbar); this.plugin.statusbar.createSpan({text: 'Speaking'}); + window.speechSynthesis.speak(msg); + } + + async sayWithVoice(text: string, voice: string): Promise { + this.text = text; + this.words = text.split(' '); + this.voice = voice; + this.wordCounter = 0; + this.speak(text); } } diff --git a/src/services/TTSService.ts b/src/services/TTSService.ts index d41f5c1..dfd3e7a 100644 --- a/src/services/TTSService.ts +++ b/src/services/TTSService.ts @@ -8,6 +8,7 @@ export interface TTSService { id: string; name: string; + progress: number; /** * @public @@ -40,6 +41,8 @@ export interface TTSService { getVoices() : Promise; + seek(position: number): void; + /** * @internal * This may not be used, depending on user settings diff --git a/src/settings.ts b/src/settings.ts index e266695..0c81fca 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,55 +1,10 @@ -import {ButtonComponent, PluginSettingTab, Setting} from "obsidian"; +import {ButtonComponent, Notice, PluginSettingTab, Setting} from "obsidian"; import {TextInputPrompt} from "./TextInputPrompt"; import TTSPlugin from "./main"; import {LanguageVoiceModal} from "./LanguageVoiceModal"; +import { ServiceConfigurationModal } from "./ServiceConfigurationModal"; +import {DEFAULT_SETTINGS} from "./constants"; -export interface LanguageVoiceMap { - id: string; - language: string; - voice: string; -} - -export interface TTSSettings { - defaultVoice: string, - pitch: number; - rate: number; - volume: number; - speakLinks: boolean; - speakFrontmatter: boolean; - speakSyntax: boolean; - speakCodeblocks: boolean; - speakTitle: boolean; - speakEmoji: boolean; - speakComments: boolean; - languageVoices: LanguageVoiceMap[]; - stopPlaybackWhenNoteChanges: boolean; - services: { - openai: { - key: string; - } - } -} - -export const DEFAULT_SETTINGS: TTSSettings = { - defaultVoice: "", - pitch: 1, - rate: 1, - volume: 1, - speakLinks: false, - speakFrontmatter: false, - speakSyntax: false, - speakTitle: true, - speakCodeblocks: false, - speakEmoji: false, - speakComments: false, - languageVoices: [], - stopPlaybackWhenNoteChanges: false, - services: { - openai: { - key: '', - } - } -} export class TTSSettingsTab extends PluginSettingTab { plugin: TTSPlugin; @@ -84,12 +39,15 @@ export class TTSSettingsTab extends PluginSettingTab { } for (const voice of voices) { - //dropdown.addOption(`${voice.serviceId}-${voice.id}`, `${voice.serviceName}: ${voice.name}`); - dropdown.addOption(`${voice.serviceId}-${voice.id}`, `${voice.name}`); + if (voice.serviceId === this.plugin.settings.defaultService) { + dropdown.addOption(`${voice.serviceId}-${voice.id}`, `${voice.name}`); + } } dropdown .setValue(this.plugin.settings.defaultVoice) .onChange(async (value) => { + const serviceKey = `${value.split('-')[0]}Voice`; + this.plugin.settings.defaultVoices[serviceKey] = value; this.plugin.settings.defaultVoice = value; await this.plugin.saveSettings(); }); @@ -98,7 +56,7 @@ export class TTSSettingsTab extends PluginSettingTab { .setIcon("play-audio-glyph") .setTooltip("Test voice") .onClick(async () => { - 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"); + 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", "Play", false); await input.openAndGetValue((async value => { if (value.getValue().length === 0) return; await this.plugin.serviceManager.sayWithVoice(value.getValue(), this.plugin.settings.defaultVoice); @@ -108,7 +66,7 @@ export class TTSSettingsTab extends PluginSettingTab { }); }); - /*new Setting(containerEl) + new Setting(containerEl) .setName("Services") .setHeading(); @@ -122,7 +80,43 @@ export class TTSSettingsTab extends PluginSettingTab { .onClick(() => { new ServiceConfigurationModal(this.plugin).open(); }); - });*/ + }); + + new Setting(containerEl).setName('Configured services').setHeading(); + + for (const service of this.plugin.serviceManager.getServices()) { + if (service.isConfigured() && service.isValid() && service.id !== "speechSynthesis") { + const setting = new Setting(containerEl); + setting.setName(service.name); + setting.addExtraButton((b) => { + b.setIcon("pencil") + .setTooltip("Edit") + .onClick(() => { + new ServiceConfigurationModal(this.plugin, service.id).open(); + }); + }); + } + } + + new Setting(containerEl) + .setName("Active service") + .setDesc("Select voice service to use") + .addDropdown(async (dropdown) => { + const services = this.plugin.serviceManager.getServices(); + for (const service of services) { + if (service.isConfigured() && service.isValid()) { + dropdown.addOption(service.id, service.name); + } + } + dropdown + .setValue(this.plugin.settings.defaultService) + .onChange(async (value) => { + this.plugin.settings.defaultService = value; + this.plugin.settings.defaultVoice = this.plugin.settings.defaultVoices[`${value}Voice`]; + await this.plugin.saveSettings(); + this.display(); + }); + }); new Setting(containerEl) .setName("Language specific voices") @@ -366,5 +360,48 @@ export class TTSSettingsTab extends PluginSettingTab { await this.plugin.saveSettings(); }); }); + + new Setting(containerEl) + .setName("Advanced") + .setHeading(); + new Setting(containerEl) + .setName("Regex patterns to ignore when speaking") + .addButton((button: ButtonComponent): ButtonComponent => { + return button + .setTooltip("Edit regex patterns to ignore") + .setIcon("pencil") + .onClick(() => { + new TextInputPrompt(this.app, "Regex patterns to ignore", "This pattern is passed to the RegExp constructor", "", "\\[[\d\s,-]*\\]", "Submit").openAndGetValue(async (value) => { + // check if the value is a valid regex + try { + new RegExp(value.getValue()); + } catch (e) { + new Notice("Invalid regex pattern"); + return; + } + // check if the value is already in the list + if (this.plugin.settings.regexPatternsToIgnore.includes(value.getValue())) { + new Notice("This pattern is already in the list"); + return; + } + this.plugin.settings.regexPatternsToIgnore.push(value.getValue()); + await this.plugin.saveSettings(); + this.display(); + }); + }); + }); + for (const pattern of this.plugin.settings.regexPatternsToIgnore) { + const setting = new Setting(containerEl); + setting.setName(pattern); + setting.addExtraButton((b) => { + b.setIcon("trash") + .setTooltip("Delete") + .onClick(() => { + this.plugin.settings.regexPatternsToIgnore = this.plugin.settings.regexPatternsToIgnore.filter(value => value !== pattern); + this.plugin.saveSettings(); + this.display(); + }); + }); + } } } diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..f85304b --- /dev/null +++ b/src/styles.css @@ -0,0 +1,8 @@ +.seekbar { + border: "solid 1px var(--interactive-accent)"; + border-radius: "8px"; + height: "7px"; + width: "200px"; + outline: "none"; + transition: "background 450ms ease-in"; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..6ed9cf1 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,15 @@ +import { setIcon } from "obsidian"; + +export const cleanText = (text: string, patterns: string[]): string => { + if (!patterns || patterns.length === 0) return text; + let res = text; + for (const pattern of patterns) { + res = res.replace(new RegExp(pattern, "g"), ""); + } + return res; +} + +export const resetStatusbar = (statusbar: HTMLElement): void => { + statusbar.empty(); + setIcon(statusbar, 'audio-file'); +} diff --git a/tsconfig.json b/tsconfig.json index e5c5eeb..3a68827 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "DOM", "ES5", "ES6", - "ES7" + "ES7", + "ES2018" ], "allowSyntheticDefaultImports": true }, diff --git a/versions.json b/versions.json index f52c457..1f6b47e 100644 --- a/versions.json +++ b/versions.json @@ -10,5 +10,6 @@ "0.5.1": "0.12.0", "0.5.2": "0.12.0", "0.5.3": "1.4.0", - "0.5.4": "1.4.0" + "0.5.4": "1.4.0", + "0.5.5": "1.4.0" }