diff --git a/src/ServiceConfigurationModal.ts b/src/ServiceConfigurationModal.ts new file mode 100644 index 0000000..f0aa862 --- /dev/null +++ b/src/ServiceConfigurationModal.ts @@ -0,0 +1,59 @@ +import {Modal, Setting} from "obsidian"; +import TTSPlugin from "./main"; + +export class ServiceConfigurationModal extends Modal { + plugin: TTSPlugin; + + constructor(plugin: TTSPlugin) { + super(plugin.app); + this.plugin = plugin; + } + + display(service?: string): void { + + const { contentEl } = this; + + contentEl.empty(); + + new Setting(contentEl) + .setName('Service') + .setDesc('test') + .addDropdown((dropdown) => { + dropdown.addOption('openai', 'OpenAI'); + dropdown.addOption('microsoft', 'Microsoft Azure'); + + dropdown.setValue(service); + + dropdown.onChange(async(value) => { + this.display(value); + }) + }); + + if (service === 'openai') { + new Setting(contentEl) + .setName('API Key') + .setDesc('API key for OpenAI') + .addText(async text => { + text + .setValue(this.plugin.settings.services.openai.key) + .onChange(async value => { + this.plugin.settings.services.openai.key = value; + await this.plugin.saveSettings(); + }); + }) + ; + + + } + + } + + onOpen(): void { + //@ts-ignore + this.setTitle('Add new service'); + this.display(); + + } + + +} diff --git a/src/TTSService.ts b/src/TTSService.ts deleted file mode 100644 index 5802a49..0000000 --- a/src/TTSService.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {MarkdownView} from "obsidian"; - -export interface TTSService { - - /** - * @public - */ - stop(): void; - - /** - * @public - */ - pause(): void; - - /** - * @public - */ - resume(): void; - - /** - * @public - */ - isSpeaking(): boolean; - - /** - * @public - */ - isPaused(): boolean; - - /** - * @internal - * get the name of the voice configured for this language - * if there is no voice configured this returns the default - * @param languageCode {@link https://www.loc.gov/standards/iso639-2/php/English_list.php ISO 639-1 code} - */ - getVoice(languageCode: string) : string; - - /** - * @internal - * @param title First thing to be spoken, with a pause before the text. - * This may not be used, depending on user settings - * @param text Some text will be removed according to user settings, before playback starts. - * @param voice if there is no voice configured with this name the default voice, according to user settings, will be used. - */ - sayWithVoice(title: string, text: string, voice: string): Promise; - - /** - * - * @public - * @param title First thing to be spoken, with a pause before the text. - * This may not be used, depending on user settings - * @param text Some text will be removed according to user settings, before playback starts. - * @param languageCode {@link https://www.loc.gov/standards/iso639-2/php/English_list.php ISO 639-1 code} - */ - say(title: string, text: string, languageCode?: string): Promise; - - /** - * Use the content of the selected view as source. - * @internal - * @param view - */ - play(view: MarkdownView, cursorPos?: number): void; - -} diff --git a/src/TTSServiceImplementation.ts b/src/TTSServiceImplementation.ts deleted file mode 100644 index afeceed..0000000 --- a/src/TTSServiceImplementation.ts +++ /dev/null @@ -1,156 +0,0 @@ -import {MarkdownView, Notice, parseYaml, setIcon, TFile} from "obsidian"; -import {LanguageVoiceMap} from "./settings"; -import TTSPlugin from "./main"; -import {detect} from "tinyld"; -import {TTSService} from "./TTSService"; - -export class TTSServiceImplementation implements 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, ""); - content = content.replace(/\^/g, ""); - content = content.replace(/==/g, ""); - - //block references - content = content.replace(/^\S{6}/g, ""); - } - if (!this.plugin.settings.speakLinks) { - //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.plugin.settings.speakCodeblocks) { - content = content.replace(/```[\s\S]*?```/g, ''); - } - - if (!this.plugin.settings.speakComments) { - content = content.replace(/%[\s\S]*?%/g, ''); - content = content.replace(//g, ''); - } - - if (!this.plugin.settings.speakEmoji) { - //regex from https://ihateregex.io/expr/emoji/ - content = content.replace(/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g, ''); - } - - //add pauses, taken from https://stackoverflow.com/a/50944593/5589264 - content = content.replace(/\n/g, " ! "); - - //only speak link aliases. - content = content.replace(/\[\[(.*\|)(.*)]]/gm, '$2'); - - if (this.plugin.settings.speakTitle && title?.length > 0) { - content = title + " ! ! " + content; - } - - - 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.createSpan({text: '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, cusorPos?: number): Promise { - const isPreview = view.getMode() === "preview"; - - const previewText = view.previewMode.containerEl.innerText; - - - 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; - } - //@ts-ignore - const hasInlineTitle = view.app.vault.getConfig("showInlineTitle"); - const title = selectedText.length > 0 ? null : (hasInlineTitle ? null : view.getDisplayText()); - let language = this.getLanguageFromFrontmatter(view); - if (language === "") { - language = detect(content); - } - - if (!this.plugin.settings.speakFrontmatter) { - if (!isPreview) { - content = content.replace("---", ""); - content = content.substring(content.indexOf("---") + 1); - } - } - await this.say(title, content, language); - - } - - getLanguageFromFrontmatter(view: MarkdownView): string { - view. - let language = ""; - //check if any language is defined in frontmatter - if (!view.getViewData().startsWith("---")) return language; - - 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 0017aeb..5ee5930 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,10 +3,11 @@ import { MarkdownView, Menu, Notice, Platform, Plugin, setIcon, TFile } from 'obsidian'; -import {DEFAULT_SETTINGS, TTSSettings, TTSSettingsTab} from "./settings"; -import {TTSServiceImplementation} from "./TTSServiceImplementation"; +import {DEFAULT_SETTINGS, LanguageVoiceMap, TTSSettings, TTSSettingsTab} from "./settings"; +import {SpeechSynthesis} from "./services/SpeechSynthesis"; import {registerAPI} from "@vanakat/plugin-api"; -import {TTSService} from "./TTSService"; +import {TTSService} from "./services/TTSService"; +import {detect} from "tinyld"; export default class TTSPlugin extends Plugin { @@ -14,11 +15,15 @@ export default class TTSPlugin extends Plugin { settings: TTSSettings; statusbar: HTMLElement; + services: TTSService[] = []; + async onload(): Promise { // from https://github.com/phosphor-icons/core addIcon('tts-play-pause', ''); - this.ttsService = new TTSServiceImplementation(this); + this.services.push(new SpeechSynthesis(this)); + //this.services.push(new OpenAI(this)); + this.ttsService = this.services[0]; console.log("loading tts plugin"); @@ -37,7 +42,7 @@ export default class TTSPlugin extends Plugin { checkCallback: (checking: boolean) => { const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!checking && markdownView) - this.ttsService.play(markdownView); + this.play(markdownView); return !!markdownView; } }); @@ -77,7 +82,7 @@ export default class TTSPlugin extends Plugin { }); this.addCommand({ - id: 'play-pause', + id: 'start-pause-resume-tts-playback', name: 'Play/Pause', icon: 'tts-play-pause', checkCallback: (checking) => { @@ -88,7 +93,7 @@ export default class TTSPlugin extends Plugin { } else if (this.ttsService.isSpeaking()) { this.ttsService.pause(); } else { - this.ttsService.play(markdownView); + this.play(markdownView); } } return !!markdownView; @@ -99,7 +104,7 @@ export default class TTSPlugin extends Plugin { this.addCommand({ id: 'cursor', name: 'after cursor', - editorCallback: (editor, view) => { + editorCallback: (editor, _) => { console.log(editor.getRange(editor.getCursor("from"), {line: editor.lastLine(), ch: editor.getLine(editor.lastLine()).length})); } }) @@ -122,7 +127,7 @@ export default class TTSPlugin extends Plugin { .setTitle(activeWindow.getSelection().toString().length > 0 ? "Read selected text" : "Read the note") .setIcon("audio-file") .onClick(() => { - this.ttsService.play(markdownView); + this.play(markdownView); }); }); }))); @@ -134,7 +139,7 @@ export default class TTSPlugin extends Plugin { .setIcon("audio-file") .onClick(async () => { const content = await this.app.vault.cachedRead(file); - await this.ttsService.say(file.name, content); + await this.say(file.name, content); }); }); } @@ -171,11 +176,11 @@ export default class TTSPlugin extends Plugin { .setIcon("play-audio-glyph") .setTitle("Add to playback queue") .onClick((async () => { - this.ttsService.play(markdownView); + await this.play(markdownView); })); }); } else { - this.ttsService.play(markdownView); + await this.play(markdownView); return; } } @@ -222,9 +227,129 @@ export default class TTSPlugin extends Plugin { async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + + //migration + let migrate = false; + if (!this.settings.defaultVoice.includes('-')) { + 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; + migrate = true; + } + } + if (migrate) { + await this.saveSettings(); + } } async saveSettings(): Promise { await this.saveData(this.settings); } + + getVoice(languageCode: string): string { + const filtered = this.settings.languageVoices.filter((lang: LanguageVoiceMap) => lang.language === languageCode); + if (filtered.length === 0) return null; + return filtered[0].voice; + } + + async say(text: string, languageCode?: string): Promise { + let usedVoice = this.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."); + } + } + const split = usedVoice.split(/-(.*)/s); + const service = this.services.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"); + return; + } + + await service.sayWithVoice(text, split[1]); + } + + prepareText(title: string, text: string) { + let content = text; + if (!this.settings.speakSyntax) { + content = content.replace(/#/g, ""); + content = content.replace(/-/g, ""); + content = content.replace(/_/g, ""); + content = content.replace(/\*/g, ""); + content = content.replace(/\^/g, ""); + content = content.replace(/==/g, ""); + + //block references + content = content.replace(/^\S{6}/g, ""); + } + if (!this.settings.speakLinks) { + //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, ''); + } + + if (!this.settings.speakComments) { + content = content.replace(/%[\s\S]*?%/g, ''); + content = content.replace(//g, ''); + } + + if (!this.settings.speakEmoji) { + //regex from https://ihateregex.io/expr/emoji/ + content = content.replace(/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g, ''); + } + + //add pauses, taken from https://stackoverflow.com/a/50944593/5589264 + content = content.replace(/\n/g, " ! "); + + //only speak link aliases. + content = content.replace(/\[\[(.*\|)(.*)]]/gm, '$2'); + + if (this.settings.speakTitle && title?.length > 0) { + content = title + " ! ! " + content; + } + + return content; + } + + async play(view: MarkdownView): Promise { + const isPreview = view.getMode() === "preview"; + + const previewText = view.previewMode.containerEl.innerText; + + + 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); + if (language === "") { + language = detect(content); + } + + if (!this.settings.speakFrontmatter) { + if (!isPreview) { + content = content.replace("---", ""); + content = content.substring(content.indexOf("---") + 1); + } + } + await this.say(content, language); + + } + + getLanguageFromFrontmatter(view: MarkdownView): string { + if(view.file) { + return this.app.metadataCache.getFileCache(view.file).frontmatter?.lang; + } + } } diff --git a/src/services/OpenAI.ts b/src/services/OpenAI.ts new file mode 100644 index 0000000..a12d2d8 --- /dev/null +++ b/src/services/OpenAI.ts @@ -0,0 +1,108 @@ +import {TTSService} from "./TTSService"; +import TTSPlugin from "../main"; +import {requestUrl} from "obsidian"; + +export class OpenAI implements TTSService { + plugin: TTSPlugin; + id = "openai"; + name = "OpenAI"; + + source: AudioBufferSourceNode; + currentTime = 0; + + 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: 'echo', + name: 'Echo', + languages: this.languages, + }, + { + id: 'fable', + name: 'Fable', + languages: this.languages, + }, + { + id: 'onyx', + name: 'Onyx', + languages: this.languages, + }, + { + id: 'nova', + name: 'Nova', + languages: this.languages, + }, + { + id: 'shimmer', + name: 'Shimmer', + languages: this.languages, + } + ]; + } + + isConfigured(): boolean { + return this.plugin.settings.services.openai.key.length > 0; + } + + isPaused(): boolean { + return this.source.context.state === "suspended"; + } + + isSpeaking(): boolean { + return this.source.context.state === "running"; + } + + isValid(): boolean { + return this.plugin.settings.services.openai.key.startsWith('sk-'); + } + + pause(): void { + this.currentTime = this.source.context.currentTime; + this.source.stop(); + } + + resume(): void { + this.source.start(this.currentTime); + } + + async sayWithVoice(text: string, voice: string) : Promise { + + const audioFile = await requestUrl({ + url: 'https://api.openai.com/v1/audio/speech', + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + this.plugin.settings.services.openai.key, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + 'model': 'tts-1', + 'input': text, + 'voice': voice, + }) + }); + + + 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(); + } + + stop(): void { + this.source.stop(); + } + +} diff --git a/src/services/SpeechSynthesis.ts b/src/services/SpeechSynthesis.ts new file mode 100644 index 0000000..32bb1f4 --- /dev/null +++ b/src/services/SpeechSynthesis.ts @@ -0,0 +1,67 @@ +import {Platform} from "obsidian"; +import TTSPlugin from "../main"; +import {TTSService} from "./TTSService"; + +export class SpeechSynthesis implements TTSService { + plugin: TTSPlugin; + id = 'speechSynthesis'; + name = 'Speech Synthesis'; + + 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; + } + + isConfigured(): boolean { + return true; + } + + isValid(): boolean { + return !Platform.isAndroidApp; + } + + async getVoices(): Promise<{id: string, name: string, languages: string[]}[]> { + const voices = window.speechSynthesis.getVoices(); + return voices.map(voice => { + return { + id: voice.voiceURI, + name: voice.name, + languages: [voice.lang] + }; + }) + } + + async sayWithVoice(text: string, voice: string): Promise { + 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); + this.plugin.statusbar.createSpan({text: 'Speaking'}); + } + +} diff --git a/src/services/TTSService.ts b/src/services/TTSService.ts new file mode 100644 index 0000000..2928542 --- /dev/null +++ b/src/services/TTSService.ts @@ -0,0 +1,46 @@ +export interface TTSService { + + id: string; + name: string; + + /** + * @public + */ + stop(): void; + + /** + * @public + */ + pause(): void; + + /** + * @public + */ + resume(): void; + + /** + * @public + */ + isSpeaking(): boolean; + + /** + * @public + */ + isPaused(): boolean; + + isConfigured(): boolean; + + isValid(): boolean; + + getVoices() : Promise<{id: string, name: string, languages: string[]}[]>; + + /** + * @internal + * This may not be used, depending on user settings + * @param text Some text will be removed according to user settings, before playback starts. + * @param voice if there is no voice configured with this name the default voice, according to user settings, will be used. + */ + sayWithVoice(text: string, voice: string): Promise; + + +} diff --git a/src/settings.ts b/src/settings.ts index 0b61e64..811a6f9 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -2,6 +2,7 @@ import {ButtonComponent, PluginSettingTab, Setting} from "obsidian"; import {TextInputPrompt} from "./TextInputPrompt"; import TTSPlugin from "./main"; import {LanguageVoiceModal} from "./LanguageVoiceModal"; +import {ServiceConfigurationModal} from "./ServiceConfigurationModal"; export interface LanguageVoiceMap { language: string; @@ -22,6 +23,11 @@ export interface TTSSettings { speakComments: boolean; languageVoices: LanguageVoiceMap[]; stopPlaybackWhenNoteChanges: boolean; + services: { + openai: { + key: string; + } + } } export const DEFAULT_SETTINGS: TTSSettings = { @@ -38,6 +44,11 @@ export const DEFAULT_SETTINGS: TTSSettings = { speakComments: false, languageVoices: [], stopPlaybackWhenNoteChanges: false, + services: { + openai: { + key: '', + } + } } export class TTSSettingsTab extends PluginSettingTab { @@ -53,14 +64,28 @@ export class TTSSettingsTab extends PluginSettingTab { containerEl.empty(); - containerEl.createEl('h2', {text: 'Text to Speech - Settings'}); - new Setting(containerEl) .setName("Default voice") .addDropdown(async (dropdown) => { - const voices = window.speechSynthesis.getVoices(); + const voices = []; + const services = this.plugin.services; + for (const service of services) { + if (service.isConfigured() && service.isValid()) { + for (const voice of await service.getVoices()) { + voices.push({ + serviceId: service.id, + serviceName: service.name, + id: voice.id, + name: voice.name, + languages: voice.languages + }); + } + } + } + for (const voice of voices) { - dropdown.addOption(voice.name, voice.name); + //dropdown.addOption(`${voice.serviceId}-${voice.id}`, `${voice.serviceName}: ${voice.name}`); + dropdown.addOption(`${voice.serviceId}-${voice.id}`, `${voice.name}`); } dropdown .setValue(this.plugin.settings.defaultVoice) @@ -76,22 +101,40 @@ 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.ttsService.say('', value.getValue()); + await this.plugin.say('', value.getValue()); })); }); }); - containerEl.createEl("h3", {text: "Language specific voices"}); + /*new Setting(containerEl) + .setName("Services") + .setHeading(); + + new Setting(containerEl) + .setName("New service") + .setDesc("Configure new service") + .addButton((button: ButtonComponent): ButtonComponent => { + return button + .setTooltip("Configure new service") + .setIcon("plus") + .onClick(() => { + new ServiceConfigurationModal(this.plugin).open(); + }); + });*/ + + new Setting(containerEl) + .setName("Language specific voices") + .setHeading(); new Setting(containerEl) - .setName("Add New") + .setName("Add new") .setDesc("Add a new language specific voice") .addButton((button: ButtonComponent): ButtonComponent => { return button .setTooltip("add new language specific voice") - .setIcon("create-new") + .setIcon("plus") .onClick(async () => { const modal = new LanguageVoiceModal(this.plugin); @@ -157,7 +200,9 @@ export class TTSSettingsTab extends PluginSettingTab { } - containerEl.createEl("h3", {text: "Audio settings"}); + new Setting(containerEl) + .setName('Audio') + .setHeading(); new Setting(containerEl) .setName("Volume") @@ -226,7 +271,9 @@ export class TTSSettingsTab extends PluginSettingTab { }); }); - containerEl.createEl('h3', {text: 'Speak'}); + new Setting(containerEl) + .setName("Speak") + .setHeading(); new Setting(containerEl) .setName("Title") @@ -305,7 +352,9 @@ export class TTSSettingsTab extends PluginSettingTab { }); }); - containerEl.createEl("h2", {text: "Misc"}); + new Setting(containerEl) + .setName('Misc') + .setHeading(); new Setting(containerEl) .setName("Stop playback when a note is closed/new note is opened") .addToggle(async (toggle) => {