"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/main.ts var main_exports = {}; __export(main_exports, { default: () => MetronomeTunerPlugin }); module.exports = __toCommonJS(main_exports); var import_obsidian3 = require("obsidian"); // src/audio/AudioRuntime.ts var AudioRuntime = class { constructor() { __publicField(this, "context", null); } async getContext() { if (!this.context || this.context.state === "closed") { this.context = new AudioContext(); } if (this.context.state === "suspended") { await this.context.resume(); } return this.context; } peekContext() { return this.context; } async dispose() { const context = this.context; this.context = null; if (context && context.state !== "closed") { await context.close(); } } }; // src/metronome/TonePresets.ts var TONE_PRESETS = [ { id: "woodblock", name: "Woodblock", waveform: "sine", frequency: 780, accentFrequency: 1050, duration: 0.055, attack: 2e-3, brightness: 0.45 }, { id: "mechanical", name: "Mechanical", waveform: "square", frequency: 1100, accentFrequency: 1450, duration: 0.025, attack: 1e-3, brightness: 0.3 }, { id: "rimshot", name: "Rimshot", waveform: "sawtooth", frequency: 520, accentFrequency: 720, duration: 0.04, attack: 1e-3, brightness: 0.8 }, { id: "claves", name: "Claves", waveform: "triangle", frequency: 1750, accentFrequency: 2100, duration: 0.035, attack: 1e-3, brightness: 0.65 }, { id: "cowbell", name: "Cowbell", waveform: "square", frequency: 560, accentFrequency: 845, duration: 0.09, attack: 2e-3, brightness: 0.4 }, { id: "soft-digital", name: "Soft digital", waveform: "triangle", frequency: 880, accentFrequency: 1175, duration: 0.08, attack: 6e-3, brightness: 0.15 }, { id: "sine-pulse", name: "Sine pulse", waveform: "sine", frequency: 1e3, accentFrequency: 1320, duration: 0.05, attack: 3e-3, brightness: 0 } ]; function getTonePreset(id) { var _a; return (_a = TONE_PRESETS.find((preset) => preset.id === id)) != null ? _a : TONE_PRESETS[0]; } // src/settingsParsing.ts var DEFAULT_SETTINGS = { metronome: { bpm: 120, meterNumerator: 4, meterDenominator: 4, accent: true, volume: 0.65, tone: "woodblock" }, tunerA4: 440 }; function parseSettings(value) { if (!isRecord(value)) return structuredClone(DEFAULT_SETTINGS); return { metronome: parseMetronomeSettings(value.metronome), tunerA4: boundedInteger(value.tunerA4, 415, 466, DEFAULT_SETTINGS.tunerA4) }; } function parseMetronomeSettings(value) { const settings = isRecord(value) ? value : {}; return { bpm: boundedNumber(settings.bpm, 30, 300, DEFAULT_SETTINGS.metronome.bpm), meterNumerator: boundedInteger(settings.meterNumerator, 1, 16, DEFAULT_SETTINGS.metronome.meterNumerator), meterDenominator: isMeterDenominator(settings.meterDenominator) ? settings.meterDenominator : DEFAULT_SETTINGS.metronome.meterDenominator, accent: typeof settings.accent === "boolean" ? settings.accent : DEFAULT_SETTINGS.metronome.accent, volume: boundedNumber(settings.volume, 0, 1, DEFAULT_SETTINGS.metronome.volume), tone: isToneId(settings.tone) ? settings.tone : DEFAULT_SETTINGS.metronome.tone }; } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function boundedNumber(value, min, max, fallback) { return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; } function boundedInteger(value, min, max, fallback) { return typeof value === "number" && Number.isInteger(value) ? Math.min(max, Math.max(min, value)) : fallback; } function isMeterDenominator(value) { return value === 2 || value === 4 || value === 8 || value === 16; } function isToneId(value) { return typeof value === "string" && TONE_PRESETS.some((preset) => preset.id === value); } // src/metronome/ClickSynth.ts var ClickSynth = class { schedule(context, time, preset, volume, accented) { if (volume <= 0) return null; const start = Math.max(time, context.currentTime); const end = start + preset.duration; const oscillator = context.createOscillator(); const filter = context.createBiquadFilter(); const gain = context.createGain(); oscillator.type = preset.waveform; oscillator.frequency.setValueAtTime(accented ? preset.accentFrequency : preset.frequency, start); oscillator.frequency.exponentialRampToValueAtTime(Math.max(80, preset.frequency * 0.65), end); filter.type = "lowpass"; filter.frequency.setValueAtTime(1800 + preset.brightness * 7e3, start); gain.gain.setValueAtTime(1e-4, start); gain.gain.exponentialRampToValueAtTime(Math.max(1e-4, volume * (accented ? 1 : 0.72)), start + preset.attack); gain.gain.exponentialRampToValueAtTime(1e-4, end); oscillator.connect(filter).connect(gain).connect(context.destination); oscillator.start(start); oscillator.stop(end + 0.01); const endedListeners = /* @__PURE__ */ new Set(); let ended = false; const finish = () => { if (ended) return; ended = true; oscillator.disconnect(); filter.disconnect(); gain.disconnect(); for (const listener of endedListeners) listener(); endedListeners.clear(); }; oscillator.addEventListener("ended", finish, { once: true }); return { cancel: () => { if (ended) return; try { oscillator.stop(); } catch (e) { finish(); return; } finish(); }, onEnded: (listener) => { if (ended) { listener(); return () => void 0; } endedListeners.add(listener); return () => endedListeners.delete(listener); } }; } }; // src/metronome/MetronomeScheduler.ts var LOOKAHEAD_MS = 25; var SCHEDULE_AHEAD_SECONDS = 0.1; var START_DELAY_SECONDS = 0.05; var MetronomeScheduler = class { constructor(context, bpm, beatsPerBar, beatUnit, scheduleBeat, presentBeat = () => void 0) { __publicField(this, "context", context); __publicField(this, "bpm", bpm); __publicField(this, "beatsPerBar", beatsPerBar); __publicField(this, "beatUnit", beatUnit); __publicField(this, "scheduleBeat", scheduleBeat); __publicField(this, "presentBeat", presentBeat); __publicField(this, "timer", null); __publicField(this, "nextBeatTime", 0); __publicField(this, "beatIndex", 0); __publicField(this, "visualTimers", /* @__PURE__ */ new Set()); this.bpm = sanitizeBpm(bpm); this.beatsPerBar = sanitizeBeatsPerBar(beatsPerBar); this.beatUnit = sanitizeBeatUnit(beatUnit); } get running() { return this.timer !== null; } start() { if (this.running) return; this.beatIndex = 0; this.nextBeatTime = this.context.currentTime + START_DELAY_SECONDS; this.tick(); this.timer = window.setInterval(() => this.tick(), LOOKAHEAD_MS); } stop() { if (this.timer !== null) window.clearInterval(this.timer); this.timer = null; for (const timer of this.visualTimers) window.clearTimeout(timer); this.visualTimers.clear(); this.beatIndex = 0; } setTempo(bpm) { this.bpm = sanitizeBpm(bpm); } setMeter(beatsPerBar, beatUnit) { this.beatsPerBar = sanitizeBeatsPerBar(beatsPerBar); this.beatUnit = sanitizeBeatUnit(beatUnit); this.beatIndex %= this.beatsPerBar; } tick() { const now = this.context.currentTime; const duration = beatDurationSeconds(this.bpm, this.beatUnit); while (this.nextBeatTime < now) { this.nextBeatTime += duration; this.beatIndex = (this.beatIndex + 1) % this.beatsPerBar; } while (this.nextBeatTime < now + SCHEDULE_AHEAD_SECONDS) { const beat = { index: this.beatIndex, time: this.nextBeatTime, accented: this.beatIndex === 0 }; this.scheduleBeat(beat); const delay = Math.max(0, (beat.time - this.context.currentTime) * 1e3); const visualTimer = window.setTimeout(() => { this.visualTimers.delete(visualTimer); if (this.running) this.presentBeat(beat); }, delay); this.visualTimers.add(visualTimer); this.nextBeatTime += duration; this.beatIndex = (this.beatIndex + 1) % this.beatsPerBar; } } }; function beatDurationSeconds(bpm, beatUnit) { return 60 / sanitizeBpm(bpm) * (4 / sanitizeBeatUnit(beatUnit)); } function sanitizeBpm(value) { return Number.isFinite(value) ? Math.min(300, Math.max(30, value)) : 120; } function sanitizeBeatsPerBar(value) { return Number.isInteger(value) ? Math.min(16, Math.max(1, value)) : 4; } function sanitizeBeatUnit(value) { return value === 2 || value === 4 || value === 8 || value === 16 ? value : 4; } // src/metronome/TapTempo.ts var RESET_AFTER_MS = 2500; var MAX_INTERVALS = 7; var TapTempo = class { constructor() { __publicField(this, "lastTap", null); __publicField(this, "intervals", []); } tap(timeMs = performance.now()) { if (this.lastTap === null || timeMs - this.lastTap > RESET_AFTER_MS) { this.intervals = []; this.lastTap = timeMs; return null; } const interval = timeMs - this.lastTap; this.lastTap = timeMs; if (interval <= 0) return null; this.intervals.push(interval); if (this.intervals.length > MAX_INTERVALS) this.intervals.shift(); const sorted = [...this.intervals].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; return Math.round(Math.min(300, Math.max(30, 6e4 / median))); } reset() { this.lastTap = null; this.intervals = []; } }; // src/metronome/MetronomeController.ts var MetronomeController = class { constructor(audio, settings) { __publicField(this, "audio", audio); __publicField(this, "settings", settings); __publicField(this, "synth", new ClickSynth()); __publicField(this, "tapTempo", new TapTempo()); __publicField(this, "scheduler", null); __publicField(this, "currentBeatValue", null); __publicField(this, "listeners", /* @__PURE__ */ new Set()); __publicField(this, "previewVoices", []); __publicField(this, "previewSession", 0); __publicField(this, "metronomeVoices", /* @__PURE__ */ new Set()); __publicField(this, "generation", 0); __publicField(this, "starting", false); this.settings = parseMetronomeSettings(settings); } get running() { var _a, _b; return this.starting || ((_b = (_a = this.scheduler) == null ? void 0 : _a.running) != null ? _b : false); } get currentBeat() { return this.currentBeatValue; } subscribe(listener) { this.listeners.add(listener); listener(this.state()); return () => this.listeners.delete(listener); } async start() { if (this.running) return; const generation = ++this.generation; this.starting = true; this.emit(); let context; try { context = await this.audio.getContext(); } catch (error) { if (generation === this.generation) { this.starting = false; this.emit(); } throw error; } if (generation !== this.generation || !this.starting) return; const scheduler = new MetronomeScheduler(context, this.settings.bpm, this.settings.meterNumerator, this.settings.meterDenominator, (beat) => { if (generation !== this.generation) return; const voice = this.synth.schedule( context, beat.time, getTonePreset(this.settings.tone), this.settings.volume, this.settings.accent && beat.accented ); if (voice) this.trackVoice(voice, this.metronomeVoices); }, (beat) => { this.currentBeatValue = beat.index; this.emit(); }); this.scheduler = scheduler; this.starting = false; scheduler.start(); this.emit(); } stop() { var _a; this.generation++; this.starting = false; (_a = this.scheduler) == null ? void 0 : _a.stop(); this.scheduler = null; for (const voice of this.metronomeVoices) voice.cancel(); this.metronomeVoices.clear(); this.currentBeatValue = null; this.emit(); } async toggle() { if (this.running) this.stop(); else await this.start(); } updateSettings(settings) { var _a, _b; this.settings = parseMetronomeSettings(settings); (_a = this.scheduler) == null ? void 0 : _a.setTempo(this.settings.bpm); (_b = this.scheduler) == null ? void 0 : _b.setMeter(this.settings.meterNumerator, this.settings.meterDenominator); } tap(timeMs) { return this.tapTempo.tap(timeMs); } async preview() { var _a; this.cancelPreview(); const session = this.previewSession; const context = await this.audio.getContext(); if (session !== this.previewSession) return; const preset = getTonePreset(this.settings.tone); const interval = 60 / this.settings.bpm * (4 / this.settings.meterDenominator); const start = context.currentTime + 0.01; for (let index = 0; index < 3; index++) { const voice = this.synth.schedule(context, start + interval * index, preset, this.settings.volume, this.settings.accent && index === 0); if (voice) { this.previewVoices.push(voice); (_a = voice.onEnded) == null ? void 0 : _a.call(voice, () => { const voiceIndex = this.previewVoices.indexOf(voice); if (voiceIndex >= 0) this.previewVoices.splice(voiceIndex, 1); }); } } } dispose() { this.stop(); this.cancelPreview(); this.tapTempo.reset(); } state() { return { running: this.running, currentBeat: this.currentBeatValue }; } emit() { const state = this.state(); for (const listener of this.listeners) listener(state); } cancelPreview() { this.previewSession++; const voices = this.previewVoices; this.previewVoices = []; for (const voice of voices) voice.cancel(); } trackVoice(voice, voices) { var _a; voices.add(voice); (_a = voice.onEnded) == null ? void 0 : _a.call(voice, () => voices.delete(voice)); } }; // src/settings.ts var import_obsidian = require("obsidian"); var DENOMINATORS = [2, 4, 8, 16]; var ABOUT_LINKS = [ { name: "Portfolio", description: "Selected work by Patrick Fanella.", label: "Visit portfolio", href: "https://patrickfanella.co", external: true }, { name: "Subcult", description: "Music, film, and independent culture projects.", label: "Visit Subcult", href: "https://subcult.tv", external: true }, { name: "GitHub", description: "Browse Patrick's open-source work.", label: "View GitHub", href: "https://github.com/patrickfanella", external: true }, { name: "Email", description: "Questions, feedback, or collaboration.", label: "Send email", href: "mailto:patrick@subcult.tv" } ]; var MetronomeTunerSettingTab = class extends import_obsidian.PluginSettingTab { constructor(app, plugin) { super(app, plugin); __publicField(this, "plugin", plugin); } getSettingDefinitions() { return [ { type: "group", heading: "Defaults", items: this.getDefaultDefinitions() }, { type: "group", heading: "About & support", items: ABOUT_LINKS.map((link) => ({ name: link.name, desc: link.description, render: (setting) => this.configureAboutLink(setting, link) })) } ]; } display() { this.containerEl.empty(); new import_obsidian.Setting(this.containerEl).setName("Defaults").setHeading(); this.addDefaultSettings(); new import_obsidian.Setting(this.containerEl).setName("About & support").setHeading(); for (const link of ABOUT_LINKS) { this.configureAboutLink(new import_obsidian.Setting(this.containerEl), link); } } getDefaultDefinitions() { return this.getDefaultSettings().map((spec) => ({ name: spec.name, desc: spec.description, render: (setting) => { setting.setName(spec.name).setDesc(spec.description); spec.configure(setting); } })); } addDefaultSettings() { for (const spec of this.getDefaultSettings()) { const setting = new import_obsidian.Setting(this.containerEl).setName(spec.name).setDesc(spec.description); spec.configure(setting); } } getDefaultSettings() { return [ { name: "Default tempo", description: "Tempo used when Obsidian starts.", configure: (setting) => this.configureDefaultTempo(setting) }, { name: "A4 reference", description: "Concert pitch in hertz.", configure: (setting) => this.configureA4Reference(setting) }, { name: "Meter numerator", description: "Number of beats in each measure.", configure: (setting) => this.configureMeterNumerator(setting) }, { name: "Meter denominator", description: "Note value represented by each beat.", configure: (setting) => this.configureMeterDenominator(setting) }, { name: "Accent first beat", description: "Emphasize the first beat of each measure.", configure: (setting) => this.configureAccent(setting) }, { name: "Click volume", description: "Set the metronome's startup volume.", configure: (setting) => this.configureClickVolume(setting) }, { name: "Click sound", description: "Choose the metronome's startup sound.", configure: (setting) => this.configureClickSound(setting) } ]; } configureDefaultTempo(setting) { setting.addSlider((slider) => slider.setLimits(30, 300, 1).setValue(this.plugin.settings.metronome.bpm).setDynamicTooltip().onChange((value) => { void this.plugin.updateSettings({ ...this.plugin.settings, metronome: { ...this.plugin.settings.metronome, bpm: value } }); })); } configureA4Reference(setting) { setting.addSlider((slider) => slider.setLimits(415, 466, 1).setValue(this.plugin.settings.tunerA4).setDynamicTooltip().onChange((value) => { void this.plugin.updateSettings({ ...this.plugin.settings, tunerA4: value }); })); } configureMeterNumerator(setting) { setting.addSlider((slider) => slider.setLimits(1, 16, 1).setValue(this.plugin.settings.metronome.meterNumerator).setDynamicTooltip().onChange((value) => { void this.plugin.updateSettings({ ...this.plugin.settings, metronome: { ...this.plugin.settings.metronome, meterNumerator: value } }); })); } configureMeterDenominator(setting) { setting.addDropdown((dropdown) => { for (const denominator of DENOMINATORS) { dropdown.addOption(String(denominator), String(denominator)); } dropdown.setValue(String(this.plugin.settings.metronome.meterDenominator)).onChange((value) => { const denominator = Number(value); if (isMeterDenominator2(denominator)) { void this.plugin.updateSettings({ ...this.plugin.settings, metronome: { ...this.plugin.settings.metronome, meterDenominator: denominator } }); } }); }); } configureAccent(setting) { setting.addToggle((toggle) => toggle.setValue(this.plugin.settings.metronome.accent).onChange((value) => { void this.plugin.updateSettings({ ...this.plugin.settings, metronome: { ...this.plugin.settings.metronome, accent: value } }); })); } configureClickVolume(setting) { setting.addSlider((slider) => slider.setLimits(0, 1, 0.01).setValue(this.plugin.settings.metronome.volume).setDynamicTooltip().onChange((value) => { void this.plugin.updateSettings({ ...this.plugin.settings, metronome: { ...this.plugin.settings.metronome, volume: value } }); })); } configureClickSound(setting) { setting.addDropdown((dropdown) => { for (const preset of TONE_PRESETS) { dropdown.addOption(preset.id, preset.name); } dropdown.setValue(this.plugin.settings.metronome.tone).onChange((value) => { if (isToneId(value)) { void this.plugin.updateSettings({ ...this.plugin.settings, metronome: { ...this.plugin.settings.metronome, tone: value } }); } }); }); } configureAboutLink(setting, link) { setting.setName(link.name).setDesc(link.description); const anchor = setting.controlEl.createEl("a", { text: link.label, href: link.href }); if (link.external) { anchor.setAttrs({ target: "_blank", rel: "noopener noreferrer" }); } } }; function isMeterDenominator2(value) { return value === 2 || value === 4 || value === 8 || value === 16; } // src/tuner/NoteMath.ts var NOTE_NAMES = ["C", "C\u266F", "D", "E\u266D", "E", "F", "F\u266F", "G", "A\u266D", "A", "B\u266D", "B"]; function describePitch(frequency, a4 = 440) { const midiFloat = 69 + 12 * Math.log2(frequency / a4); const midi = Math.round(midiFloat); const noteIndex = (midi % 12 + 12) % 12; return { note: NOTE_NAMES[noteIndex], octave: Math.floor(midi / 12) - 1, cents: Math.round((midiFloat - midi) * 100) || 0 }; } // src/tuner/YinPitchDetector.ts var YIN_THRESHOLD = 0.15; var YinPitchDetector = class { constructor(sampleRate, minFrequency = 40, maxFrequency = 2e3, minRms = 0.01, minConfidence = 0.8) { __publicField(this, "sampleRate", sampleRate); __publicField(this, "minFrequency", minFrequency); __publicField(this, "maxFrequency", maxFrequency); __publicField(this, "minRms", minRms); __publicField(this, "minConfidence", minConfidence); __publicField(this, "difference", new Float32Array(0)); __publicField(this, "downsampled", new Float32Array(0)); } detect(buffer) { if (buffer.length < 4) return null; const downsampledLength = Math.floor(buffer.length / 2); if (this.downsampled.length !== downsampledLength) this.downsampled = new Float32Array(downsampledLength); for (let index = 0; index < downsampledLength; index++) { this.downsampled[index] = (buffer[index * 2] + buffer[index * 2 + 1]) * 0.5; } const samples = this.downsampled; const effectiveSampleRate = this.sampleRate / 2; let mean = 0; for (const sample of samples) mean += sample; mean /= samples.length; let squareSum = 0; for (const sample of samples) { const centered = sample - mean; squareSum += centered * centered; } const rms = Math.sqrt(squareSum / samples.length); if (rms < this.minRms) return null; const minTau = Math.max(2, Math.floor(effectiveSampleRate / this.maxFrequency)); const maxTau = Math.min(Math.floor(effectiveSampleRate / this.minFrequency), Math.floor(samples.length / 2)); if (maxTau <= minTau) return null; const comparisonLength = Math.min(maxTau, samples.length - maxTau); const start = samples.length - comparisonLength - maxTau; if (this.difference.length < maxTau + 1) this.difference = new Float32Array(maxTau + 1); const difference = this.difference; for (let tau2 = 1; tau2 <= maxTau; tau2++) { let sum = 0; for (let index = 0; index < comparisonLength; index++) { const delta = samples[start + index] - samples[start + index + tau2]; sum += delta * delta; } difference[tau2] = sum; } let runningSum = 0; difference[0] = 1; for (let tau2 = 1; tau2 <= maxTau; tau2++) { runningSum += difference[tau2]; difference[tau2] = runningSum === 0 ? 1 : difference[tau2] * tau2 / runningSum; } let tau = minTau; while (tau <= maxTau) { if (difference[tau] < YIN_THRESHOLD) { while (tau + 1 <= maxTau && difference[tau + 1] < difference[tau]) tau++; break; } tau++; } if (tau > maxTau) return null; const confidence = 1 - difference[tau]; if (confidence < this.minConfidence) return null; const refinedTau = this.parabolicTau(difference, tau, maxTau); const frequency = effectiveSampleRate / refinedTau; if (frequency < this.minFrequency || frequency > this.maxFrequency) return null; return { frequency, confidence, rms }; } parabolicTau(values, tau, maxTau) { if (tau <= 1 || tau >= maxTau) return tau; const left = values[tau - 1]; const center = values[tau]; const right = values[tau + 1]; if (center < 1e-6) return tau; const denominator = 2 * (2 * center - right - left); return denominator === 0 ? tau : tau + (right - left) / denominator; } }; // src/tuner/TunerController.ts var ANALYSIS_INTERVAL_MS = 100; var SMOOTHING_WINDOW = 5; var READING_HOLD_MS = 550; var DEFAULT_A4 = 440; var TunerOwner = class _TunerOwner { constructor() { __publicField(this, "identity", /* @__PURE__ */ Symbol()); } static create() { return new _TunerOwner(); } }; var TunerController = class { constructor(audio, a4) { __publicField(this, "audio", audio); __publicField(this, "a4", a4); __publicField(this, "midiPitches", []); __publicField(this, "lastReliableAt", 0); __publicField(this, "statusValue", "idle"); __publicField(this, "errorValue", null); __publicField(this, "readingValue", null); __publicField(this, "activeSession", null); this.a4 = sanitizeA4(a4); } get status() { return this.statusValue; } get error() { return this.errorValue; } get reading() { return this.readingValue; } isOwnedBy(owner) { var _a; return ((_a = this.activeSession) == null ? void 0 : _a.owner) === owner; } setA4(a4) { this.a4 = sanitizeA4(a4); this.midiPitches = []; this.readingValue = null; this.lastReliableAt = 0; } async start(owner) { var _a; if (this.activeSession) return this.activeSession.owner === owner; const session = { owner, resources: { stream: null, source: null, analyser: null, timer: null, trackEndedListeners: /* @__PURE__ */ new Map() } }; this.activeSession = session; this.statusValue = "starting"; this.errorValue = null; try { if (!((_a = navigator.mediaDevices) == null ? void 0 : _a.getUserMedia)) throw new Error("Microphone access is not available on this device."); const contextResultPromise = this.audio.getContext().then( (context2) => ({ context: context2, error: null }), (error) => ({ context: null, error }) ); const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, video: false }); session.resources.stream = stream; for (const track of stream.getTracks()) { const onEnded = () => this.handleTrackEnded(session); track.addEventListener("ended", onEnded); session.resources.trackEndedListeners.set(track, onEnded); } if (this.activeSession !== session) { this.stopResources(session.resources); return false; } const contextResult = await contextResultPromise; if (contextResult.error) { throw contextResult.error instanceof Error ? contextResult.error : new Error("Could not start audio context."); } const context = contextResult.context; if (!context) throw new Error("Could not start audio."); if (this.activeSession !== session) { this.stopResources(session.resources); return false; } const analyser = context.createAnalyser(); analyser.fftSize = 8192; analyser.smoothingTimeConstant = 0; const source = context.createMediaStreamSource(stream); source.connect(analyser); session.resources.source = source; session.resources.analyser = analyser; this.statusValue = "listening"; const detector = new YinPitchDetector(context.sampleRate); const buffer = new Float32Array(new ArrayBuffer(analyser.fftSize * Float32Array.BYTES_PER_ELEMENT)); session.resources.timer = window.setInterval(() => this.analyze(session, detector, buffer), ANALYSIS_INTERVAL_MS); return true; } catch (error) { this.stopResources(session.resources); if (this.activeSession !== session) return false; this.activeSession = null; this.statusValue = "error"; this.errorValue = microphoneErrorMessage(error); return false; } } stop(owner) { const session = this.activeSession; if (!session || session.owner !== owner) return false; this.activeSession = null; this.stopResources(session.resources); this.resetState(); return true; } dispose() { const session = this.activeSession; this.activeSession = null; if (session) this.stopResources(session.resources); this.resetState(); } resetState() { this.statusValue = "idle"; this.errorValue = null; this.readingValue = null; this.midiPitches = []; this.lastReliableAt = 0; } analyze(session, detector, buffer) { const analyser = session.resources.analyser; if (this.activeSession !== session || !analyser) return; analyser.getFloatTimeDomainData(buffer); const detection = detector.detect(buffer); if (!detection) { if (performance.now() - this.lastReliableAt >= READING_HOLD_MS) { this.readingValue = null; this.midiPitches = []; } return; } this.lastReliableAt = performance.now(); this.midiPitches.push(69 + 12 * Math.log2(detection.frequency / this.a4)); if (this.midiPitches.length > SMOOTHING_WINDOW) this.midiPitches.shift(); const sorted = [...this.midiPitches].sort((a, b) => a - b); const midiPitch = sorted[Math.floor(sorted.length / 2)]; const frequency = this.a4 * 2 ** ((midiPitch - 69) / 12); this.readingValue = { ...detection, frequency, ...describePitch(frequency, this.a4) }; } stopResources(resources) { var _a, _b, _c; if (resources.timer !== null) window.clearInterval(resources.timer); resources.timer = null; (_a = resources.source) == null ? void 0 : _a.disconnect(); (_b = resources.analyser) == null ? void 0 : _b.disconnect(); (_c = resources.stream) == null ? void 0 : _c.getTracks().forEach((track) => { const listener = resources.trackEndedListeners.get(track); if (listener) track.removeEventListener("ended", listener); track.stop(); }); resources.trackEndedListeners.clear(); resources.source = null; resources.analyser = null; resources.stream = null; } handleTrackEnded(session) { if (this.activeSession !== session) return; this.activeSession = null; this.stopResources(session.resources); this.readingValue = null; this.midiPitches = []; this.statusValue = "error"; this.errorValue = "The microphone disconnected. Reconnect it and try again."; } }; function sanitizeA4(value) { return Number.isFinite(value) ? Math.min(466, Math.max(415, value)) : DEFAULT_A4; } function microphoneErrorMessage(error) { if (error instanceof DOMException) { if (error.name === "NotAllowedError" || error.name === "SecurityError") return "Microphone permission was denied. Allow microphone access in your system or Obsidian settings."; if (error.name === "NotFoundError" || error.name === "DevicesNotFoundError") return "No microphone was found. Connect an input device and try again."; if (error.name === "NotReadableError" || error.name === "TrackStartError") return "The microphone is busy or unavailable. Close other audio apps and try again."; if (error.name === "OverconstrainedError") return "The microphone does not support the requested audio settings."; } return error instanceof Error ? error.message : "Could not start the microphone."; } // src/view.ts var import_obsidian2 = require("obsidian"); var VIEW_TYPE_METRONOME_TUNER = "metronome-tuner-view"; var viewId = 0; var MetronomeTunerView = class extends import_obsidian2.ItemView { constructor(leaf, plugin) { super(leaf); __publicField(this, "plugin", plugin); __publicField(this, "activeTab", "metronome"); __publicField(this, "abortController", null); __publicField(this, "unsubscribeMetronome", null); __publicField(this, "tunerTimer", null); __publicField(this, "lastTunerStatus", ""); __publicField(this, "lastTunerAnnouncement", ""); __publicField(this, "lastTunerAnnouncedAt", 0); __publicField(this, "idPrefix", `tempo-tune-${++viewId}`); __publicField(this, "tunerOwner", TunerOwner.create()); __publicField(this, "beatMarkers", []); __publicField(this, "startButton", null); } getViewType() { return VIEW_TYPE_METRONOME_TUNER; } getDisplayText() { return "Tempo & tune"; } getIcon() { return "audio-waveform"; } async onOpen() { this.abortController = new AbortController(); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { this.plugin.tuner.stop(this.tunerOwner); this.updateTunerReading(true); } }, { signal: this.abortController.signal }); this.render(); this.unsubscribeMetronome = this.plugin.metronome.subscribe((state) => this.updateMetronomeState(state)); this.tunerTimer = window.setInterval(() => this.updateTunerReading(), 100); } async onClose() { var _a, _b; (_a = this.abortController) == null ? void 0 : _a.abort(); this.abortController = null; (_b = this.unsubscribeMetronome) == null ? void 0 : _b.call(this); this.unsubscribeMetronome = null; if (this.tunerTimer !== null) window.clearInterval(this.tunerTimer); this.tunerTimer = null; this.plugin.tuner.stop(this.tunerOwner); this.beatMarkers = []; this.startButton = null; this.contentEl.empty(); } render() { const root = this.contentEl; root.empty(); root.addClass("metronome-tuner-view"); root.createEl("header", { cls: "tempo-tune-header" }).createEl("h1", { text: "Tempo & tune" }); const tabList = root.createDiv({ cls: "tempo-tune-tabs", attr: { role: "tablist", "aria-label": "Tempo and tuning tools" } }); const panels = root.createDiv({ cls: "tempo-tune-panels" }); const tabs = []; for (const tabName of ["metronome", "tuner"]) { const tabId = `${this.idPrefix}-${tabName}-tab`; const panelId = `${this.idPrefix}-${tabName}-panel`; const tab = tabList.createEl("button", { cls: "tempo-tune-tab", text: tabName === "metronome" ? "Metronome" : "Tuner", attr: { id: tabId, role: "tab", "aria-controls": panelId, type: "button" } }); const panel = panels.createEl("section", { cls: "tempo-tune-panel", attr: { id: panelId, role: "tabpanel", "aria-labelledby": tabId, tabindex: "0" } }); tabs.push(tab); this.listen(tab, "click", () => this.selectTab(tabName, tabs, panels)); if (tabName === "metronome") this.renderMetronome(panel); else this.renderTuner(panel); } this.listen(tabList, "keydown", (event) => this.handleTabKeydown(event, tabs, panels)); this.listen(root, "keydown", (event) => this.handleViewKeydown(event)); this.selectTab(this.activeTab, tabs, panels, false); this.updateTunerReading(true); } renderMetronome(panel) { panel.addClass("metronome-panel"); panel.createEl("p", { cls: "tempo-tune-kicker", text: "Keep time" }); const hero = panel.createDiv({ cls: "metronome-hero" }); const readout = hero.createDiv({ cls: "bpm-readout", attr: { "aria-label": `${this.plugin.settings.metronome.bpm} beats per minute` } }); readout.createSpan({ cls: "bpm-value", text: String(this.plugin.settings.metronome.bpm), attr: { "data-bpm-readout": "" } }); readout.createSpan({ cls: "bpm-unit", text: "BPM" }); const beatGroup = hero.createDiv({ cls: "beat-group", attr: { "aria-label": "Beats in measure" } }); this.renderBeatMarkers(beatGroup); this.startButton = panel.createEl("button", { cls: "metronome-start mod-cta", text: this.plugin.metronome.running ? "Stop metronome" : "Start metronome", attr: { type: "button", "aria-pressed": String(this.plugin.metronome.running) } }); this.listen(this.startButton, "click", () => void this.toggleMetronome()); const tempoGroup = panel.createDiv({ cls: "tempo-control-group", attr: { role: "group", "aria-labelledby": `${this.idPrefix}-tempo-label` } }); tempoGroup.createSpan({ cls: "control-label", text: "Tempo", attr: { id: `${this.idPrefix}-tempo-label` } }); const stepper = tempoGroup.createDiv({ cls: "tempo-stepper" }); const minus = stepper.createEl("button", { text: "\u2212", attr: { type: "button", "aria-label": "Decrease tempo" } }); const bpm = stepper.createEl("input", { type: "number", attr: { "aria-label": "Tempo in beats per minute", inputmode: "numeric" } }); bpm.min = "30"; bpm.max = "300"; bpm.step = "1"; bpm.value = String(this.plugin.settings.metronome.bpm); const plus = stepper.createEl("button", { text: "+", attr: { type: "button", "aria-label": "Increase tempo" } }); const range = tempoGroup.createEl("input", { type: "range", cls: "tempo-range", attr: { "aria-label": "Tempo slider" } }); range.min = "30"; range.max = "300"; range.step = "1"; range.value = bpm.value; const setTempo = (value) => { const next = this.boundedInteger(value, 30, 300, this.plugin.settings.metronome.bpm); bpm.value = String(next); range.value = String(next); this.setBpmReadout(next); void this.plugin.setBpm(next); }; this.listen(minus, "click", () => setTempo(Number(bpm.value) - 1)); this.listen(plus, "click", () => setTempo(Number(bpm.value) + 1)); this.listen(bpm, "change", () => setTempo(Number(bpm.value))); this.listen(range, "input", () => { bpm.value = range.value; this.setBpmReadout(Number(range.value)); }); this.listen(range, "change", () => setTempo(Number(range.value))); const tap = panel.createEl("button", { cls: "tap-tempo", text: "Tap tempo", attr: { type: "button" } }); this.listen(tap, "click", () => { const tapped = this.plugin.metronome.tap(); if (tapped !== null) setTempo(tapped); }); const details = panel.createEl("details", { cls: "metronome-details" }); details.open = true; details.createEl("summary", { text: "Meter & sound" }); const secondary = details.createDiv({ cls: "metronome-secondary" }); const meterFieldset = secondary.createEl("fieldset", { cls: "meter-settings" }); meterFieldset.createEl("legend", { text: "Time signature" }); const meterRow = meterFieldset.createDiv({ cls: "meter-row" }); const numerator = this.createLabeledInput(meterRow, "Numerator", "number"); numerator.min = "1"; numerator.max = "16"; numerator.step = "1"; numerator.value = String(this.plugin.settings.metronome.meterNumerator); const denominatorWrap = meterRow.createEl("label", { cls: "field" }); denominatorWrap.createSpan({ text: "Denominator" }); const denominator = denominatorWrap.createEl("select"); for (const value of [2, 4, 8, 16]) denominator.createEl("option", { text: String(value), value: String(value) }); denominator.value = String(this.plugin.settings.metronome.meterDenominator); this.listen(numerator, "change", () => { const value = this.boundedInteger(Number(numerator.value), 1, 16, this.plugin.settings.metronome.meterNumerator); numerator.value = String(value); void this.updateMetronomeSettings({ meterNumerator: value }); this.renderBeatMarkers(beatGroup); }); this.listen(denominator, "change", () => void this.updateMetronomeSettings({ meterDenominator: Number(denominator.value) })); const accentLabel = meterFieldset.createEl("label", { cls: "toggle-field" }); const accent = accentLabel.createEl("input", { type: "checkbox" }); accent.checked = this.plugin.settings.metronome.accent; accentLabel.createSpan({ text: "Accent first beat" }); this.listen(accent, "change", () => { void this.updateMetronomeSettings({ accent: accent.checked }); this.renderBeatMarkers(beatGroup); }); const soundFieldset = secondary.createEl("fieldset", { cls: "sound-settings" }); soundFieldset.createEl("legend", { text: "Click sound" }); const toneLabel = soundFieldset.createEl("label", { cls: "field" }); toneLabel.createSpan({ text: "Sound" }); const tone = toneLabel.createEl("select"); for (const preset of TONE_PRESETS) tone.createEl("option", { text: preset.name, value: preset.id }); tone.value = this.plugin.settings.metronome.tone; this.listen(tone, "change", () => { if (isToneId(tone.value)) void this.plugin.setTone(tone.value); }); const volumeLabel = soundFieldset.createEl("label", { cls: "field" }); volumeLabel.createSpan({ text: "Volume" }); const volume = volumeLabel.createEl("input", { type: "range" }); volume.min = "0"; volume.max = "1"; volume.step = "0.01"; volume.value = String(this.plugin.settings.metronome.volume); this.listen(volume, "change", () => void this.updateMetronomeSettings({ volume: Number(volume.value) })); const preview = soundFieldset.createEl("button", { text: "Preview sound", attr: { type: "button" } }); this.listen(preview, "click", () => void this.plugin.metronome.preview().catch((error) => this.showAudioError(error, "Could not preview the click sound."))); } renderTuner(panel) { panel.addClass("tuner-panel"); panel.createEl("p", { cls: "tempo-tune-kicker", text: "Find the pitch" }); panel.createEl("p", { cls: "tuner-intro", text: "Play one clear note near your microphone." }); const micCard = panel.createDiv({ cls: "microphone-card" }); const micCopy = micCard.createDiv(); micCopy.createSpan({ cls: "microphone-label", text: "Microphone" }); micCopy.createEl("p", { cls: "microphone-status", attr: { "data-tuner-status": "", role: "status", "aria-live": "polite", "aria-atomic": "true" } }); const tunerButton = micCard.createEl("button", { cls: "microphone-action mod-cta", attr: { type: "button", "data-tuner-action": "" } }); this.listen(tunerButton, "click", () => this.toggleTuner()); const display = panel.createDiv({ cls: "tuner-display", attr: { "data-tuner-display": "" } }); const pitch = display.createDiv({ cls: "pitch-readout" }); pitch.createSpan({ cls: "pitch-note", text: "\u2013", attr: { "data-note": "" } }); pitch.createSpan({ cls: "pitch-octave", text: "", attr: { "data-octave": "" } }); const measurements = display.createDiv({ cls: "pitch-measurements" }); const frequency = measurements.createDiv(); frequency.createSpan({ cls: "measurement-value", text: "\u2014", attr: { "data-frequency": "" } }); frequency.createSpan({ cls: "measurement-label", text: "Frequency" }); const confidence = measurements.createDiv(); confidence.createSpan({ cls: "measurement-value", text: "\u2014", attr: { "data-confidence": "" } }); confidence.createSpan({ cls: "measurement-label", text: "Confidence" }); const gauge = panel.createDiv({ cls: "tuner-gauge" }); const scale = gauge.createDiv({ cls: "gauge-scale", attr: { role: "meter", "aria-label": "Pitch accuracy", "aria-valuemin": "-50", "aria-valuemax": "50", "aria-valuenow": "0", "aria-valuetext": "No pitch detected", "data-gauge": "" } }); scale.createDiv({ cls: "gauge-center" }); scale.createDiv({ cls: "gauge-needle", attr: { "data-gauge-needle": "" } }); const ticks = gauge.createDiv({ cls: "gauge-ticks", attr: { "aria-hidden": "true" } }); for (const label of ["\u221250", "\u221225", "0", "+25", "+50"]) ticks.createSpan({ text: label }); gauge.createEl("p", { cls: "cents-description", text: "Waiting for a note", attr: { "data-cents-description": "" } }); const reference = panel.createDiv({ cls: "tuner-reference" }); const a4 = this.createLabeledInput(reference, "A4 reference (Hz)", "number"); a4.min = "415"; a4.max = "466"; a4.step = "1"; a4.value = String(this.plugin.settings.tunerA4); this.listen(a4, "change", () => { const value = this.boundedInteger(Number(a4.value), 415, 466, this.plugin.settings.tunerA4); a4.value = String(value); void this.plugin.updateSettings({ ...this.plugin.settings, tunerA4: value }); }); panel.createDiv({ cls: "sr-only", attr: { "aria-live": "polite", "aria-atomic": "true", "data-tuner-live": "" } }); } selectTab(name, tabs, panels, moveFocus = false) { if (name !== "tuner") this.plugin.tuner.stop(this.tunerOwner); this.activeTab = name; tabs.forEach((tab, index) => { const selected = (index === 0 ? "metronome" : "tuner") === name; tab.setAttr("aria-selected", String(selected)); tab.tabIndex = selected ? 0 : -1; const panel = panels.children[index]; if (panel) panel.hidden = !selected; if (selected && moveFocus) tab.focus(); }); } handleTabKeydown(event, tabs, panels) { const current = tabs.indexOf(event.target); if (current < 0) return; let next = current; if (event.key === "ArrowRight") next = (current + 1) % tabs.length; else if (event.key === "ArrowLeft") next = (current - 1 + tabs.length) % tabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = tabs.length - 1; else return; event.preventDefault(); this.selectTab(next === 0 ? "metronome" : "tuner", tabs, panels, true); } handleViewKeydown(event) { if (event.code !== "Space" || this.activeTab !== "metronome" || this.isInteractive(event.target)) return; event.preventDefault(); void this.toggleMetronome(); } isInteractive(target) { return target instanceof Element && Boolean(target.closest("button, input, select, textarea, summary, a, [contenteditable='true'], [role='tab']")); } async toggleMetronome() { try { await this.plugin.metronome.toggle(); } catch (error) { this.showAudioError(error, "Could not start the metronome."); } } toggleTuner() { const active = this.plugin.tuner.status === "listening" || this.plugin.tuner.status === "starting"; if (active && !this.plugin.tuner.isOwnedBy(this.tunerOwner)) return; if (active) { this.plugin.tuner.stop(this.tunerOwner); this.updateTunerReading(true); return; } const starting = this.plugin.tuner.start(this.tunerOwner); this.updateTunerReading(true); void starting.then(() => this.updateTunerReading(true)); } updateMetronomeState(state) { if (this.startButton) { this.startButton.setText(state.running ? "Stop metronome" : "Start metronome"); this.startButton.setAttr("aria-pressed", String(state.running)); this.startButton.toggleClass("is-running", state.running); } this.beatMarkers.forEach((marker, index) => marker.toggleClass("is-current", state.running && state.currentBeat === index)); } renderBeatMarkers(container) { container.empty(); this.beatMarkers = []; const { meterNumerator, accent } = this.plugin.settings.metronome; container.setAttr("aria-label", `${meterNumerator} beats per measure${accent ? ", first beat accented" : ""}`); for (let index = 0; index < meterNumerator; index++) { const marker = container.createSpan({ cls: `beat-marker${accent && index === 0 ? " is-accent" : ""}`, attr: { "aria-hidden": "true" } }); this.beatMarkers.push(marker); } this.updateMetronomeState({ running: this.plugin.metronome.running, currentBeat: this.plugin.metronome.currentBeat }); } updateTunerReading(forceAnnouncement = false) { var _a, _b; const statusElement = this.contentEl.querySelector("[data-tuner-status]"); const button = this.contentEl.querySelector("[data-tuner-action]"); if (!statusElement || !button) return; const status = this.plugin.tuner.status; const activeElsewhere = (status === "starting" || status === "listening") && !this.plugin.tuner.isOwnedBy(this.tunerOwner); const reading = this.plugin.tuner.reading; const error = this.plugin.tuner.error; const statusText = activeElsewhere ? "Microphone active in another Tempo & Tune view." : error != null ? error : status === "starting" ? "Requesting microphone permission\u2026" : status === "listening" ? reading ? "Microphone active." : "Listening for a steady note\u2026" : "Microphone off. Start listening to tune."; if (statusText !== this.lastTunerStatus) { statusElement.setText(statusText); this.lastTunerStatus = statusText; } button.disabled = activeElsewhere; button.setText(activeElsewhere ? "Microphone in use" : status === "starting" ? "Cancel microphone request" : status === "listening" ? "Stop listening" : status === "error" ? "Try microphone again" : "Use microphone"); const note = this.contentEl.querySelector("[data-note]"); const octave = this.contentEl.querySelector("[data-octave]"); const frequency = this.contentEl.querySelector("[data-frequency]"); const confidence = this.contentEl.querySelector("[data-confidence]"); const gauge = this.contentEl.querySelector("[data-gauge]"); const needle = this.contentEl.querySelector("[data-gauge-needle]"); const description = this.contentEl.querySelector("[data-cents-description]"); if (!note || !octave || !frequency || !confidence || !gauge || !needle || !description) return; if (!reading) { (_a = this.contentEl.querySelector("[data-tuner-live]")) == null ? void 0 : _a.setText(""); this.lastTunerAnnouncement = ""; this.lastTunerAnnouncedAt = 0; note.setText("\u2013"); octave.setText(""); frequency.setText("\u2014"); confidence.setText("\u2014"); needle.setCssProps({ "--cents-position": "50%" }); needle.toggleClass("has-reading", false); gauge.setAttr("aria-valuenow", "0"); gauge.setAttr("aria-valuetext", status === "listening" ? "No signal" : "Tuner inactive"); description.setText(status === "listening" ? "No signal yet" : "Waiting for a note"); return; } const cents = Math.max(-50, Math.min(50, reading.cents)); const centsText = this.centsDescription(reading.cents); note.setText(reading.note); octave.setText(String(reading.octave)); frequency.setText(`${reading.frequency.toFixed(1)} Hz`); confidence.setText(`${Math.round(reading.confidence * 100)}%`); needle.setCssProps({ "--cents-position": `${cents + 50}%` }); needle.toggleClass("has-reading", true); gauge.setAttr("aria-valuenow", String(cents)); gauge.setAttr("aria-valuetext", centsText); description.setText(centsText); const announcement = `${reading.note}${reading.octave}, ${centsText}`; const now = performance.now(); if ((forceAnnouncement || now - this.lastTunerAnnouncedAt >= 1500) && announcement !== this.lastTunerAnnouncement) { (_b = this.contentEl.querySelector("[data-tuner-live]")) == null ? void 0 : _b.setText(announcement); this.lastTunerAnnouncement = announcement; this.lastTunerAnnouncedAt = now; } } centsDescription(cents) { if (Math.abs(cents) <= 2) return "In tune"; const amount = Math.abs(Math.round(cents)); return `${amount} ${amount === 1 ? "cent" : "cents"} ${cents < 0 ? "flat" : "sharp"}`; } setBpmReadout(value) { var _a; const readout = this.contentEl.querySelector("[data-bpm-readout]"); if (readout) { readout.setText(String(value)); (_a = readout.parentElement) == null ? void 0 : _a.setAttr("aria-label", `${value} beats per minute`); } } createLabeledInput(parent, label, type) { const wrapper = parent.createEl("label", { cls: "field" }); wrapper.createSpan({ text: label }); return wrapper.createEl("input", { type }); } async updateMetronomeSettings(changes) { await this.plugin.updateSettings({ ...this.plugin.settings, metronome: { ...this.plugin.settings.metronome, ...changes } }); } boundedInteger(value, min, max, fallback) { return Number.isFinite(value) ? Math.round(Math.min(max, Math.max(min, value))) : fallback; } listen(element, type, listener) { var _a; element.addEventListener(type, listener, { signal: (_a = this.abortController) == null ? void 0 : _a.signal }); } showAudioError(error, fallback) { new import_obsidian2.Notice(error instanceof Error ? error.message : fallback); } }; // src/main.ts var MetronomeTunerPlugin = class extends import_obsidian3.Plugin { constructor() { super(...arguments); __publicField(this, "settings", structuredClone(DEFAULT_SETTINGS)); __publicField(this, "audio", new AudioRuntime()); __publicField(this, "metronome"); __publicField(this, "tuner"); __publicField(this, "saveQueue", Promise.resolve()); } async onload() { const loadedSettings = await this.loadData(); this.settings = parseSettings(loadedSettings); this.metronome = new MetronomeController(this.audio, this.settings.metronome); this.tuner = new TunerController(this.audio, this.settings.tunerA4); this.registerView(VIEW_TYPE_METRONOME_TUNER, (leaf) => new MetronomeTunerView(leaf, this)); this.addRibbonIcon("audio-waveform", "Open metronome & tuner", () => void this.activateView()); this.addCommand({ id: "open-view", name: "Open", callback: () => void this.activateView() }); this.addCommand({ id: "start-metronome", name: "Start metronome", callback: () => void this.metronome.start().catch((error) => new import_obsidian3.Notice(errorMessage(error))) }); this.addCommand({ id: "stop-metronome", name: "Stop metronome", callback: () => this.metronome.stop() }); this.addSettingTab(new MetronomeTunerSettingTab(this.app, this)); } onunload() { this.metronome.dispose(); this.tuner.dispose(); void this.audio.dispose(); } async activateView() { var _a; let leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_METRONOME_TUNER)[0]; if (!leaf) { leaf = (_a = this.app.workspace.getRightLeaf(false)) != null ? _a : this.app.workspace.getLeaf(true); await leaf.setViewState({ type: VIEW_TYPE_METRONOME_TUNER, active: true }); } await this.app.workspace.revealLeaf(leaf); } async updateSettings(settings) { const previousA4 = this.settings.tunerA4; this.settings = parseSettings(settings); this.metronome.updateSettings(this.settings.metronome); if (this.settings.tunerA4 !== previousA4) this.tuner.setA4(this.settings.tunerA4); const snapshot = structuredClone(this.settings); this.saveQueue = this.saveQueue.catch(() => void 0).then(() => this.saveData(snapshot)); await this.saveQueue; } async setBpm(bpm) { await this.updateSettings({ ...this.settings, metronome: { ...this.settings.metronome, bpm } }); } async setTone(tone) { await this.updateSettings({ ...this.settings, metronome: { ...this.settings.metronome, tone } }); } }; function errorMessage(error) { return error instanceof Error ? error.message : "Could not start the metronome."; }