"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; 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 __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); 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/i18n.ts var SUPPORTED_LOCALES = ["en", "de", "es", "fr", "it", "pt-BR", "nl", "pl", "hr", "zh-CN"]; var LANGUAGE_LOCALE_ORDER = ["de", "en", "es", "fr", "hr", "it", "nl", "pl", "pt-BR", "zh-CN"]; var LANGUAGE_NAMES = { de: "Deutsch", en: "English", es: "Espa\xF1ol", fr: "Fran\xE7ais", hr: "Hrvatski", it: "Italiano", nl: "Nederlands", pl: "Polski", "pt-BR": "Portugu\xEAs (Brasil)", "zh-CN": "\u7B80\u4F53\u4E2D\u6587" }; var en = { appName: "Tempo & tune", toolsAria: "Tempo and tuning tools", metronome: "Metronome", tuner: "Tuner", openView: "Open metronome & tuner", commandOpen: "Open", commandStart: "Start metronome", commandStop: "Stop metronome", keepTime: "Keep time", bpmAria: "{{bpm}} beats per minute", bpmUnit: "BPM", beatsInMeasure: "Beats in measure", startMetronome: "Start metronome", stopMetronome: "Stop metronome", tempo: "Tempo", decreaseTempo: "Decrease tempo", tempoBpm: "Tempo in beats per minute", increaseTempo: "Increase tempo", tempoSlider: "Tempo slider", tapTempo: "Tap tempo", meterSound: "Meter & sound", timeSignature: "Time signature", numerator: "Numerator", denominator: "Denominator", accentFirstBeat: "Accent first beat", clickSound: "Click sound", sound: "Sound", volume: "Volume", previewSound: "Preview sound", findPitch: "Find the pitch", tunerIntro: "Play one clear note near your microphone.", microphone: "Microphone", frequency: "Frequency", confidence: "Confidence", pitchAccuracy: "Pitch accuracy", noPitchDetected: "No pitch detected", waitingNote: "Waiting for a note", a4ReferenceHz: "A4 reference (Hz)", beatsMeasure: "{{count}} beats per measure", beatsMeasureAccented: "{{count}} beats per measure, first beat accented", micOtherView: "Microphone active in another Tempo & Tune view.", micRequesting: "Requesting microphone permission\u2026", micActive: "Microphone active.", micListening: "Listening for a steady note\u2026", micOff: "Microphone off. Start listening to tune.", micInUse: "Microphone in use", micCancel: "Cancel microphone request", micStop: "Stop listening", micTryAgain: "Try microphone again", micUse: "Use microphone", noSignal: "No signal", tunerInactive: "Tuner inactive", noSignalYet: "No signal yet", inTune: "In tune", centFlatOne: "{{count}} cent flat", centFlatFew: "{{count}} cents flat", centFlatMany: "{{count}} cents flat", centFlatOther: "{{count}} cents flat", centSharpOne: "{{count}} cent sharp", centSharpFew: "{{count}} cents sharp", centSharpMany: "{{count}} cents sharp", centSharpOther: "{{count}} cents sharp", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}}%", errorMetronome: "Could not start the metronome.", errorPreview: "Could not preview the click sound.", errorMediaUnavailable: "Microphone access is not available on this device.", errorPermission: "Microphone permission was denied. Allow microphone access in your system or Obsidian settings.", errorNoDevice: "No microphone was found. Connect an input device and try again.", errorBusy: "The microphone is busy or unavailable. Close other audio apps and try again.", errorConstraints: "The microphone does not support the requested audio settings.", errorAudioContext: "Could not start the audio context.", errorAudioStart: "Could not start audio.", errorDisconnected: "The microphone disconnected. Reconnect it and try again.", errorUnknown: "Could not start the microphone.", general: "General", language: "Language", languageDesc: "Automatic follows Obsidian's language. Restart Obsidian to apply changes.", automaticObsidian: "Automatic (Obsidian)", defaults: "Defaults", aboutSupport: "About & support", defaultTempo: "Default tempo", defaultTempoDesc: "Tempo used when Obsidian starts.", a4Reference: "A4 reference", a4ReferenceDesc: "Concert pitch in hertz.", meterNumerator: "Meter numerator", meterNumeratorDesc: "Number of beats in each measure.", meterDenominator: "Meter denominator", meterDenominatorDesc: "Note value represented by each beat.", accentFirstBeatDesc: "Emphasize the first beat of each measure.", clickVolume: "Click volume", clickVolumeDesc: "Set the metronome's startup volume.", clickSoundDesc: "Choose the metronome's startup sound.", portfolio: "Portfolio", portfolioDesc: "Selected work by Patrick Fanella.", visitPortfolio: "Visit portfolio", subcultDesc: "Music, film, and independent culture projects.", visitSubcult: "Visit Subcult", githubDesc: "Browse Patrick's open-source work.", viewGithub: "View GitHub", email: "Email", emailDesc: "Questions, feedback, or collaboration.", sendEmail: "Send email", toneWoodblock: "Woodblock", toneMechanical: "Mechanical", toneRimshot: "Rimshot", toneClaves: "Claves", toneCowbell: "Cowbell", toneSoftDigital: "Soft digital", toneSinePulse: "Sine pulse" }; var de = { ...en, general: "Allgemein", language: "Sprache", languageDesc: "Automatisch folgt der Sprache von Obsidian. Starte Obsidian neu, um \xC4nderungen anzuwenden.", automaticObsidian: "Automatisch (Obsidian)", appName: "Tempo & Stimmung", toolsAria: "Werkzeuge f\xFCr Tempo und Stimmung", metronome: "Metronom", tuner: "Stimmger\xE4t", openView: "Metronom & Stimmger\xE4t \xF6ffnen", commandOpen: "\xD6ffnen", commandStart: "Metronom starten", commandStop: "Metronom stoppen", keepTime: "Im Takt bleiben", bpmAria: "{{bpm}} Schl\xE4ge pro Minute", beatsInMeasure: "Schl\xE4ge im Takt", startMetronome: "Metronom starten", stopMetronome: "Metronom stoppen", tempo: "Tempo", decreaseTempo: "Tempo verringern", tempoBpm: "Tempo in Schl\xE4gen pro Minute", increaseTempo: "Tempo erh\xF6hen", tempoSlider: "Tempo-Regler", tapTempo: "Tempo tippen", meterSound: "Takt & Klang", timeSignature: "Taktart", numerator: "Z\xE4hler", denominator: "Nenner", accentFirstBeat: "Ersten Schlag betonen", clickSound: "Klickklang", sound: "Klang", volume: "Lautst\xE4rke", previewSound: "Klang anh\xF6ren", findPitch: "Tonh\xF6he finden", tunerIntro: "Spiele einen klaren Ton nahe am Mikrofon.", microphone: "Mikrofon", frequency: "Frequenz", confidence: "Sicherheit", pitchAccuracy: "Stimmgenauigkeit", noPitchDetected: "Keine Tonh\xF6he erkannt", waitingNote: "Warte auf einen Ton", a4ReferenceHz: "Kammerton A4 (Hz)", beatsMeasure: "{{count}} Schl\xE4ge pro Takt", beatsMeasureAccented: "{{count}} Schl\xE4ge pro Takt, erster Schlag betont", micOtherView: "Mikrofon ist in einer anderen Tempo-&-Stimmung-Ansicht aktiv.", micRequesting: "Mikrofonberechtigung wird angefragt\u2026", micActive: "Mikrofon aktiv.", micListening: "Warte auf einen gleichm\xE4\xDFigen Ton\u2026", micOff: "Mikrofon aus. Starte es zum Stimmen.", micInUse: "Mikrofon wird verwendet", micCancel: "Mikrofonanfrage abbrechen", micStop: "Zuh\xF6ren beenden", micTryAgain: "Mikrofon erneut versuchen", micUse: "Mikrofon verwenden", noSignal: "Kein Signal", tunerInactive: "Stimmger\xE4t inaktiv", noSignalYet: "Noch kein Signal", inTune: "Stimmt", centFlatOne: "{{count}} Cent zu tief", centFlatOther: "{{count}} Cent zu tief", centSharpOne: "{{count}} Cent zu hoch", centSharpOther: "{{count}} Cent zu hoch", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}} %", errorMetronome: "Metronom konnte nicht gestartet werden.", errorPreview: "Klickklang konnte nicht wiedergegeben werden.", errorMediaUnavailable: "Mikrofonzugriff ist auf diesem Ger\xE4t nicht verf\xFCgbar.", errorPermission: "Mikrofonzugriff wurde verweigert. Erlaube ihn in den System- oder Obsidian-Einstellungen.", errorNoDevice: "Kein Mikrofon gefunden. Schlie\xDFe ein Eingabeger\xE4t an und versuche es erneut.", errorBusy: "Das Mikrofon ist belegt oder nicht verf\xFCgbar. Schlie\xDFe andere Audio-Apps und versuche es erneut.", errorConstraints: "Das Mikrofon unterst\xFCtzt die angeforderten Audioeinstellungen nicht.", errorAudioContext: "Audiokontext konnte nicht gestartet werden.", errorAudioStart: "Audio konnte nicht gestartet werden.", errorDisconnected: "Das Mikrofon wurde getrennt. Verbinde es erneut und versuche es noch einmal.", errorUnknown: "Mikrofon konnte nicht gestartet werden.", defaults: "Standardwerte", aboutSupport: "Info & Support", defaultTempo: "Standardtempo", defaultTempoDesc: "Tempo beim Start von Obsidian.", a4Reference: "Kammerton A4", a4ReferenceDesc: "Kammerton in Hertz.", meterNumerator: "Taktz\xE4hler", meterNumeratorDesc: "Anzahl der Schl\xE4ge pro Takt.", meterDenominator: "Taktnenner", meterDenominatorDesc: "Notenwert eines Schlags.", accentFirstBeatDesc: "Betont den ersten Schlag jedes Takts.", clickVolume: "Klicklautst\xE4rke", clickVolumeDesc: "Startlautst\xE4rke des Metronoms.", clickSoundDesc: "Startklang des Metronoms.", portfolioDesc: "Ausgew\xE4hlte Arbeiten von Patrick Fanella.", visitPortfolio: "Portfolio besuchen", subcultDesc: "Projekte zu Musik, Film und unabh\xE4ngiger Kultur.", visitSubcult: "Subcult besuchen", githubDesc: "Patricks Open-Source-Arbeiten ansehen.", viewGithub: "GitHub \xF6ffnen", email: "E-Mail", emailDesc: "Fragen, Feedback oder Zusammenarbeit.", sendEmail: "E-Mail senden", toneWoodblock: "Holzblock", toneMechanical: "Mechanisch", toneRimshot: "Rimshot", toneClaves: "Claves", toneCowbell: "Kuhglocke", toneSoftDigital: "Sanft digital", toneSinePulse: "Sinusimpuls" }; var es = { ...en, general: "General", language: "Idioma", languageDesc: "Autom\xE1tico sigue el idioma de Obsidian. Reinicia Obsidian para aplicar los cambios.", automaticObsidian: "Autom\xE1tico (Obsidian)", appName: "Tempo y afinaci\xF3n", toolsAria: "Herramientas de tempo y afinaci\xF3n", metronome: "Metr\xF3nomo", tuner: "Afinador", openView: "Abrir metr\xF3nomo y afinador", commandOpen: "Abrir", commandStart: "Iniciar metr\xF3nomo", commandStop: "Detener metr\xF3nomo", keepTime: "Mant\xE9n el ritmo", bpmAria: "{{bpm}} pulsaciones por minuto", beatsInMeasure: "Pulsos del comp\xE1s", startMetronome: "Iniciar metr\xF3nomo", stopMetronome: "Detener metr\xF3nomo", tempo: "Tempo", decreaseTempo: "Reducir tempo", tempoBpm: "Tempo en pulsaciones por minuto", increaseTempo: "Aumentar tempo", tempoSlider: "Control de tempo", tapTempo: "Marcar tempo", meterSound: "Comp\xE1s y sonido", timeSignature: "Comp\xE1s", numerator: "Numerador", denominator: "Denominador", accentFirstBeat: "Acentuar primer pulso", clickSound: "Sonido del clic", sound: "Sonido", volume: "Volumen", previewSound: "Probar sonido", findPitch: "Encuentra la nota", tunerIntro: "Toca una nota clara cerca del micr\xF3fono.", microphone: "Micr\xF3fono", frequency: "Frecuencia", confidence: "Confianza", pitchAccuracy: "Precisi\xF3n de afinaci\xF3n", noPitchDetected: "No se detecta ninguna nota", waitingNote: "Esperando una nota", a4ReferenceHz: "Referencia A4 (Hz)", beatsMeasure: "{{count}} pulsos por comp\xE1s", beatsMeasureAccented: "{{count}} pulsos por comp\xE1s, primero acentuado", micOtherView: "El micr\xF3fono est\xE1 activo en otra vista de Tempo y afinaci\xF3n.", micRequesting: "Solicitando permiso para el micr\xF3fono\u2026", micActive: "Micr\xF3fono activo.", micListening: "Esperando una nota estable\u2026", micOff: "Micr\xF3fono apagado. Act\xEDvalo para afinar.", micInUse: "Micr\xF3fono en uso", micCancel: "Cancelar solicitud", micStop: "Dejar de escuchar", micTryAgain: "Reintentar micr\xF3fono", micUse: "Usar micr\xF3fono", noSignal: "Sin se\xF1al", tunerInactive: "Afinador inactivo", noSignalYet: "A\xFAn no hay se\xF1al", inTune: "Afinado", centFlatOne: "{{count}} cent bajo", centFlatOther: "{{count}} cents bajo", centSharpOne: "{{count}} cent alto", centSharpOther: "{{count}} cents alto", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}} %", errorMetronome: "No se pudo iniciar el metr\xF3nomo.", errorPreview: "No se pudo reproducir el clic.", errorMediaUnavailable: "El acceso al micr\xF3fono no est\xE1 disponible en este dispositivo.", errorPermission: "Se deneg\xF3 el permiso del micr\xF3fono. Perm\xEDtelo en los ajustes del sistema o de Obsidian.", errorNoDevice: "No se encontr\xF3 ning\xFAn micr\xF3fono. Conecta uno e int\xE9ntalo de nuevo.", errorBusy: "El micr\xF3fono est\xE1 ocupado o no disponible. Cierra otras aplicaciones de audio e int\xE9ntalo de nuevo.", errorConstraints: "El micr\xF3fono no admite los ajustes de audio solicitados.", errorAudioContext: "No se pudo iniciar el contexto de audio.", errorAudioStart: "No se pudo iniciar el audio.", errorDisconnected: "El micr\xF3fono se desconect\xF3. Vuelve a conectarlo e int\xE9ntalo de nuevo.", errorUnknown: "No se pudo iniciar el micr\xF3fono.", defaults: "Valores predeterminados", aboutSupport: "Acerca de y soporte", defaultTempo: "Tempo predeterminado", defaultTempoDesc: "Tempo usado al iniciar Obsidian.", a4Reference: "Referencia A4", a4ReferenceDesc: "Tono de concierto en hercios.", meterNumerator: "Numerador del comp\xE1s", meterNumeratorDesc: "N\xFAmero de pulsos por comp\xE1s.", meterDenominator: "Denominador del comp\xE1s", meterDenominatorDesc: "Valor de nota de cada pulso.", accentFirstBeatDesc: "Destaca el primer pulso de cada comp\xE1s.", clickVolume: "Volumen del clic", clickVolumeDesc: "Volumen inicial del metr\xF3nomo.", clickSoundDesc: "Sonido inicial del metr\xF3nomo.", portfolioDesc: "Trabajos seleccionados de Patrick Fanella.", visitPortfolio: "Visitar portafolio", subcultDesc: "Proyectos de m\xFAsica, cine y cultura independiente.", visitSubcult: "Visitar Subcult", githubDesc: "Explora el trabajo de c\xF3digo abierto de Patrick.", viewGithub: "Ver GitHub", email: "Correo electr\xF3nico", emailDesc: "Preguntas, comentarios o colaboraci\xF3n.", sendEmail: "Enviar correo", toneWoodblock: "Bloque de madera", toneMechanical: "Mec\xE1nico", toneRimshot: "Golpe de aro", toneClaves: "Claves", toneCowbell: "Cencerro", toneSoftDigital: "Digital suave", toneSinePulse: "Pulso sinusoidal" }; var fr = { ...en, general: "G\xE9n\xE9ral", language: "Langue", languageDesc: "Le mode automatique suit la langue d\u2019Obsidian. Red\xE9marrez Obsidian pour appliquer les modifications.", automaticObsidian: "Automatique (Obsidian)", appName: "Tempo et accordage", toolsAria: "Outils de tempo et d\u2019accordage", metronome: "M\xE9tronome", tuner: "Accordeur", openView: "Ouvrir le m\xE9tronome et l\u2019accordeur", commandOpen: "Ouvrir", commandStart: "D\xE9marrer le m\xE9tronome", commandStop: "Arr\xEAter le m\xE9tronome", keepTime: "Gardez le rythme", bpmAria: "{{bpm}} battements par minute", beatsInMeasure: "Temps dans la mesure", startMetronome: "D\xE9marrer le m\xE9tronome", stopMetronome: "Arr\xEAter le m\xE9tronome", tempo: "Tempo", decreaseTempo: "R\xE9duire le tempo", tempoBpm: "Tempo en battements par minute", increaseTempo: "Augmenter le tempo", tempoSlider: "Curseur de tempo", tapTempo: "Battre le tempo", meterSound: "Mesure et son", timeSignature: "Signature rythmique", numerator: "Num\xE9rateur", denominator: "D\xE9nominateur", accentFirstBeat: "Accentuer le premier temps", clickSound: "Son du clic", sound: "Son", volume: "Volume", previewSound: "\xC9couter le son", findPitch: "Trouvez la hauteur", tunerIntro: "Jouez une note claire pr\xE8s du microphone.", microphone: "Microphone", frequency: "Fr\xE9quence", confidence: "Fiabilit\xE9", pitchAccuracy: "Pr\xE9cision de l\u2019accordage", noPitchDetected: "Aucune hauteur d\xE9tect\xE9e", waitingNote: "En attente d\u2019une note", a4ReferenceHz: "R\xE9f\xE9rence A4 (Hz)", beatsMeasure: "{{count}} temps par mesure", beatsMeasureAccented: "{{count}} temps par mesure, premier temps accentu\xE9", micOtherView: "Le microphone est actif dans une autre vue Tempo et accordage.", micRequesting: "Demande d\u2019acc\xE8s au microphone\u2026", micActive: "Microphone actif.", micListening: "En attente d\u2019une note stable\u2026", micOff: "Microphone \xE9teint. Activez-le pour accorder.", micInUse: "Microphone utilis\xE9", micCancel: "Annuler la demande", micStop: "Arr\xEAter l\u2019\xE9coute", micTryAgain: "R\xE9essayer le microphone", micUse: "Utiliser le microphone", noSignal: "Aucun signal", tunerInactive: "Accordeur inactif", noSignalYet: "Pas encore de signal", inTune: "Juste", centFlatOne: "{{count}} cent trop bas", centFlatOther: "{{count}} cents trop bas", centSharpOne: "{{count}} cent trop haut", centSharpOther: "{{count}} cents trop haut", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}} %", errorMetronome: "Impossible de d\xE9marrer le m\xE9tronome.", errorPreview: "Impossible de lire le son du clic.", errorMediaUnavailable: "L\u2019acc\xE8s au microphone n\u2019est pas disponible sur cet appareil.", errorPermission: "L\u2019acc\xE8s au microphone a \xE9t\xE9 refus\xE9. Autorisez-le dans les r\xE9glages du syst\xE8me ou d\u2019Obsidian.", errorNoDevice: "Aucun microphone trouv\xE9. Connectez-en un et r\xE9essayez.", errorBusy: "Le microphone est occup\xE9 ou indisponible. Fermez les autres applications audio et r\xE9essayez.", errorConstraints: "Le microphone ne prend pas en charge les r\xE9glages audio demand\xE9s.", errorAudioContext: "Impossible de d\xE9marrer le contexte audio.", errorAudioStart: "Impossible de d\xE9marrer l\u2019audio.", errorDisconnected: "Le microphone a \xE9t\xE9 d\xE9connect\xE9. Reconnectez-le et r\xE9essayez.", errorUnknown: "Impossible de d\xE9marrer le microphone.", defaults: "Valeurs par d\xE9faut", aboutSupport: "\xC0 propos et assistance", defaultTempo: "Tempo par d\xE9faut", defaultTempoDesc: "Tempo utilis\xE9 au d\xE9marrage d\u2019Obsidian.", a4Reference: "R\xE9f\xE9rence A4", a4ReferenceDesc: "Diapason en hertz.", meterNumerator: "Num\xE9rateur de mesure", meterNumeratorDesc: "Nombre de temps par mesure.", meterDenominator: "D\xE9nominateur de mesure", meterDenominatorDesc: "Valeur de note de chaque temps.", accentFirstBeatDesc: "Accentue le premier temps de chaque mesure.", clickVolume: "Volume du clic", clickVolumeDesc: "Volume initial du m\xE9tronome.", clickSoundDesc: "Son initial du m\xE9tronome.", portfolioDesc: "S\xE9lection de travaux de Patrick Fanella.", visitPortfolio: "Voir le portfolio", subcultDesc: "Projets de musique, cin\xE9ma et culture ind\xE9pendante.", visitSubcult: "Visiter Subcult", githubDesc: "Parcourir les projets libres de Patrick.", viewGithub: "Voir GitHub", email: "E-mail", emailDesc: "Questions, commentaires ou collaboration.", sendEmail: "Envoyer un e-mail", toneWoodblock: "Bloc de bois", toneMechanical: "M\xE9canique", toneRimshot: "Rimshot", toneClaves: "Claves", toneCowbell: "Cloche", toneSoftDigital: "Num\xE9rique doux", toneSinePulse: "Impulsion sinuso\xEFdale" }; var it = { ...en, general: "Generale", language: "Lingua", languageDesc: "La modalit\xE0 automatica segue la lingua di Obsidian. Riavvia Obsidian per applicare le modifiche.", automaticObsidian: "Automatico (Obsidian)", appName: "Tempo e accordatura", toolsAria: "Strumenti per tempo e accordatura", metronome: "Metronomo", tuner: "Accordatore", openView: "Apri metronomo e accordatore", commandOpen: "Apri", commandStart: "Avvia metronomo", commandStop: "Ferma metronomo", keepTime: "Tieni il tempo", bpmAria: "{{bpm}} battiti al minuto", beatsInMeasure: "Battiti nella misura", startMetronome: "Avvia metronomo", stopMetronome: "Ferma metronomo", tempo: "Tempo", decreaseTempo: "Diminuisci tempo", tempoBpm: "Tempo in battiti al minuto", increaseTempo: "Aumenta tempo", tempoSlider: "Cursore del tempo", tapTempo: "Batti il tempo", meterSound: "Metro e suono", timeSignature: "Indicazione di tempo", numerator: "Numeratore", denominator: "Denominatore", accentFirstBeat: "Accentua il primo battito", clickSound: "Suono del clic", sound: "Suono", volume: "Volume", previewSound: "Ascolta il suono", findPitch: "Trova l\u2019intonazione", tunerIntro: "Suona una nota chiara vicino al microfono.", microphone: "Microfono", frequency: "Frequenza", confidence: "Affidabilit\xE0", pitchAccuracy: "Precisione dell\u2019intonazione", noPitchDetected: "Nessuna nota rilevata", waitingNote: "In attesa di una nota", a4ReferenceHz: "Riferimento A4 (Hz)", beatsMeasure: "{{count}} battiti per misura", beatsMeasureAccented: "{{count}} battiti per misura, primo accentato", micOtherView: "Il microfono \xE8 attivo in un\u2019altra vista Tempo e accordatura.", micRequesting: "Richiesta di accesso al microfono\u2026", micActive: "Microfono attivo.", micListening: "In ascolto di una nota stabile\u2026", micOff: "Microfono spento. Attivalo per accordare.", micInUse: "Microfono in uso", micCancel: "Annulla richiesta", micStop: "Interrompi ascolto", micTryAgain: "Riprova microfono", micUse: "Usa microfono", noSignal: "Nessun segnale", tunerInactive: "Accordatore inattivo", noSignalYet: "Ancora nessun segnale", inTune: "Accordato", centFlatOne: "{{count}} cent calante", centFlatOther: "{{count}} cent calanti", centSharpOne: "{{count}} cent crescente", centSharpOther: "{{count}} cent crescenti", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}}%", errorMetronome: "Impossibile avviare il metronomo.", errorPreview: "Impossibile riprodurre il clic.", errorMediaUnavailable: "L\u2019accesso al microfono non \xE8 disponibile su questo dispositivo.", errorPermission: "Accesso al microfono negato. Abilitalo nelle impostazioni di sistema o di Obsidian.", errorNoDevice: "Nessun microfono trovato. Collegane uno e riprova.", errorBusy: "Il microfono \xE8 occupato o non disponibile. Chiudi le altre app audio e riprova.", errorConstraints: "Il microfono non supporta le impostazioni audio richieste.", errorAudioContext: "Impossibile avviare il contesto audio.", errorAudioStart: "Impossibile avviare l\u2019audio.", errorDisconnected: "Il microfono \xE8 stato scollegato. Ricollegalo e riprova.", errorUnknown: "Impossibile avviare il microfono.", defaults: "Valori predefiniti", aboutSupport: "Informazioni e supporto", defaultTempo: "Tempo predefinito", defaultTempoDesc: "Tempo usato all\u2019avvio di Obsidian.", a4Reference: "Riferimento A4", a4ReferenceDesc: "Diapason in hertz.", meterNumerator: "Numeratore del metro", meterNumeratorDesc: "Numero di battiti per misura.", meterDenominator: "Denominatore del metro", meterDenominatorDesc: "Valore della nota di ogni battito.", accentFirstBeatDesc: "Enfatizza il primo battito di ogni misura.", clickVolume: "Volume del clic", clickVolumeDesc: "Volume iniziale del metronomo.", clickSoundDesc: "Suono iniziale del metronomo.", portfolioDesc: "Lavori selezionati di Patrick Fanella.", visitPortfolio: "Visita il portfolio", subcultDesc: "Progetti di musica, cinema e cultura indipendente.", visitSubcult: "Visita Subcult", githubDesc: "Esplora i progetti open source di Patrick.", viewGithub: "Vedi GitHub", emailDesc: "Domande, commenti o collaborazioni.", sendEmail: "Invia e-mail", toneWoodblock: "Blocco di legno", toneMechanical: "Meccanico", toneRimshot: "Rimshot", toneClaves: "Claves", toneCowbell: "Campanaccio", toneSoftDigital: "Digitale morbido", toneSinePulse: "Impulso sinusoidale" }; var ptBR = { ...en, general: "Geral", language: "Idioma", languageDesc: "O modo autom\xE1tico segue o idioma do Obsidian. Reinicie o Obsidian para aplicar as altera\xE7\xF5es.", automaticObsidian: "Autom\xE1tico (Obsidian)", appName: "Tempo e afina\xE7\xE3o", toolsAria: "Ferramentas de tempo e afina\xE7\xE3o", metronome: "Metr\xF4nomo", tuner: "Afinador", openView: "Abrir metr\xF4nomo e afinador", commandOpen: "Abrir", commandStart: "Iniciar metr\xF4nomo", commandStop: "Parar metr\xF4nomo", keepTime: "Mantenha o ritmo", bpmAria: "{{bpm}} batidas por minuto", beatsInMeasure: "Batidas no compasso", startMetronome: "Iniciar metr\xF4nomo", stopMetronome: "Parar metr\xF4nomo", tempo: "Andamento", decreaseTempo: "Diminuir andamento", tempoBpm: "Andamento em batidas por minuto", increaseTempo: "Aumentar andamento", tempoSlider: "Controle de andamento", tapTempo: "Marcar andamento", meterSound: "Compasso e som", timeSignature: "F\xF3rmula de compasso", numerator: "Numerador", denominator: "Denominador", accentFirstBeat: "Acentuar primeira batida", clickSound: "Som do clique", sound: "Som", volume: "Volume", previewSound: "Ouvir som", findPitch: "Encontre a afina\xE7\xE3o", tunerIntro: "Toque uma nota clara perto do microfone.", microphone: "Microfone", frequency: "Frequ\xEAncia", confidence: "Confian\xE7a", pitchAccuracy: "Precis\xE3o da afina\xE7\xE3o", noPitchDetected: "Nenhuma nota detectada", waitingNote: "Aguardando uma nota", a4ReferenceHz: "Refer\xEAncia A4 (Hz)", beatsMeasure: "{{count}} batidas por compasso", beatsMeasureAccented: "{{count}} batidas por compasso, primeira acentuada", micOtherView: "O microfone est\xE1 ativo em outra visualiza\xE7\xE3o de Tempo e afina\xE7\xE3o.", micRequesting: "Solicitando permiss\xE3o para o microfone\u2026", micActive: "Microfone ativo.", micListening: "Aguardando uma nota est\xE1vel\u2026", micOff: "Microfone desligado. Ative para afinar.", micInUse: "Microfone em uso", micCancel: "Cancelar solicita\xE7\xE3o", micStop: "Parar de ouvir", micTryAgain: "Tentar microfone novamente", micUse: "Usar microfone", noSignal: "Sem sinal", tunerInactive: "Afinador inativo", noSignalYet: "Ainda sem sinal", inTune: "Afinado", centFlatOne: "{{count}} cent abaixo", centFlatOther: "{{count}} cents abaixo", centSharpOne: "{{count}} cent acima", centSharpOther: "{{count}} cents acima", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}}%", errorMetronome: "N\xE3o foi poss\xEDvel iniciar o metr\xF4nomo.", errorPreview: "N\xE3o foi poss\xEDvel reproduzir o clique.", errorMediaUnavailable: "O acesso ao microfone n\xE3o est\xE1 dispon\xEDvel neste dispositivo.", errorPermission: "A permiss\xE3o do microfone foi negada. Permita o acesso nas configura\xE7\xF5es do sistema ou do Obsidian.", errorNoDevice: "Nenhum microfone encontrado. Conecte um dispositivo e tente novamente.", errorBusy: "O microfone est\xE1 ocupado ou indispon\xEDvel. Feche outros aplicativos de \xE1udio e tente novamente.", errorConstraints: "O microfone n\xE3o aceita as configura\xE7\xF5es de \xE1udio solicitadas.", errorAudioContext: "N\xE3o foi poss\xEDvel iniciar o contexto de \xE1udio.", errorAudioStart: "N\xE3o foi poss\xEDvel iniciar o \xE1udio.", errorDisconnected: "O microfone foi desconectado. Reconecte-o e tente novamente.", errorUnknown: "N\xE3o foi poss\xEDvel iniciar o microfone.", defaults: "Padr\xF5es", aboutSupport: "Sobre e suporte", defaultTempo: "Andamento padr\xE3o", defaultTempoDesc: "Andamento usado ao iniciar o Obsidian.", a4Reference: "Refer\xEAncia A4", a4ReferenceDesc: "Diapas\xE3o em hertz.", meterNumerator: "Numerador do compasso", meterNumeratorDesc: "N\xFAmero de batidas em cada compasso.", meterDenominator: "Denominador do compasso", meterDenominatorDesc: "Valor da nota de cada batida.", accentFirstBeatDesc: "Destaca a primeira batida de cada compasso.", clickVolume: "Volume do clique", clickVolumeDesc: "Volume inicial do metr\xF4nomo.", clickSoundDesc: "Som inicial do metr\xF4nomo.", portfolioDesc: "Trabalhos selecionados de Patrick Fanella.", visitPortfolio: "Visitar portf\xF3lio", subcultDesc: "Projetos de m\xFAsica, cinema e cultura independente.", visitSubcult: "Visitar Subcult", githubDesc: "Veja os projetos de c\xF3digo aberto de Patrick.", viewGithub: "Ver GitHub", email: "E-mail", emailDesc: "D\xFAvidas, coment\xE1rios ou colabora\xE7\xE3o.", sendEmail: "Enviar e-mail", toneWoodblock: "Bloco de madeira", toneMechanical: "Mec\xE2nico", toneRimshot: "Rimshot", toneClaves: "Claves", toneCowbell: "Agog\xF4", toneSoftDigital: "Digital suave", toneSinePulse: "Pulso senoidal" }; var nl = { ...en, general: "Algemeen", language: "Taal", languageDesc: "Automatisch volgt de taal van Obsidian. Start Obsidian opnieuw om wijzigingen toe te passen.", automaticObsidian: "Automatisch (Obsidian)", appName: "Tempo en stemming", toolsAria: "Hulpmiddelen voor tempo en stemming", metronome: "Metronoom", tuner: "Stemapparaat", openView: "Metronoom en stemapparaat openen", commandOpen: "Openen", commandStart: "Metronoom starten", commandStop: "Metronoom stoppen", keepTime: "Houd de maat", bpmAria: "{{bpm}} slagen per minuut", beatsInMeasure: "Slagen in de maat", startMetronome: "Metronoom starten", stopMetronome: "Metronoom stoppen", tempo: "Tempo", decreaseTempo: "Tempo verlagen", tempoBpm: "Tempo in slagen per minuut", increaseTempo: "Tempo verhogen", tempoSlider: "Temposchuif", tapTempo: "Tempo tikken", meterSound: "Maat en geluid", timeSignature: "Maatsoort", numerator: "Teller", denominator: "Noemer", accentFirstBeat: "Eerste tel benadrukken", clickSound: "Klikgeluid", sound: "Geluid", volume: "Volume", previewSound: "Geluid beluisteren", findPitch: "Vind de toonhoogte", tunerIntro: "Speel \xE9\xE9n heldere noot bij de microfoon.", microphone: "Microfoon", frequency: "Frequentie", confidence: "Zekerheid", pitchAccuracy: "Zuiverheid", noPitchDetected: "Geen toonhoogte gedetecteerd", waitingNote: "Wachten op een noot", a4ReferenceHz: "A4-referentie (Hz)", beatsMeasure: "{{count}} slagen per maat", beatsMeasureAccented: "{{count}} slagen per maat, eerste slag benadrukt", micOtherView: "Microfoon is actief in een andere Tempo en stemming-weergave.", micRequesting: "Microfoontoestemming aanvragen\u2026", micActive: "Microfoon actief.", micListening: "Luisteren naar een stabiele noot\u2026", micOff: "Microfoon uit. Start om te stemmen.", micInUse: "Microfoon in gebruik", micCancel: "Microfoonaanvraag annuleren", micStop: "Stoppen met luisteren", micTryAgain: "Microfoon opnieuw proberen", micUse: "Microfoon gebruiken", noSignal: "Geen signaal", tunerInactive: "Stemapparaat inactief", noSignalYet: "Nog geen signaal", inTune: "Zuiver", centFlatOne: "{{count}} cent te laag", centFlatOther: "{{count}} cent te laag", centSharpOne: "{{count}} cent te hoog", centSharpOther: "{{count}} cent te hoog", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}}%", errorMetronome: "De metronoom kon niet worden gestart.", errorPreview: "Het klikgeluid kon niet worden afgespeeld.", errorMediaUnavailable: "Microfoontoegang is niet beschikbaar op dit apparaat.", errorPermission: "Microfoontoegang is geweigerd. Sta deze toe in de systeem- of Obsidian-instellingen.", errorNoDevice: "Geen microfoon gevonden. Sluit er een aan en probeer opnieuw.", errorBusy: "De microfoon is bezet of niet beschikbaar. Sluit andere audio-apps en probeer opnieuw.", errorConstraints: "De microfoon ondersteunt de gevraagde audio-instellingen niet.", errorAudioContext: "De audiocontext kon niet worden gestart.", errorAudioStart: "Audio kon niet worden gestart.", errorDisconnected: "De microfoon is losgekoppeld. Sluit hem opnieuw aan en probeer het nogmaals.", errorUnknown: "De microfoon kon niet worden gestart.", defaults: "Standaardwaarden", aboutSupport: "Over en ondersteuning", defaultTempo: "Standaardtempo", defaultTempoDesc: "Tempo bij het starten van Obsidian.", a4Reference: "A4-referentie", a4ReferenceDesc: "Concerttoonhoogte in hertz.", meterNumerator: "Maatteller", meterNumeratorDesc: "Aantal slagen in elke maat.", meterDenominator: "Maatnoemer", meterDenominatorDesc: "Nootwaarde van elke slag.", accentFirstBeatDesc: "Benadruk de eerste slag van elke maat.", clickVolume: "Klikvolume", clickVolumeDesc: "Startvolume van de metronoom.", clickSoundDesc: "Startgeluid van de metronoom.", portfolioDesc: "Geselecteerd werk van Patrick Fanella.", visitPortfolio: "Portfolio bezoeken", subcultDesc: "Projecten rond muziek, film en onafhankelijke cultuur.", visitSubcult: "Subcult bezoeken", githubDesc: "Bekijk Patricks opensourcewerk.", viewGithub: "GitHub bekijken", email: "E-mail", emailDesc: "Vragen, feedback of samenwerking.", sendEmail: "E-mail sturen", toneWoodblock: "Houtblok", toneMechanical: "Mechanisch", toneRimshot: "Rimshot", toneClaves: "Claves", toneCowbell: "Koebel", toneSoftDigital: "Zacht digitaal", toneSinePulse: "Sinuspuls" }; var pl = { ...en, general: "Og\xF3lne", language: "J\u0119zyk", languageDesc: "Tryb automatyczny u\u017Cywa j\u0119zyka Obsidian. Uruchom ponownie Obsidian, aby zastosowa\u0107 zmiany.", automaticObsidian: "Automatycznie (Obsidian)", appName: "Tempo i strojenie", toolsAria: "Narz\u0119dzia tempa i strojenia", metronome: "Metronom", tuner: "Stroik", openView: "Otw\xF3rz metronom i stroik", commandOpen: "Otw\xF3rz", commandStart: "Uruchom metronom", commandStop: "Zatrzymaj metronom", keepTime: "Trzymaj rytm", bpmAria: "{{bpm}} uderze\u0144 na minut\u0119", beatsInMeasure: "Uderzenia w takcie", startMetronome: "Uruchom metronom", stopMetronome: "Zatrzymaj metronom", tempo: "Tempo", decreaseTempo: "Zmniejsz tempo", tempoBpm: "Tempo w uderzeniach na minut\u0119", increaseTempo: "Zwi\u0119ksz tempo", tempoSlider: "Suwak tempa", tapTempo: "Wystukaj tempo", meterSound: "Metrum i d\u017Awi\u0119k", timeSignature: "Metrum", numerator: "Licznik", denominator: "Mianownik", accentFirstBeat: "Akcentuj pierwsz\u0105 miar\u0119", clickSound: "D\u017Awi\u0119k klikni\u0119cia", sound: "D\u017Awi\u0119k", volume: "G\u0142o\u015Bno\u015B\u0107", previewSound: "Ods\u0142uchaj d\u017Awi\u0119k", findPitch: "Znajd\u017A wysoko\u015B\u0107", tunerIntro: "Zagraj wyra\u017Any d\u017Awi\u0119k blisko mikrofonu.", microphone: "Mikrofon", frequency: "Cz\u0119stotliwo\u015B\u0107", confidence: "Pewno\u015B\u0107", pitchAccuracy: "Dok\u0142adno\u015B\u0107 strojenia", noPitchDetected: "Nie wykryto wysoko\u015Bci", waitingNote: "Oczekiwanie na d\u017Awi\u0119k", a4ReferenceHz: "Wzorzec A4 (Hz)", beatsMeasure: "{{count}} uderze\u0144 w takcie", beatsMeasureAccented: "{{count}} uderze\u0144 w takcie, pierwsze akcentowane", micOtherView: "Mikrofon jest aktywny w innym widoku Tempo i strojenie.", micRequesting: "Pro\u015Bba o dost\u0119p do mikrofonu\u2026", micActive: "Mikrofon aktywny.", micListening: "Nas\u0142uchiwanie sta\u0142ego d\u017Awi\u0119ku\u2026", micOff: "Mikrofon wy\u0142\u0105czony. W\u0142\u0105cz go, aby stroi\u0107.", micInUse: "Mikrofon w u\u017Cyciu", micCancel: "Anuluj pro\u015Bb\u0119", micStop: "Przesta\u0144 s\u0142ucha\u0107", micTryAgain: "Spr\xF3buj ponownie", micUse: "U\u017Cyj mikrofonu", noSignal: "Brak sygna\u0142u", tunerInactive: "Stroik nieaktywny", noSignalYet: "Jeszcze brak sygna\u0142u", inTune: "Nastrojone", centFlatOne: "{{count}} cent za nisko", centFlatOther: "{{count}} cent\xF3w za nisko", centSharpOne: "{{count}} cent za wysoko", centSharpOther: "{{count}} cent\xF3w za wysoko", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}}%", errorMetronome: "Nie uda\u0142o si\u0119 uruchomi\u0107 metronomu.", errorPreview: "Nie uda\u0142o si\u0119 odtworzy\u0107 klikni\u0119cia.", errorMediaUnavailable: "Dost\u0119p do mikrofonu jest niedost\u0119pny na tym urz\u0105dzeniu.", errorPermission: "Odm\xF3wiono dost\u0119pu do mikrofonu. Zezw\xF3l na niego w ustawieniach systemu lub Obsidian.", errorNoDevice: "Nie znaleziono mikrofonu. Pod\u0142\u0105cz urz\u0105dzenie i spr\xF3buj ponownie.", errorBusy: "Mikrofon jest zaj\u0119ty lub niedost\u0119pny. Zamknij inne aplikacje audio i spr\xF3buj ponownie.", errorConstraints: "Mikrofon nie obs\u0142uguje \u017C\u0105danych ustawie\u0144 audio.", errorAudioContext: "Nie uda\u0142o si\u0119 uruchomi\u0107 kontekstu audio.", errorAudioStart: "Nie uda\u0142o si\u0119 uruchomi\u0107 d\u017Awi\u0119ku.", errorDisconnected: "Mikrofon zosta\u0142 od\u0142\u0105czony. Pod\u0142\u0105cz go i spr\xF3buj ponownie.", errorUnknown: "Nie uda\u0142o si\u0119 uruchomi\u0107 mikrofonu.", defaults: "Domy\u015Blne", aboutSupport: "Informacje i wsparcie", defaultTempo: "Domy\u015Blne tempo", defaultTempoDesc: "Tempo u\u017Cywane przy starcie Obsidian.", a4Reference: "Wzorzec A4", a4ReferenceDesc: "Str\xF3j koncertowy w hercach.", meterNumerator: "Licznik metrum", meterNumeratorDesc: "Liczba uderze\u0144 w takcie.", meterDenominator: "Mianownik metrum", meterDenominatorDesc: "Warto\u015B\u0107 nuty ka\u017Cdego uderzenia.", accentFirstBeatDesc: "Podkre\u015Bl pierwsze uderzenie ka\u017Cdego taktu.", clickVolume: "G\u0142o\u015Bno\u015B\u0107 klikni\u0119cia", clickVolumeDesc: "Pocz\u0105tkowa g\u0142o\u015Bno\u015B\u0107 metronomu.", clickSoundDesc: "Pocz\u0105tkowy d\u017Awi\u0119k metronomu.", portfolioDesc: "Wybrane prace Patricka Fanelli.", visitPortfolio: "Odwied\u017A portfolio", subcultDesc: "Projekty muzyczne, filmowe i kultury niezale\u017Cnej.", visitSubcult: "Odwied\u017A Subcult", githubDesc: "Zobacz projekty open source Patricka.", viewGithub: "Zobacz GitHub", email: "E-mail", emailDesc: "Pytania, opinie lub wsp\xF3\u0142praca.", sendEmail: "Wy\u015Blij e-mail", toneWoodblock: "Drewniany klocek", toneMechanical: "Mechaniczny", toneRimshot: "Rimshot", toneClaves: "Klawesy", toneCowbell: "Krowi dzwonek", toneSoftDigital: "\u0141agodny cyfrowy", toneSinePulse: "Impuls sinusoidalny" }; var hr = { ...en, general: "Op\u0107enito", language: "Jezik", languageDesc: "Automatski na\u010Din prati jezik Obsidiana. Ponovno pokrenite Obsidian kako biste primijenili promjene.", automaticObsidian: "Automatski (Obsidian)", appName: "Tempo i uga\u0111anje", toolsAria: "Alati za tempo i uga\u0111anje", metronome: "Metronom", tuner: "Uga\u0111alo", openView: "Otvori metronom i uga\u0111alo", commandOpen: "Otvori", commandStart: "Pokreni metronom", commandStop: "Zaustavi metronom", keepTime: "Dr\u017Eite ritam", bpmAria: "{{bpm}} otkucaja u minuti", beatsInMeasure: "Otkucaji u taktu", startMetronome: "Pokreni metronom", stopMetronome: "Zaustavi metronom", tempo: "Tempo", decreaseTempo: "Smanji tempo", tempoBpm: "Tempo u otkucajima u minuti", increaseTempo: "Pove\u0107aj tempo", tempoSlider: "Kliza\u010D tempa", tapTempo: "Istapkaj tempo", meterSound: "Mjera i zvuk", timeSignature: "Mjera", numerator: "Brojnik", denominator: "Nazivnik", accentFirstBeat: "Naglasite prvi otkucaj", clickSound: "Zvuk klika", sound: "Zvuk", volume: "Glasno\u0107a", previewSound: "Poslu\u0161aj zvuk", findPitch: "Prona\u0111ite visinu tona", tunerIntro: "Odsvirajte jasan ton blizu mikrofona.", microphone: "Mikrofon", frequency: "Frekvencija", confidence: "Pouzdanost", pitchAccuracy: "To\u010Dnost uga\u0111anja", noPitchDetected: "Visina tona nije prepoznata", waitingNote: "\u010Cekanje tona", a4ReferenceHz: "Referenca A4 (Hz)", beatsMeasure: "{{count}} otkucaja po taktu", beatsMeasureAccented: "{{count}} otkucaja po taktu, prvi nagla\u0161en", micOtherView: "Mikrofon je aktivan u drugom prikazu Tempo i uga\u0111anje.", micRequesting: "Tra\u017Eenje dopu\u0161tenja za mikrofon\u2026", micActive: "Mikrofon je aktivan.", micListening: "Slu\u0161anje stabilnog tona\u2026", micOff: "Mikrofon je isklju\u010Den. Uklju\u010Dite ga za uga\u0111anje.", micInUse: "Mikrofon se koristi", micCancel: "Otka\u017Ei zahtjev", micStop: "Prestani slu\u0161ati", micTryAgain: "Poku\u0161aj ponovno", micUse: "Koristi mikrofon", noSignal: "Nema signala", tunerInactive: "Uga\u0111alo nije aktivno", noSignalYet: "Jo\u0161 nema signala", inTune: "Ugo\u0111eno", centFlatOne: "{{count}} cent prenisko", centFlatOther: "{{count}} centi prenisko", centSharpOne: "{{count}} cent previsoko", centSharpOther: "{{count}} centi previsoko", readingAnnouncement: "{{note}}, {{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}} %", errorMetronome: "Metronom se nije mogao pokrenuti.", errorPreview: "Zvuk klika nije se mogao reproducirati.", errorMediaUnavailable: "Pristup mikrofonu nije dostupan na ovom ure\u0111aju.", errorPermission: "Dopu\u0161tenje za mikrofon je odbijeno. Omogu\u0107ite ga u postavkama sustava ili Obsidiana.", errorNoDevice: "Mikrofon nije prona\u0111en. Spojite ga i poku\u0161ajte ponovno.", errorBusy: "Mikrofon je zauzet ili nedostupan. Zatvorite druge audio aplikacije i poku\u0161ajte ponovno.", errorConstraints: "Mikrofon ne podr\u017Eava tra\u017Eene postavke zvuka.", errorAudioContext: "Audio kontekst nije se mogao pokrenuti.", errorAudioStart: "Zvuk se nije mogao pokrenuti.", errorDisconnected: "Mikrofon je odspojen. Ponovno ga spojite i poku\u0161ajte.", errorUnknown: "Mikrofon se nije mogao pokrenuti.", defaults: "Zadane vrijednosti", aboutSupport: "O dodatku i podr\u0161ka", defaultTempo: "Zadani tempo", defaultTempoDesc: "Tempo pri pokretanju Obsidiana.", a4Reference: "Referenca A4", a4ReferenceDesc: "Koncertna visina u hercima.", meterNumerator: "Brojnik mjere", meterNumeratorDesc: "Broj otkucaja u svakom taktu.", meterDenominator: "Nazivnik mjere", meterDenominatorDesc: "Notna vrijednost svakog otkucaja.", accentFirstBeatDesc: "Naglasite prvi otkucaj svakog takta.", clickVolume: "Glasno\u0107a klika", clickVolumeDesc: "Po\u010Detna glasno\u0107a metronoma.", clickSoundDesc: "Po\u010Detni zvuk metronoma.", portfolioDesc: "Odabrani radovi Patricka Fanelle.", visitPortfolio: "Posjeti portfolio", subcultDesc: "Projekti glazbe, filma i nezavisne kulture.", visitSubcult: "Posjeti Subcult", githubDesc: "Pregledajte Patrickove projekte otvorenog koda.", viewGithub: "Otvori GitHub", email: "E-po\u0161ta", emailDesc: "Pitanja, povratne informacije ili suradnja.", sendEmail: "Po\u0161alji e-po\u0161tu", toneWoodblock: "Drveni blok", toneMechanical: "Mehani\u010Dki", toneRimshot: "Rimshot", toneClaves: "Klave", toneCowbell: "Kravlje zvono", toneSoftDigital: "Blagi digitalni", toneSinePulse: "Sinusni impuls" }; var zhCN = { ...en, general: "\u5E38\u89C4", language: "\u8BED\u8A00", languageDesc: "\u81EA\u52A8\u6A21\u5F0F\u8DDF\u968F Obsidian \u7684\u8BED\u8A00\u3002\u91CD\u542F Obsidian \u540E\u66F4\u6539\u624D\u4F1A\u751F\u6548\u3002", automaticObsidian: "\u81EA\u52A8\uFF08Obsidian\uFF09", appName: "\u8282\u62CD\u4E0E\u8C03\u97F3", toolsAria: "\u8282\u62CD\u5668\u548C\u8C03\u97F3\u5DE5\u5177", metronome: "\u8282\u62CD\u5668", tuner: "\u8C03\u97F3\u5668", openView: "\u6253\u5F00\u8282\u62CD\u5668\u548C\u8C03\u97F3\u5668", commandOpen: "\u6253\u5F00", commandStart: "\u542F\u52A8\u8282\u62CD\u5668", commandStop: "\u505C\u6B62\u8282\u62CD\u5668", keepTime: "\u4FDD\u6301\u8282\u62CD", bpmAria: "\u6BCF\u5206\u949F {{bpm}} \u62CD", bpmUnit: "BPM", beatsInMeasure: "\u5C0F\u8282\u62CD\u6570", startMetronome: "\u542F\u52A8\u8282\u62CD\u5668", stopMetronome: "\u505C\u6B62\u8282\u62CD\u5668", tempo: "\u901F\u5EA6", decreaseTempo: "\u964D\u4F4E\u901F\u5EA6", tempoBpm: "\u6BCF\u5206\u949F\u62CD\u6570", increaseTempo: "\u63D0\u9AD8\u901F\u5EA6", tempoSlider: "\u901F\u5EA6\u6ED1\u5757", tapTempo: "\u70B9\u51FB\u6D4B\u901F", meterSound: "\u62CD\u53F7\u4E0E\u97F3\u8272", timeSignature: "\u62CD\u53F7", numerator: "\u5206\u5B50", denominator: "\u5206\u6BCD", accentFirstBeat: "\u91CD\u97F3\u7B2C\u4E00\u62CD", clickSound: "\u8282\u62CD\u97F3\u8272", sound: "\u97F3\u8272", volume: "\u97F3\u91CF", previewSound: "\u8BD5\u542C\u97F3\u8272", findPitch: "\u68C0\u6D4B\u97F3\u9AD8", tunerIntro: "\u8BF7\u5728\u9EA6\u514B\u98CE\u9644\u8FD1\u6E05\u6670\u5730\u6F14\u594F\u4E00\u4E2A\u97F3\u3002", microphone: "\u9EA6\u514B\u98CE", frequency: "\u9891\u7387", confidence: "\u7F6E\u4FE1\u5EA6", pitchAccuracy: "\u97F3\u51C6", noPitchDetected: "\u672A\u68C0\u6D4B\u5230\u97F3\u9AD8", waitingNote: "\u7B49\u5F85\u97F3\u7B26", a4ReferenceHz: "A4 \u57FA\u51C6\uFF08Hz\uFF09", beatsMeasure: "\u6BCF\u5C0F\u8282 {{count}} \u62CD", beatsMeasureAccented: "\u6BCF\u5C0F\u8282 {{count}} \u62CD\uFF0C\u7B2C\u4E00\u62CD\u91CD\u97F3", micOtherView: "\u9EA6\u514B\u98CE\u5DF2\u5728\u53E6\u4E00\u4E2A\u201C\u8282\u62CD\u4E0E\u8C03\u97F3\u201D\u89C6\u56FE\u4E2D\u542F\u7528\u3002", micRequesting: "\u6B63\u5728\u8BF7\u6C42\u9EA6\u514B\u98CE\u6743\u9650\u2026", micActive: "\u9EA6\u514B\u98CE\u5DF2\u542F\u7528\u3002", micListening: "\u6B63\u5728\u7B49\u5F85\u7A33\u5B9A\u7684\u97F3\u7B26\u2026", micOff: "\u9EA6\u514B\u98CE\u5DF2\u5173\u95ED\u3002\u8BF7\u542F\u52A8\u4EE5\u8FDB\u884C\u8C03\u97F3\u3002", micInUse: "\u9EA6\u514B\u98CE\u6B63\u5728\u4F7F\u7528", micCancel: "\u53D6\u6D88\u9EA6\u514B\u98CE\u8BF7\u6C42", micStop: "\u505C\u6B62\u76D1\u542C", micTryAgain: "\u91CD\u8BD5\u9EA6\u514B\u98CE", micUse: "\u4F7F\u7528\u9EA6\u514B\u98CE", noSignal: "\u65E0\u4FE1\u53F7", tunerInactive: "\u8C03\u97F3\u5668\u672A\u542F\u7528", noSignalYet: "\u5C1A\u65E0\u4FE1\u53F7", inTune: "\u97F3\u51C6\u6B63\u786E", centFlatOne: "\u4F4E {{count}} \u97F3\u5206", centFlatOther: "\u4F4E {{count}} \u97F3\u5206", centSharpOne: "\u9AD8 {{count}} \u97F3\u5206", centSharpOther: "\u9AD8 {{count}} \u97F3\u5206", readingAnnouncement: "{{note}}\uFF0C{{cents}}", frequencyValue: "{{value}} Hz", percentValue: "{{value}}%", errorMetronome: "\u65E0\u6CD5\u542F\u52A8\u8282\u62CD\u5668\u3002", errorPreview: "\u65E0\u6CD5\u8BD5\u542C\u8282\u62CD\u97F3\u8272\u3002", errorMediaUnavailable: "\u6B64\u8BBE\u5907\u4E0D\u652F\u6301\u9EA6\u514B\u98CE\u8BBF\u95EE\u3002", errorPermission: "\u9EA6\u514B\u98CE\u6743\u9650\u88AB\u62D2\u7EDD\u3002\u8BF7\u5728\u7CFB\u7EDF\u6216 Obsidian \u8BBE\u7F6E\u4E2D\u5141\u8BB8\u8BBF\u95EE\u3002", errorNoDevice: "\u672A\u627E\u5230\u9EA6\u514B\u98CE\u3002\u8BF7\u8FDE\u63A5\u8F93\u5165\u8BBE\u5907\u540E\u91CD\u8BD5\u3002", errorBusy: "\u9EA6\u514B\u98CE\u6B63\u5FD9\u6216\u4E0D\u53EF\u7528\u3002\u8BF7\u5173\u95ED\u5176\u4ED6\u97F3\u9891\u5E94\u7528\u540E\u91CD\u8BD5\u3002", errorConstraints: "\u9EA6\u514B\u98CE\u4E0D\u652F\u6301\u8BF7\u6C42\u7684\u97F3\u9891\u8BBE\u7F6E\u3002", errorAudioContext: "\u65E0\u6CD5\u542F\u52A8\u97F3\u9891\u4E0A\u4E0B\u6587\u3002", errorAudioStart: "\u65E0\u6CD5\u542F\u52A8\u97F3\u9891\u3002", errorDisconnected: "\u9EA6\u514B\u98CE\u5DF2\u65AD\u5F00\u3002\u8BF7\u91CD\u65B0\u8FDE\u63A5\u540E\u91CD\u8BD5\u3002", errorUnknown: "\u65E0\u6CD5\u542F\u52A8\u9EA6\u514B\u98CE\u3002", defaults: "\u9ED8\u8BA4\u8BBE\u7F6E", aboutSupport: "\u5173\u4E8E\u4E0E\u652F\u6301", defaultTempo: "\u9ED8\u8BA4\u901F\u5EA6", defaultTempoDesc: "Obsidian \u542F\u52A8\u65F6\u4F7F\u7528\u7684\u901F\u5EA6\u3002", a4Reference: "A4 \u57FA\u51C6", a4ReferenceDesc: "\u4EE5\u8D6B\u5179\u8868\u793A\u7684\u6807\u51C6\u97F3\u9AD8\u3002", meterNumerator: "\u62CD\u53F7\u5206\u5B50", meterNumeratorDesc: "\u6BCF\u5C0F\u8282\u7684\u62CD\u6570\u3002", meterDenominator: "\u62CD\u53F7\u5206\u6BCD", meterDenominatorDesc: "\u6BCF\u62CD\u5BF9\u5E94\u7684\u97F3\u7B26\u65F6\u503C\u3002", accentFirstBeatDesc: "\u5F3A\u8C03\u6BCF\u5C0F\u8282\u7684\u7B2C\u4E00\u62CD\u3002", clickVolume: "\u8282\u62CD\u97F3\u91CF", clickVolumeDesc: "\u8BBE\u7F6E\u8282\u62CD\u5668\u7684\u521D\u59CB\u97F3\u91CF\u3002", clickSoundDesc: "\u9009\u62E9\u8282\u62CD\u5668\u7684\u521D\u59CB\u97F3\u8272\u3002", portfolio: "\u4F5C\u54C1\u96C6", portfolioDesc: "Patrick Fanella \u7684\u7CBE\u9009\u4F5C\u54C1\u3002", visitPortfolio: "\u8BBF\u95EE\u4F5C\u54C1\u96C6", subcultDesc: "\u97F3\u4E50\u3001\u7535\u5F71\u548C\u72EC\u7ACB\u6587\u5316\u9879\u76EE\u3002", visitSubcult: "\u8BBF\u95EE Subcult", githubDesc: "\u6D4F\u89C8 Patrick \u7684\u5F00\u6E90\u4F5C\u54C1\u3002", viewGithub: "\u67E5\u770B GitHub", email: "\u7535\u5B50\u90AE\u4EF6", emailDesc: "\u95EE\u9898\u3001\u53CD\u9988\u6216\u5408\u4F5C\u3002", sendEmail: "\u53D1\u9001\u90AE\u4EF6", toneWoodblock: "\u6728\u9C7C", toneMechanical: "\u673A\u68B0", toneRimshot: "\u9F13\u8FB9", toneClaves: "\u54CD\u68D2", toneCowbell: "\u725B\u94C3", toneSoftDigital: "\u67D4\u548C\u6570\u7801", toneSinePulse: "\u6B63\u5F26\u8109\u51B2" }; var DICTIONARIES = { en, de, es: { ...es, portfolio: "Portafolio" }, fr, it: { ...it, email: "E-mail" }, "pt-BR": { ...ptBR, portfolio: "Portf\xF3lio" }, nl, pl: { ...pl, centFlatFew: "{{count}} centy za nisko", centFlatMany: "{{count}} cent\xF3w za nisko", centSharpFew: "{{count}} centy za wysoko", centSharpMany: "{{count}} cent\xF3w za wysoko" }, hr, "zh-CN": zhCN }; function resolveLocale(language) { var _a; if (!language) return "en"; const normalized = language.replaceAll("_", "-"); const exact = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === normalized.toLowerCase()); if (exact) return exact; const lower = normalized.toLowerCase(); if (lower === "zh-tw" || lower.startsWith("zh-tw-") || lower === "zh-hk" || lower.startsWith("zh-hk-") || lower === "zh-hant" || lower.startsWith("zh-hant-")) return "en"; if (lower === "zh" || lower.startsWith("zh-")) return "zh-CN"; if (lower === "pt" || lower === "pt-br" || lower.startsWith("pt-br-")) return "pt-BR"; if (lower.startsWith("pt-")) return "en"; const base = lower.split("-")[0]; return (_a = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === base)) != null ? _a : "en"; } function createTranslator(locale = "en") { const dictionary = DICTIONARIES[locale]; const numberFormatters = /* @__PURE__ */ new Map(); return { locale, t(key, values = {}) { return dictionary[key].replace(/\{\{(\w+)\}\}/g, (placeholder, name) => { const value = values[name]; return value === void 0 ? placeholder : String(value); }); }, number(value, options = {}) { const cacheKey = JSON.stringify(options); let formatter = numberFormatters.get(cacheKey); if (!formatter) { formatter = new Intl.NumberFormat(locale, options); numberFormatters.set(cacheKey, formatter); } return formatter.format(value); } }; } var BEAT_MESSAGES = { en: { one: "{{count}} beat per measure", oneAccented: "{{count}} beat per measure, first beat accented" }, de: { one: "{{count}} Schlag pro Takt", oneAccented: "{{count}} Schlag pro Takt, erster Schlag betont" }, es: { one: "{{count}} pulso por comp\xE1s", oneAccented: "{{count}} pulso por comp\xE1s, primero acentuado" }, fr: { one: "{{count}} temps par mesure", oneAccented: "{{count}} temps par mesure, premier temps accentu\xE9" }, it: { one: "{{count}} battito per misura", oneAccented: "{{count}} battito per misura, primo accentato" }, "pt-BR": { one: "{{count}} batida por compasso", oneAccented: "{{count}} batida por compasso, primeira acentuada" }, nl: { one: "{{count}} slag per maat", oneAccented: "{{count}} slag per maat, eerste slag benadrukt" }, pl: { one: "{{count}} uderzenie w takcie", oneAccented: "{{count}} uderzenie w takcie, pierwsze akcentowane", few: "{{count}} uderzenia w takcie", fewAccented: "{{count}} uderzenia w takcie, pierwsze akcentowane" }, hr: { one: "{{count}} otkucaj po taktu", oneAccented: "{{count}} otkucaj po taktu, prvi nagla\u0161en" }, "zh-CN": { one: "\u6BCF\u5C0F\u8282 {{count}} \u62CD", oneAccented: "\u6BCF\u5C0F\u8282 {{count}} \u62CD\uFF0C\u7B2C\u4E00\u62CD\u91CD\u97F3" } }; function beatMeasureMessage(i18n, count, accented) { const pluralCategory = new Intl.PluralRules(i18n.locale).select(count); const messages = BEAT_MESSAGES[i18n.locale]; const fewMessage = messages[accented ? "fewAccented" : "few"]; if (pluralCategory === "few" && fewMessage) { return fewMessage.replace("{{count}}", i18n.number(count)); } if (pluralCategory !== "one") { return i18n.t(accented ? "beatsMeasureAccented" : "beatsMeasure", { count: i18n.number(count) }); } const message = messages[accented ? "oneAccented" : "one"]; return message.replace("{{count}}", i18n.number(count)); } function centsDescription(i18n, cents) { if (Math.abs(cents) <= 2) return i18n.t("inTune"); const amount = Math.abs(Math.round(cents)); const pluralCategory = new Intl.PluralRules(i18n.locale).select(amount); const category = pluralCategory === "one" ? "one" : i18n.locale === "pl" && (pluralCategory === "few" || pluralCategory === "many") ? pluralCategory : "other"; const direction = cents < 0 ? "flat" : "sharp"; return i18n.t(CENT_KEYS[direction][category], { count: i18n.number(amount) }); } var CENT_KEYS = { flat: { one: "centFlatOne", few: "centFlatFew", many: "centFlatMany", other: "centFlatOther" }, sharp: { one: "centSharpOne", few: "centSharpFew", many: "centSharpMany", other: "centSharpOther" } }; var TONE_KEYS = { woodblock: "toneWoodblock", mechanical: "toneMechanical", rimshot: "toneRimshot", claves: "toneClaves", cowbell: "toneCowbell", "soft-digital": "toneSoftDigital", "sine-pulse": "toneSinePulse" }; var TUNER_ERROR_KEYS = { mediaUnavailable: "errorMediaUnavailable", permission: "errorPermission", noDevice: "errorNoDevice", busy: "errorBusy", unsupportedConstraints: "errorConstraints", audioContext: "errorAudioContext", audioStart: "errorAudioStart", disconnected: "errorDisconnected", unknown: "errorUnknown" }; function toneName(i18n, id) { return i18n.t(TONE_KEYS[id]); } function tunerErrorMessage(i18n, code) { return i18n.t(TUNER_ERROR_KEYS[code]); } // src/i18n/detectLocale.ts var Obsidian = __toESM(require("obsidian"), 1); function resolveLanguagePreference(preference, detector = detectLocale) { return preference === "auto" ? detector() : preference; } function detectLocale() { var _a; try { const languageApi = Reflect.get(Obsidian, "getLanguage"); const language = isLanguageSource(languageApi) ? languageApi() : void 0; if (typeof language === "string" && language) return resolveLocale(language); } catch (e) { } try { const language = typeof ((_a = Obsidian.moment) == null ? void 0 : _a.locale) === "function" ? Obsidian.moment.locale() : void 0; if (language) return resolveLocale(language); } catch (e) { } try { if (typeof navigator !== "undefined" && navigator.language) return resolveLocale(navigator.language); } catch (e) { } return "en"; } function isLanguageSource(value) { return typeof value === "function"; } // src/metronome/TonePresets.ts var TONE_PRESETS = [ { id: "woodblock", waveform: "sine", frequency: 780, accentFrequency: 1050, duration: 0.055, attack: 2e-3, brightness: 0.45 }, { id: "mechanical", waveform: "square", frequency: 1100, accentFrequency: 1450, duration: 0.025, attack: 1e-3, brightness: 0.3 }, { id: "rimshot", waveform: "sawtooth", frequency: 520, accentFrequency: 720, duration: 0.04, attack: 1e-3, brightness: 0.8 }, { id: "claves", waveform: "triangle", frequency: 1750, accentFrequency: 2100, duration: 0.035, attack: 1e-3, brightness: 0.65 }, { id: "cowbell", waveform: "square", frequency: 560, accentFrequency: 845, duration: 0.09, attack: 2e-3, brightness: 0.4 }, { id: "soft-digital", waveform: "triangle", frequency: 880, accentFrequency: 1175, duration: 0.08, attack: 6e-3, brightness: 0.15 }, { id: "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 = { language: "auto", 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 { language: isLanguagePreference(value.language) ? value.language : DEFAULT_SETTINGS.language, 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); } function isLanguagePreference(value) { return value === "auto" || typeof value === "string" && SUPPORTED_LOCALES.some((locale) => locale === 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", descriptionKey: "portfolioDesc", labelKey: "visitPortfolio", href: "https://patrickfanella.co", external: true }, { name: "Subcult", descriptionKey: "subcultDesc", labelKey: "visitSubcult", href: "https://subcult.tv", external: true }, { name: "GitHub", descriptionKey: "githubDesc", labelKey: "viewGithub", href: "https://github.com/patrickfanella", external: true }, { name: "email", descriptionKey: "emailDesc", labelKey: "sendEmail", 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: this.plugin.i18n.t("general"), items: [this.getLanguageDefinition()] }, { type: "group", heading: this.plugin.i18n.t("defaults"), items: this.getDefaultDefinitions() }, { type: "group", heading: this.plugin.i18n.t("aboutSupport"), items: ABOUT_LINKS.map((link) => ({ name: this.aboutLinkName(link), desc: this.plugin.i18n.t(link.descriptionKey), render: (setting) => this.configureAboutLink(setting, link) })) } ]; } display() { this.containerEl.empty(); new import_obsidian.Setting(this.containerEl).setName(this.plugin.i18n.t("general")).setHeading(); this.configureLanguage(new import_obsidian.Setting(this.containerEl).setName(this.plugin.i18n.t("language")).setDesc(this.plugin.i18n.t("languageDesc"))); new import_obsidian.Setting(this.containerEl).setName(this.plugin.i18n.t("defaults")).setHeading(); this.addDefaultSettings(); new import_obsidian.Setting(this.containerEl).setName(this.plugin.i18n.t("aboutSupport")).setHeading(); for (const link of ABOUT_LINKS) { this.configureAboutLink(new import_obsidian.Setting(this.containerEl), link); } } getLanguageDefinition() { const name = this.plugin.i18n.t("language"); const description = this.plugin.i18n.t("languageDesc"); return { name, desc: description, render: (setting) => { setting.setName(name).setDesc(description); this.configureLanguage(setting); } }; } getDefaultDefinitions() { return this.getDefaultSettings().map((spec) => ({ name: spec.name, desc: spec.description, render: (setting) => { setting.setName(spec.name).setDesc(spec.description); spec.configure(setting); } })); } configureLanguage(setting) { setting.addDropdown((dropdown) => { dropdown.addOption("auto", this.plugin.i18n.t("automaticObsidian")); for (const locale of LANGUAGE_LOCALE_ORDER) { dropdown.addOption(locale, LANGUAGE_NAMES[locale]); } dropdown.setValue(this.plugin.settings.language).onChange((value) => { if (isLanguagePreference(value)) { void this.plugin.updateSettings({ ...this.plugin.settings, language: value }); } }); }); } 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: this.plugin.i18n.t("defaultTempo"), description: this.plugin.i18n.t("defaultTempoDesc"), configure: (setting) => this.configureDefaultTempo(setting) }, { name: this.plugin.i18n.t("a4Reference"), description: this.plugin.i18n.t("a4ReferenceDesc"), configure: (setting) => this.configureA4Reference(setting) }, { name: this.plugin.i18n.t("meterNumerator"), description: this.plugin.i18n.t("meterNumeratorDesc"), configure: (setting) => this.configureMeterNumerator(setting) }, { name: this.plugin.i18n.t("meterDenominator"), description: this.plugin.i18n.t("meterDenominatorDesc"), configure: (setting) => this.configureMeterDenominator(setting) }, { name: this.plugin.i18n.t("accentFirstBeat"), description: this.plugin.i18n.t("accentFirstBeatDesc"), configure: (setting) => this.configureAccent(setting) }, { name: this.plugin.i18n.t("clickVolume"), description: this.plugin.i18n.t("clickVolumeDesc"), configure: (setting) => this.configureClickVolume(setting) }, { name: this.plugin.i18n.t("clickSound"), description: this.plugin.i18n.t("clickSoundDesc"), 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, toneName(this.plugin.i18n, preset.id)); } 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(this.aboutLinkName(link)).setDesc(this.plugin.i18n.t(link.descriptionKey)); const anchor = setting.controlEl.createEl("a", { text: this.plugin.i18n.t(link.labelKey), href: link.href }); if (link.external) { anchor.setAttrs({ target: "_blank", rel: "noopener noreferrer" }); } } aboutLinkName(link) { return link.name === "Subcult" || link.name === "GitHub" ? link.name : this.plugin.i18n.t(link.name); } }; 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 TunerStartError = class extends Error { constructor(code) { super(code); __publicField(this, "code", code); } }; 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 (typeof navigator === "undefined" || !((_a = navigator.mediaDevices) == null ? void 0 : _a.getUserMedia)) throw new TunerStartError("mediaUnavailable"); 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) { console.error("Could not start tuner audio context", contextResult.error); throw new TunerStartError("audioContext"); } const context = contextResult.context; if (!context) throw new TunerStartError("audioStart"); 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 = categorizeTunerError(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 = "disconnected"; } }; function sanitizeA4(value) { return Number.isFinite(value) ? Math.min(466, Math.max(415, value)) : DEFAULT_A4; } function categorizeTunerError(error) { if (error instanceof TunerStartError) return error.code; if (error instanceof DOMException) { if (error.name === "NotAllowedError" || error.name === "SecurityError") return "permission"; if (error.name === "NotFoundError" || error.name === "DevicesNotFoundError") return "noDevice"; if (error.name === "NotReadableError" || error.name === "TrackStartError") return "busy"; if (error.name === "OverconstrainedError") return "unsupportedConstraints"; } return "unknown"; } // 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 this.plugin.i18n.t("appName"); } 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: this.plugin.i18n.t("appName") }); const tabList = root.createDiv({ cls: "tempo-tune-tabs", attr: { role: "tablist", "aria-label": this.plugin.i18n.t("toolsAria") } }); 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: this.plugin.i18n.t(tabName), 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: this.plugin.i18n.t("keepTime") }); const hero = panel.createDiv({ cls: "metronome-hero" }); const readout = hero.createDiv({ cls: "bpm-readout", attr: { "aria-label": this.plugin.i18n.t("bpmAria", { bpm: this.plugin.i18n.number(this.plugin.settings.metronome.bpm) }) } }); readout.createSpan({ cls: "bpm-value", text: this.plugin.i18n.number(this.plugin.settings.metronome.bpm), attr: { "data-bpm-readout": "" } }); readout.createSpan({ cls: "bpm-unit", text: this.plugin.i18n.t("bpmUnit") }); const beatGroup = hero.createDiv({ cls: "beat-group", attr: { "aria-label": this.plugin.i18n.t("beatsInMeasure") } }); this.renderBeatMarkers(beatGroup); this.startButton = panel.createEl("button", { cls: "metronome-start mod-cta", text: this.plugin.i18n.t(this.plugin.metronome.running ? "stopMetronome" : "startMetronome"), 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: this.plugin.i18n.t("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": this.plugin.i18n.t("decreaseTempo") } }); const bpm = stepper.createEl("input", { type: "number", attr: { "aria-label": this.plugin.i18n.t("tempoBpm"), 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": this.plugin.i18n.t("increaseTempo") } }); const range = tempoGroup.createEl("input", { type: "range", cls: "tempo-range", attr: { "aria-label": this.plugin.i18n.t("tempoSlider") } }); 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: this.plugin.i18n.t("tapTempo"), 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: this.plugin.i18n.t("meterSound") }); const secondary = details.createDiv({ cls: "metronome-secondary" }); const meterFieldset = secondary.createEl("fieldset", { cls: "meter-settings" }); meterFieldset.createEl("legend", { text: this.plugin.i18n.t("timeSignature") }); const meterRow = meterFieldset.createDiv({ cls: "meter-row" }); const numerator = this.createLabeledInput(meterRow, this.plugin.i18n.t("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: this.plugin.i18n.t("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: this.plugin.i18n.t("accentFirstBeat") }); 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: this.plugin.i18n.t("clickSound") }); const toneLabel = soundFieldset.createEl("label", { cls: "field" }); toneLabel.createSpan({ text: this.plugin.i18n.t("sound") }); const tone = toneLabel.createEl("select"); for (const preset of TONE_PRESETS) tone.createEl("option", { text: toneName(this.plugin.i18n, preset.id), 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: this.plugin.i18n.t("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: this.plugin.i18n.t("previewSound"), attr: { type: "button" } }); this.listen(preview, "click", () => void this.plugin.metronome.preview().catch((error) => this.showAudioError(error, "errorPreview"))); } renderTuner(panel) { panel.addClass("tuner-panel"); panel.createEl("p", { cls: "tempo-tune-kicker", text: this.plugin.i18n.t("findPitch") }); panel.createEl("p", { cls: "tuner-intro", text: this.plugin.i18n.t("tunerIntro") }); const micCard = panel.createDiv({ cls: "microphone-card" }); const micCopy = micCard.createDiv(); micCopy.createSpan({ cls: "microphone-label", text: this.plugin.i18n.t("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: this.plugin.i18n.t("frequency") }); const confidence = measurements.createDiv(); confidence.createSpan({ cls: "measurement-value", text: "\u2014", attr: { "data-confidence": "" } }); confidence.createSpan({ cls: "measurement-label", text: this.plugin.i18n.t("confidence") }); const gauge = panel.createDiv({ cls: "tuner-gauge" }); const scale = gauge.createDiv({ cls: "gauge-scale", attr: { role: "meter", "aria-label": this.plugin.i18n.t("pitchAccuracy"), "aria-valuemin": "-50", "aria-valuemax": "50", "aria-valuenow": "0", "aria-valuetext": this.plugin.i18n.t("noPitchDetected"), "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: this.plugin.i18n.t("waitingNote"), attr: { "data-cents-description": "" } }); const reference = panel.createDiv({ cls: "tuner-reference" }); const a4 = this.createLabeledInput(reference, this.plugin.i18n.t("a4ReferenceHz"), "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, "errorMetronome"); } } 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(this.plugin.i18n.t(state.running ? "stopMetronome" : "startMetronome")); 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", beatMeasureMessage(this.plugin.i18n, meterNumerator, accent)); 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 ? this.plugin.i18n.t("micOtherView") : error ? tunerErrorMessage(this.plugin.i18n, error) : status === "starting" ? this.plugin.i18n.t("micRequesting") : status === "listening" ? reading ? this.plugin.i18n.t("micActive") : this.plugin.i18n.t("micListening") : this.plugin.i18n.t("micOff"); if (statusText !== this.lastTunerStatus) { statusElement.setText(statusText); this.lastTunerStatus = statusText; } button.disabled = activeElsewhere; button.setText(this.plugin.i18n.t(activeElsewhere ? "micInUse" : status === "starting" ? "micCancel" : status === "listening" ? "micStop" : status === "error" ? "micTryAgain" : "micUse")); 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", this.plugin.i18n.t(status === "listening" ? "noSignal" : "tunerInactive")); description.setText(this.plugin.i18n.t(status === "listening" ? "noSignalYet" : "waitingNote")); 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(this.plugin.i18n.t("frequencyValue", { value: this.plugin.i18n.number(reading.frequency, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) })); confidence.setText(this.plugin.i18n.t("percentValue", { value: this.plugin.i18n.number(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 = this.plugin.i18n.t("readingAnnouncement", { note: `${reading.note}${reading.octave}`, cents: 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) { return centsDescription(this.plugin.i18n, cents); } setBpmReadout(value) { var _a; const readout = this.contentEl.querySelector("[data-bpm-readout]"); if (readout) { readout.setText(this.plugin.i18n.number(value)); (_a = readout.parentElement) == null ? void 0 : _a.setAttr("aria-label", this.plugin.i18n.t("bpmAria", { bpm: this.plugin.i18n.number(value) })); } } 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, key) { console.error(this.plugin.i18n.t(key), error); new import_obsidian2.Notice(this.plugin.i18n.t(key)); } }; // 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, "i18n"); __publicField(this, "saveQueue", Promise.resolve()); } async onload() { const loadedSettings = await this.loadData(); this.settings = parseSettings(loadedSettings); this.i18n = createTranslator(resolveLanguagePreference(this.settings.language)); 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", this.i18n.t("openView"), () => void this.activateView()); this.addCommand({ id: "open-view", name: this.i18n.t("commandOpen"), callback: () => void this.activateView() }); this.addCommand({ id: "start-metronome", name: this.i18n.t("commandStart"), callback: () => void this.metronome.start().catch((error) => { console.error("Could not start metronome", error); new import_obsidian3.Notice(this.i18n.t("errorMetronome")); }) }); this.addCommand({ id: "stop-metronome", name: this.i18n.t("commandStop"), 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 } }); } };