mirror of
https://github.com/curtgrimes/obsidian-metronome-plugin.git
synced 2026-07-22 06:10:24 +00:00
Add initial metronome work
This commit is contained in:
parent
229a42929c
commit
0fb41be7ea
16 changed files with 553 additions and 149 deletions
3
.stylelintrc.json
Normal file
3
.stylelintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "stylelint-config-standard"
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from 'builtin-modules'
|
||||
import builtins from 'builtin-modules';
|
||||
import vuePlugin from 'esbuild-plugin-vue3';
|
||||
import svgPlugin from 'esbuild-plugin-svg';
|
||||
|
||||
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
|
||||
*/
|
||||
|
|
@ -15,9 +17,10 @@ esbuild.build({
|
|||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ['main.ts'],
|
||||
entryPoints: ['src/main.ts'],
|
||||
bundle: true,
|
||||
external: ['obsidian', 'electron', ...builtins],
|
||||
plugins: [vuePlugin(), svgPlugin()],
|
||||
format: 'cjs',
|
||||
watch: !prod,
|
||||
target: 'es2016',
|
||||
|
|
|
|||
5
main.css
Normal file
5
main.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/* sfc-style:/Users/curt/obsidian/Primary Vault/.obsidian/plugins/obsidian-metronome-plugin/src/components/Metronome.vue?type=style&index=0 */
|
||||
.example {
|
||||
color: red;
|
||||
}
|
||||
/*# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsic2ZjLXN0eWxlOi9Vc2Vycy9jdXJ0L29ic2lkaWFuL1ByaW1hcnkgVmF1bHQvLm9ic2lkaWFuL3BsdWdpbnMvb2JzaWRpYW4tbWV0cm9ub21lLXBsdWdpbi9zcmMvY29tcG9uZW50cy9NZXRyb25vbWUudnVlP3R5cGU9c3R5bGUmaW5kZXg9MCJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiXG4uZXhhbXBsZSB7XG4gIGNvbG9yOiByZWQ7XG59XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQ0E7QUFDRTtBQUFBOyIsCiAgIm5hbWVzIjogW10KfQo= */
|
||||
137
main.ts
137
main.ts
|
|
@ -1,137 +0,0 @@
|
|||
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
|
||||
|
||||
// Remember to rename these classes and interfaces!
|
||||
|
||||
interface MyPluginSettings {
|
||||
mySetting: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: MyPluginSettings = {
|
||||
mySetting: 'default'
|
||||
}
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MyPluginSettings;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// This creates an icon in the left ribbon.
|
||||
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
|
||||
// Called when the user clicks the icon.
|
||||
new Notice('This is a notice!');
|
||||
});
|
||||
// Perform additional things with the ribbon
|
||||
ribbonIconEl.addClass('my-plugin-ribbon-class');
|
||||
|
||||
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
||||
const statusBarItemEl = this.addStatusBarItem();
|
||||
statusBarItemEl.setText('Status Bar Text');
|
||||
|
||||
// This adds a simple command that can be triggered anywhere
|
||||
this.addCommand({
|
||||
id: 'open-sample-modal-simple',
|
||||
name: 'Open sample modal (simple)',
|
||||
callback: () => {
|
||||
new SampleModal(this.app).open();
|
||||
}
|
||||
});
|
||||
// This adds an editor command that can perform some operation on the current editor instance
|
||||
this.addCommand({
|
||||
id: 'sample-editor-command',
|
||||
name: 'Sample editor command',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
console.log(editor.getSelection());
|
||||
editor.replaceSelection('Sample Editor Command');
|
||||
}
|
||||
});
|
||||
// This adds a complex command that can check whether the current state of the app allows execution of the command
|
||||
this.addCommand({
|
||||
id: 'open-sample-modal-complex',
|
||||
name: 'Open sample modal (complex)',
|
||||
checkCallback: (checking: boolean) => {
|
||||
// Conditions to check
|
||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (markdownView) {
|
||||
// If checking is true, we're simply "checking" if the command can be run.
|
||||
// If checking is false, then we want to actually perform the operation.
|
||||
if (!checking) {
|
||||
new SampleModal(this.app).open();
|
||||
}
|
||||
|
||||
// This command will only show up in Command Palette when the check function returns true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||
this.addSettingTab(new SampleSettingTab(this.app, this));
|
||||
|
||||
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
||||
// Using this function will automatically remove the event listener when this plugin is disabled.
|
||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
||||
console.log('click', evt);
|
||||
});
|
||||
|
||||
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
||||
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
||||
}
|
||||
|
||||
onunload() {
|
||||
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const {contentEl} = this;
|
||||
contentEl.setText('Woah!');
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const {contentEl} = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
class SampleSettingTab extends PluginSettingTab {
|
||||
plugin: MyPlugin;
|
||||
|
||||
constructor(app: App, plugin: MyPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const {containerEl} = this;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Setting #1')
|
||||
.setDesc('It\'s a secret')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter your secret')
|
||||
.setValue(this.plugin.settings.mySetting)
|
||||
.onChange(async (value) => {
|
||||
console.log('Secret: ' + value);
|
||||
this.plugin.settings.mySetting = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"id": "obsidian-sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"version": "1.0.1",
|
||||
"name": "Metronome",
|
||||
"version": "0.1",
|
||||
"minAppVersion": "0.12.0",
|
||||
"description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.",
|
||||
"author": "Obsidian",
|
||||
"authorUrl": "https://obsidian.md",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
}
|
||||
13
package.json
13
package.json
|
|
@ -14,10 +14,17 @@
|
|||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.2.0",
|
||||
"@typescript-eslint/parser": "^5.2.0",
|
||||
"@vapurrmaid/bpm": "^0.1.8",
|
||||
"builtin-modules": "^3.2.0",
|
||||
"esbuild": "0.13.12",
|
||||
"esbuild": "^0.13.12",
|
||||
"esbuild-plugin-svg": "^0.1.0",
|
||||
"esbuild-plugin-vue3": "^0.3.0",
|
||||
"obsidian": "^0.12.17",
|
||||
"stylelint": "^14.1.0",
|
||||
"stylelint-config-standard": "^24.0.0",
|
||||
"tone": "^14.8.32",
|
||||
"tslib": "2.3.1",
|
||||
"typescript": "4.4.4"
|
||||
"typescript": "4.4.4",
|
||||
"vue": "^3.2.26"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/assets/icons/volume-mute.svg
Normal file
3
src/assets/icons/volume-mute.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-volume-mute" viewBox="0 0 16 16">
|
||||
<path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zM6 5.04 4.312 6.39A.5.5 0 0 1 4 6.5H2v3h2a.5.5 0 0 1 .312.11L6 10.96V5.04zm7.854.606a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 558 B |
5
src/assets/icons/volume-up.svg
Normal file
5
src/assets/icons/volume-up.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-volume-up" viewBox="0 0 16 16">
|
||||
<path d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z"/>
|
||||
<path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z"/>
|
||||
<path d="M10.025 8a4.486 4.486 0 0 1-1.318 3.182L8 10.475A3.489 3.489 0 0 0 9.025 8c0-.966-.392-1.841-1.025-2.475l.707-.707A4.486 4.486 0 0 1 10.025 8zM7 4a.5.5 0 0 0-.812-.39L3.825 5.5H1.5A.5.5 0 0 0 1 6v4a.5.5 0 0 0 .5.5h2.325l2.363 1.89A.5.5 0 0 0 7 12V4zM4.312 6.39 6 5.04v5.92L4.312 9.61A.5.5 0 0 0 4 9.5H2v-3h2a.5.5 0 0 0 .312-.11z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
58
src/components/Metronome.vue
Normal file
58
src/components/Metronome.vue
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<script setup lang="ts">
|
||||
import { MetronomeCodeBlockParameters } from "../main";
|
||||
import SoundToggle from "./SoundToggle.vue";
|
||||
import { computed, ref, watch, nextTick, toRefs } from "vue";
|
||||
import { BPM, NoteDuration } from "@vapurrmaid/bpm";
|
||||
import {
|
||||
tick as playTick,
|
||||
tickUpbeat as playTickUpbeat,
|
||||
tock as playTock,
|
||||
} from "../sounds";
|
||||
import { useTick } from "../hooks/useTick";
|
||||
|
||||
const props = defineProps<{
|
||||
bpm: MetronomeCodeBlockParameters["bpm"];
|
||||
sound: MetronomeCodeBlockParameters["sound"];
|
||||
meter: MetronomeCodeBlockParameters["meter"];
|
||||
size: MetronomeCodeBlockParameters["size"];
|
||||
}>();
|
||||
|
||||
const soundOn = ref(props.sound);
|
||||
const tickColor = ref("");
|
||||
const { meter } = toRefs(props);
|
||||
const { doBeat, onTick, onTickAlternate, onTock, resetTick } = useTick(meter);
|
||||
const metronomeDurationSeconds = computed(() =>
|
||||
new BPM(props.bpm).durationFor(
|
||||
(props.meter && props.meter.noteDuration()) || "quarter"
|
||||
)
|
||||
);
|
||||
|
||||
// Do sounds
|
||||
onTick(() => soundOn.value && playTick());
|
||||
onTickAlternate(() => soundOn.value && playTickUpbeat());
|
||||
onTock(() => soundOn.value && playTock());
|
||||
|
||||
// Do visuals
|
||||
onTick(() => (tickColor.value = "rgba(168, 8, 8, 1)"));
|
||||
onTock(() => (tickColor.value = "rgba(100, 100, 100, .55)"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'metronome-metronome',
|
||||
]"
|
||||
:style="{
|
||||
'--tick-color': tickColor,
|
||||
'--metronome-duration': `${metronomeDurationSeconds}s`
|
||||
}"
|
||||
:data-size="props.size"
|
||||
@animationiteration="doBeat"
|
||||
>
|
||||
<span class="metronome-description">{{bpm}} BPM {{meter && '·'}} {{meter}}</span>
|
||||
<SoundToggle
|
||||
v-model="soundOn"
|
||||
@soundOn="resetTick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
39
src/components/SoundToggle.vue
Normal file
39
src/components/SoundToggle.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup>
|
||||
import { watch } from "vue";
|
||||
import VolumeUpIcon from "../assets/icons/volume-up.svg";
|
||||
import VolumeMuteIcon from "../assets/icons/volume-mute.svg";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "soundOn", "soundOff"]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(modelValue) => {
|
||||
if (modelValue) {
|
||||
emit("soundOn");
|
||||
} else {
|
||||
emit("soundOff");
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="['sound-toggle', {'enabled': props.modelValue}]"
|
||||
@click="emit('update:modelValue', !props.modelValue)"
|
||||
title="Toggle sound"
|
||||
>
|
||||
<div
|
||||
v-if="props.modelValue"
|
||||
v-html="VolumeUpIcon"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
v-html="VolumeMuteIcon"
|
||||
></div>
|
||||
</button>
|
||||
</template>
|
||||
86
src/hooks/useTick.ts
Normal file
86
src/hooks/useTick.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { computed, Ref, ref, watch } from "vue";
|
||||
import { Meter } from "../models/Meter";
|
||||
|
||||
export function useTick(meter: Ref<Meter>) {
|
||||
const beatCount = ref(-1);
|
||||
const doBeat = () => beatCount.value++;
|
||||
const resetAfterNextBeat = ref(false);
|
||||
|
||||
const resetTick = () => (resetAfterNextBeat.value = true);
|
||||
|
||||
const currentBeatIsTick = computed(
|
||||
() =>
|
||||
// Every beat is a tick if we don't have a valid meter
|
||||
!meter.value ||
|
||||
!meter.value.isValid() ||
|
||||
beatCount.value % meter.value.upper === 0
|
||||
);
|
||||
|
||||
const currentBeatIsTickAlternate = computed(
|
||||
() =>
|
||||
meter.value &&
|
||||
meter.value.isValid() &&
|
||||
meter.value.upper >= 6 &&
|
||||
beatCount.value % (meter.value.upper / 2) === 0
|
||||
);
|
||||
|
||||
const currentBeatIsTock = computed(
|
||||
() => !currentBeatIsTick.value && !currentBeatIsTickAlternate.value
|
||||
);
|
||||
|
||||
const onTickCallbacks: CallableFunction[] = [];
|
||||
const onTickAlternateCallbacks: CallableFunction[] = [];
|
||||
const onTockCallbacks: CallableFunction[] = [];
|
||||
|
||||
const onTick = (callback: CallableFunction) => {
|
||||
onTickCallbacks.push(callback);
|
||||
};
|
||||
|
||||
const onTickAlternate = (callback: CallableFunction) =>
|
||||
onTickAlternateCallbacks.push(callback);
|
||||
|
||||
const onTock = (callback: CallableFunction) =>
|
||||
onTockCallbacks.push(callback);
|
||||
|
||||
watch(beatCount, () => {
|
||||
if (resetAfterNextBeat.value) {
|
||||
beatCount.value = 0;
|
||||
resetAfterNextBeat.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch([currentBeatIsTick, beatCount], ([currentBeatIsTick]) => {
|
||||
if (resetAfterNextBeat.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBeatIsTick) {
|
||||
onTickCallbacks.forEach((callback) => callback());
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[currentBeatIsTickAlternate, beatCount],
|
||||
([currentBeatIsTickAlternate]) => {
|
||||
if (resetAfterNextBeat.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBeatIsTickAlternate) {
|
||||
onTickAlternateCallbacks.forEach((callback) => callback());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch([currentBeatIsTock, beatCount], ([currentBeatIsTock]) => {
|
||||
if (resetAfterNextBeat.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBeatIsTock) {
|
||||
onTockCallbacks.forEach((callback) => callback());
|
||||
}
|
||||
});
|
||||
|
||||
return { doBeat, onTick, onTickAlternate, onTock, resetTick };
|
||||
}
|
||||
170
src/main.ts
Normal file
170
src/main.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { MarkdownPostProcessor, MarkdownRenderChild, Plugin } from "obsidian";
|
||||
import Metronome from "./components/Metronome.vue";
|
||||
import { createApp } from "vue";
|
||||
import { Meter } from "./models/Meter";
|
||||
import { MetronomeSize, isMetronomeSize } from "./models/MetronomeSize";
|
||||
|
||||
interface MetronomePluginSettings {
|
||||
mySetting: string;
|
||||
}
|
||||
|
||||
export interface MetronomeCodeBlockParameters {
|
||||
bpm: number;
|
||||
sound: boolean;
|
||||
meter?: Meter;
|
||||
size: MetronomeSize;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: MetronomePluginSettings = {
|
||||
mySetting: "default",
|
||||
};
|
||||
|
||||
export default class MetronomePlugin extends Plugin {
|
||||
settings: MetronomePluginSettings;
|
||||
processors: MarkdownPostProcessor[];
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
this.registerMarkdownCodeBlockProcessor(
|
||||
`metronome`,
|
||||
(src, el, context) => {
|
||||
const parameters = this.getCodeBlockParameters(src);
|
||||
|
||||
const div = document.createElement("div");
|
||||
|
||||
createApp(Metronome, {
|
||||
...parameters,
|
||||
}).mount(div);
|
||||
|
||||
const child = new MarkdownRenderChild(div);
|
||||
context.addChild(child);
|
||||
el.append(div);
|
||||
}
|
||||
);
|
||||
|
||||
// console.log("processor", processor);
|
||||
|
||||
// // This adds an editor command that can perform some operation on the current editor instance
|
||||
// this.addCommand({
|
||||
// id: "add-metronome",
|
||||
// name: "Add metronome",
|
||||
// editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
// editor.replaceSelection("\n```metronome\nasdf```");
|
||||
// },
|
||||
// });
|
||||
|
||||
// // This adds a complex command that can check whether the current state of the app allows execution of the command
|
||||
// this.addCommand({
|
||||
// id: "open-sample-modal-complex",
|
||||
// name: "Open sample modal (complex)",
|
||||
// checkCallback: (checking: boolean) => {
|
||||
// // Conditions to check
|
||||
// const markdownView =
|
||||
// this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
// if (markdownView) {
|
||||
// // If checking is true, we're simply "checking" if the command can be run.
|
||||
// // If checking is false, then we want to actually perform the operation.
|
||||
// if (!checking) {
|
||||
// new SampleModal(this.app).open();
|
||||
// }
|
||||
|
||||
// // This command will only show up in Command Palette when the check function returns true
|
||||
// return true;
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
|
||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||
// this.addSettingTab(new SampleSettingTab(this.app, this));
|
||||
|
||||
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
||||
// Using this function will automatically remove the event listener when this plugin is disabled.
|
||||
// this.registerDomEvent(document, "click", (evt: MouseEvent) => {
|
||||
// console.log("click", evt);
|
||||
// });
|
||||
|
||||
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
||||
// this.registerInterval(
|
||||
// window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000)
|
||||
// );
|
||||
}
|
||||
|
||||
onunload() {}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData()
|
||||
);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
getCodeBlockParameters(src: string): MetronomeCodeBlockParameters {
|
||||
const values: { [key: string]: any } = {};
|
||||
|
||||
src.split("\n")
|
||||
.map((line: string) => line.split(":"))
|
||||
.forEach(([key, value]: [string, string]) => {
|
||||
values[key] = (value || "").trim();
|
||||
});
|
||||
|
||||
return {
|
||||
bpm: parseFloat(values.bpm) || null,
|
||||
sound: values.sound === "yes",
|
||||
meter: values.meter ? Meter.fromString(values.meter) : null,
|
||||
size: isMetronomeSize(values.size) ? values.size : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// class SampleModal extends Modal {
|
||||
// constructor(app: App) {
|
||||
// super(app);
|
||||
// }
|
||||
|
||||
// onOpen() {
|
||||
// const { contentEl } = this;
|
||||
// contentEl.setText("Woah!");
|
||||
// }
|
||||
|
||||
// onClose() {
|
||||
// const { contentEl } = this;
|
||||
// contentEl.empty();
|
||||
// }
|
||||
// }
|
||||
|
||||
// class SampleSettingTab extends PluginSettingTab {
|
||||
// plugin: MetronomePlugin;
|
||||
|
||||
// constructor(app: App, plugin: MetronomePlugin) {
|
||||
// super(app, plugin);
|
||||
// this.plugin = plugin;
|
||||
// }
|
||||
|
||||
// display(): void {
|
||||
// const { containerEl } = this;
|
||||
|
||||
// containerEl.empty();
|
||||
|
||||
// containerEl.createEl("h2", { text: "Settings for my awesome plugin." });
|
||||
|
||||
// new Setting(containerEl)
|
||||
// .setName("Setting #1")
|
||||
// .setDesc("It's a secret")
|
||||
// .addText((text) =>
|
||||
// text
|
||||
// .setPlaceholder("Enter your secret")
|
||||
// .setValue(this.plugin.settings.mySetting)
|
||||
// .onChange(async (value) => {
|
||||
// console.log("Secret: " + value);
|
||||
// this.plugin.settings.mySetting = value;
|
||||
// await this.plugin.saveSettings();
|
||||
// })
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
55
src/models/Meter.ts
Normal file
55
src/models/Meter.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NoteDuration } from "@vapurrmaid/bpm";
|
||||
|
||||
export class Meter {
|
||||
upper: number;
|
||||
lower: number;
|
||||
|
||||
constructor(upper: number, lower: number) {
|
||||
this.upper = upper;
|
||||
this.lower = lower;
|
||||
}
|
||||
|
||||
noteDuration(): NoteDuration {
|
||||
switch (this.lower) {
|
||||
case 64:
|
||||
return "sixtyfourth";
|
||||
case 32:
|
||||
return "thirtysecondth";
|
||||
case 16:
|
||||
return "sixteenth";
|
||||
case 8:
|
||||
return "eigth";
|
||||
case 4:
|
||||
return "quarter";
|
||||
case 2:
|
||||
return "half";
|
||||
case 1:
|
||||
return "whole";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isValid() {
|
||||
return (
|
||||
!isNaN(this.upper) &&
|
||||
!isNaN(this.lower) &&
|
||||
this.upper > 0 &&
|
||||
this.lower > 0
|
||||
);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.isValid() && this.noteDuration()
|
||||
? `${this.upper}/${this.lower}`
|
||||
: "Invalid or unsupported meter";
|
||||
}
|
||||
|
||||
static fromString(unparsedMeterString: string) {
|
||||
const meterSplit = (unparsedMeterString || "").split("/");
|
||||
const upper = parseInt(meterSplit?.[0]);
|
||||
const lower = parseInt(meterSplit?.[1]);
|
||||
|
||||
return new Meter(upper, lower);
|
||||
}
|
||||
}
|
||||
5
src/models/MetronomeSize.ts
Normal file
5
src/models/MetronomeSize.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type MetronomeSize = "small" | "medium" | "large";
|
||||
|
||||
export function isMetronomeSize(inputSize: string): inputSize is MetronomeSize {
|
||||
return ["small", "medium", "large"].includes(inputSize);
|
||||
}
|
||||
21
src/sounds.ts
Normal file
21
src/sounds.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
window.TONE_SILENCE_LOGGING = true;
|
||||
|
||||
import * as Tone from "tone/build/esm";
|
||||
|
||||
// const synth = new Tone.Synth().toDestination();
|
||||
|
||||
const whiteNoise = new Tone.NoiseSynth({
|
||||
noise: { type: "white" },
|
||||
}).toDestination();
|
||||
|
||||
const brownNoise = new Tone.NoiseSynth({
|
||||
noise: { type: "brown" },
|
||||
}).toDestination();
|
||||
|
||||
const pinkNoise = new Tone.NoiseSynth({
|
||||
noise: { type: "pink" },
|
||||
}).toDestination();
|
||||
|
||||
export const tick = () => whiteNoise.triggerAttackRelease("8n");
|
||||
export const tock = () => brownNoise.triggerAttackRelease("8n");
|
||||
export const tickUpbeat = () => pinkNoise.triggerAttackRelease("8n");
|
||||
87
styles.css
87
styles.css
|
|
@ -1,4 +1,85 @@
|
|||
/* Sets all the text color to red! */
|
||||
body {
|
||||
color: red;
|
||||
.metronome-metronome {
|
||||
border-radius: 0.25rem;
|
||||
animation: metronome-pulse var(--metronome-duration) infinite;
|
||||
margin: 15px 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0.15rem;
|
||||
}
|
||||
|
||||
/* Sizes: ["small" (default), "medium", "large"] */
|
||||
.metronome-metronome[data-size="medium"] {
|
||||
height: 4rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.metronome-metronome[data-size="large"] {
|
||||
height: 15rem;
|
||||
align-items: flex-end;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.metronome-metronome button {
|
||||
/* Reset button styles */
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
outline: 0 !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.metronome-metronome .sound-toggle {
|
||||
opacity: 0.5;
|
||||
border-radius: 0.2rem;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metronome-metronome .sound-toggle div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metronome-description {
|
||||
font-weight: bold;
|
||||
margin-right: auto;
|
||||
font-size: 0.68rem;
|
||||
padding:0 0.25rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.metronome-metronome[data-size="medium"] .metronome-description,
|
||||
.metronome-metronome[data-size="large"] .metronome-description {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
.metronome-metronome .sound-toggle:hover,
|
||||
.metronome-metronome .sound-toggle:active {
|
||||
background: rgb(100 100 100 / 30%);
|
||||
}
|
||||
|
||||
.metronome-metronome .sound-toggle:hover,
|
||||
.metronome-metronome .sound-toggle:active,
|
||||
.metronome-metronome .sound-toggle.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes metronome-pulse {
|
||||
0% {
|
||||
background: var(--tick-color);
|
||||
}
|
||||
|
||||
100% {
|
||||
background: rgb(0 0 0 / 10%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue