Add initial metronome work

This commit is contained in:
Curt Grimes 2021-12-18 15:46:45 -06:00
parent 229a42929c
commit 0fb41be7ea
16 changed files with 553 additions and 149 deletions

3
.stylelintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "stylelint-config-standard"
}

View file

@ -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
View 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
View file

@ -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();
}));
}
}

View file

@ -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
}
}

View file

@ -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"
}
}
}

View 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

View 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

View 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 && '&middot;'}} {{meter}}</span>
<SoundToggle
v-model="soundOn"
@soundOn="resetTick"
/>
</div>
</template>

View 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
View 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
View 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
View 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);
}
}

View 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
View 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");

View file

@ -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%);
}
}