Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
# Slopsmith Tuner Plugin
<img width="290" height="362" alt="grafik" src="https://github.com/user-attachments/assets/879440e9-b680-481b-9091-ddfa73319078" />
A real-time guitar and bass tuner plugin for [Slopsmith](https://github.com/byrongamatos/slopsmith).
This plugin adds a floating "Tuner" button to the Slopsmith interface, providing a high-accuracy chromatic tuner with support for multiple presets, custom tunings, and automatic song tuning detection.
## Features
- **Real-time Pitch Detection**: Uses the YIN algorithm for robust and accurate frequency tracking.
- **Multiple Presets**: Includes common guitar and bass tunings (Standard, Drop D, DADGAD, Open G, etc.).
- **Automatic Song Tuning**: Detects and selects the correct tuning for the currently playing song in the Slopsmith player.
- **Manual & Auto Tracking**: Automatically estimates the closest string or allows manual selection for focused tuning.
- **Visual Feedback**: Large cents-deviation gauge, frequency display, and color-coded indicators.
- **Custom Tunings**: Add your own tunings via note names (e.g., E2, A2) or Hz frequencies in the settings.
- **Audio Device Selection**: Choose specific input devices and channels (Mono, Left, Right) for professional interfaces.
- **Themable UI**: Styled with Tailwind CSS to match your Slopsmith theme.
- **Visualizations**: Pick from different visualizations to suit your needs (Currently: Default, Strobe, Analogue Gauge, Mace Fx III, and Toilet Tuner)
## Available Visualizations
| Name | Image |
|------|-------|
| Default | <img width="450" height="261" alt="grafik" src="https://github.com/user-attachments/assets/7b63cac5-07c8-4fea-88ba-60e051a3cbb4" /> |
| Strobe | <img width="450" alt="grafik" src="https://github.com/user-attachments/assets/d73f9434-dd2b-4d36-a21b-ceff4cd278a2" /> |
| Analogue Gauge | <img width="450" alt="grafik" src="https://github.com/user-attachments/assets/44918f20-fc56-4219-9081-8c46bf473e20" /> |
| Mace-Fx III | <img width="450" alt="grafik" src="https://github.com/user-attachments/assets/bd80a850-668e-4217-861b-50a6015f4f2d" /> |
| Bender PP-Tiny | <img width="450" alt="grafik" src="https://github.com/user-attachments/assets/7e6fe983-240d-4cf7-9622-ea5203bdafc7" /> |
| CHEF MT-3 | <img width="450" alt="grafik" src="https://github.com/user-attachments/assets/7f812395-0cd6-4091-9fb3-c4c16a8f2afe" /> |
| Toilet Tuner | <img width="450" alt="grafik" src="https://github.com/user-attachments/assets/07925cd6-386d-4089-8278-a3e6eb499685" /> |
## Installation
### Download a Release
1. Download one of the [Releases](https://github.com/OmikronApex/slopsmith-plugin-tuner/releases)
2. Extract it to your plugins folder
3. Restart Slopsmith
### Update Manager
The plugin is listed in the official plugin repository, so it can also be installed directly via the [Update Manager](https://github.com/masc0t/slopsmith-update-manager)
### Git
```bash
cd /path/to/slopsmith/plugins
git clone https://github.com/OmikronApex/slopsmith-plugin-tuner.git tuner
# Restart Slopsmith (or restart your docker container)
docker compose restart
```
## How to Use
1. Click the **Tuner** button at the bottom-right of the screen (or the "Tuner" button in the player controls).
2. The tuner will automatically default to the **Current Song** tuning if you are in the player.
3. Select other presets or custom tunings from the dropdown menu if needed.
4. Pluck a string. The tuner will automatically detect the closest string in the selected tuning.
5. (Optional) Click a specific note button in the tuner window to lock onto that string (useful for very out-of-tune strings).
6. Adjust your tuning until the needle is centered and the indicator turns green.
## Configuration
### In-App Settings
Click the ⚙️ icon in the tuner window to access:
- **Audio Input**: Select your preferred microphone or audio interface.
- **Channel Selection**: Choose between Mono (mixed), Left, or Right channels (ideal for multi-channel audio interfaces).
- **Visualization**: Choose between different visualization options.
<img width="303" height="268" alt="grafik" src="https://github.com/user-attachments/assets/54310baf-56b0-4a19-a076-5450f6d3cc9d" />
### Plugin Manager
Access advanced settings via the Slopsmith Plugin Manager (Settings -> Plugins -> Tuner):
- **Floating Button**: Toggle the visibility of the tuner button on the main interface.
- **Tuning Visibility**: Toggle which built-in tunings appear in your menu.
- **Custom Tunings**: Define your own tuning presets by entering a name and a list of notes/frequencies.
<img width="640" height="1025" alt="grafik" src="https://github.com/user-attachments/assets/d67585a2-f376-44bb-8c9b-64a0de732dbd" />
## Changelog
### [1.3.1] - 2026-06-04
- JUCE bridge audio input: when running inside Slopsmith Desktop the tuner taps the engine's raw audio stream (`getRawAudioFrame`) and runs its own tuning-optimised YIN over it, falling back to the browser microphone pipeline otherwise.
- Fixed octave-low / sub-harmonic pitch errors (canonical YIN absolute-threshold selection) and added octave-aware nearest-string matching.
- "Free Tune" is now remembered as your last tuning, so it persists across sessions instead of resetting to a preset each time.
- Relocated visualization SVG assets to `visualization/assets/`, served via the dedicated `/api/plugins/tuner/viz-assets/` route (supersedes the 1.3.0 note about the root `assets/` directory).
- Removed the legacy Tailwind stylesheet (`assets/plugin.css`) and its `styles` manifest entry — supersedes the 1.3.0 stylesheet note below.
### [1.3.0] - 2026-06-01
- Added PP-Tiny visualization: inspired by the Fender PT-100 chromatic tuner panel, with a curved 11-LED arc, 8-segment note display with split centre bar, and always-on BATT. indicator.
- Added CHEF MT-3 visualization: inspired by the BOSS TU-3, featuring a 90° curved glass gauge arc, 51 tick marks, red 7-segment display, and rubber mode/brightness buttons.
- Refactored `screen.js` into focused modules: audio pipeline extracted to `utils/audio.js`, UI layer extracted to `utils/ui.js` (shared-state factory pattern). `screen.js` reduced from ~1060 to ~300 lines.
- Normalised `DEFAULT_TUNINGS` keys to instrument keys (`guitar-6`, `bass-4`, etc.) — removes the internal group-name lookup table.
- Added plugin stylesheet (`assets/plugin.css`) via the Slopsmith styles contract, ensuring arbitrary Tailwind classes render correctly for runtime-installed users.
- Moved SVG assets (`Bathroom.svg`, `Plunger.svg`, `Toiletbowl.svg`) to the root `assets/` directory; removed the now-redundant custom asset route from `routes.py`.
- Moved Toilet Tuner to the end of the visualization picker list.
### [1.2.8] - 2026-05-31
- Added Toilet Tuner visualization: bathroom scene background with a plunger that slides left/right proportional to cents deviation; dips into the toilet bowl when in tune (±2 cents) and shows a 💩 emoji on the wall calendar.
### [1.2.7] - 2026-05-31
- Added Mace Fx III visualization: dark navy LCD-style panel with a chromatic tick gauge, inward directional arrows, large note/octave readout, a rotating pink strobe semicircle, and a pixelated grid overlay.
- Improved tuner detection stability: median frequency filtering plus YIN octave correction (rejects both overtone and undertone errors) keeps low strings from jumping octaves as they decay.
- Added pluck-attack warm-up so the noisy string-attack transient no longer shows a wrong pitch before settling.
- Added frame-to-frame octave continuity tracking to eliminate residual octave flips.
### [1.2.6] - 2026-05-31
- Added Analogue Gauge visualization: vintage mechanical instrument panel with rotating frequency and note name drums, semicircular needle gauge, and a physical-style in-tune lightbulb.
- Added AUTO mode indicator lamp: lights when Free Tune is active, dims on manual string lock.
- Visualizations now receive tuning mode context (`free` / `auto` / `manual`) from the core plugin.
### [1.2.5] - 2026-05-30
- Improved mic error handling: better error messages and inline error banner instead of browser alert.
- Fixed Real Tone Cable (mono-only USB audio) support when the device is explicitly selected.
- Fixed error banner persisting across screen navigation after a mic failure.
- Fixed stale error banner remaining visible after a successful device switch.
- Fixed silent failure when audio restart fails during device switch.
### [1.2.4] - 2026-05-25
- Improved low-frequency detection by lowering minimum detectable frequency to 20Hz.
### [1.2.3] - 2026-05-19
- Refactored tuner plugin: simplified script loading, modularized audio pipeline, and improved visualization state management.
- Fixed issue where targeting a specific string was impossible when no audio input was present.
### [1.2.2] - 2026-05-18
- Added missing YIN-worker script.
### [1.2.1] - 2026-05-18
- Added graceful handling for audio device errors by resetting device ID on exceptions.
### [1.2.0] - 2026-05-18
- Introduced Strobe Tuner visualization.
- Modularized visualization handling and improved state management.
- Enhanced tuning synchronization logic.
### [1.1.0] - 2026-05-10
- Added 5-string bass tunings.
- Removed unnecessary scroll limit in settings UI.
### [1.0.3] - 2026-05-10
- Visual polish for the settings page.
- Added toggle for floating tuner button visibility.
### [1.0.2] - 2026-05-10
- Added microphone and channel selection settings.
- Integrated tuner button into the player UI.
- Added dynamic tuning detection within the player.
### [1.0.1] - 2026-05-10
- Fixed floating button reappearing on song end.
- Improved tuner button injection in player UI.
### [1.0.0] - 2026-05-10
- Initial release.
## License
MIT
+10
View File
@@ -0,0 +1,10 @@
{
"id": "tuner",
"name": "Guitar/Bass Tuner",
"version": "1.3.1",
"bundled": true,
"private": false,
"script": "screen.js",
"settings": { "html": "settings.html" },
"routes": "routes.py"
}
+156
View File
@@ -0,0 +1,156 @@
"""Tuner plugin — persist last selected tuning and custom tunings in config_dir."""
import json
import logging
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import Response
DEFAULT_TUNING = "Standard"
DEFAULT_INSTRUMENT = "guitar-6"
_INSTRUMENT_BY_STRING_COUNT = {4: "bass-4", 5: "bass-5", 7: "guitar-7", 8: "guitar-8"}
def _migrate_custom_tuning(name: str, value) -> dict:
"""Return {instrument, strings} for both old flat-list and new dict formats."""
if isinstance(value, list):
instrument = _INSTRUMENT_BY_STRING_COUNT.get(len(value), "guitar-6")
return {"instrument": instrument, "strings": value}
if isinstance(value, dict) and "strings" in value:
return value
return {"instrument": "guitar-6", "strings": []}
def setup(app: FastAPI, context: dict):
config_dir = Path(context["config_dir"])
config_file = config_dir / "tuner.json"
log = context.get("log") or logging.getLogger("slopsmith.plugin.tuner")
def _read() -> dict:
defaults = {
"lastTuning": DEFAULT_TUNING,
"lastInstrument": DEFAULT_INSTRUMENT,
"freeTune": False,
"customTunings": {},
"disabledTunings": [],
"showFloatingButton": True,
"visualizationMode": "default",
"audioInputMode": "auto",
}
if not config_file.exists():
return defaults
try:
data = json.loads(config_file.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return defaults
res = {}
res["lastTuning"] = str(data.get("lastTuning", DEFAULT_TUNING))
res["lastInstrument"] = str(data.get("lastInstrument", DEFAULT_INSTRUMENT))
res["freeTune"] = bool(data.get("freeTune", False))
res["customTunings"] = data.get("customTunings", {})
res["disabledTunings"] = data.get("disabledTunings", [])
res["showFloatingButton"] = bool(data.get("showFloatingButton", True))
res["visualizationMode"] = str(data.get("visualizationMode", "default"))
raw_mode = str(data.get("audioInputMode", "auto"))
res["audioInputMode"] = raw_mode if raw_mode in ("auto", "browser") else "auto"
if not isinstance(res["customTunings"], dict):
res["customTunings"] = {}
if not isinstance(res["disabledTunings"], list):
res["disabledTunings"] = []
# Migrate custom tunings from old flat-list format
res["customTunings"] = {
name: _migrate_custom_tuning(name, val)
for name, val in res["customTunings"].items()
}
# Strip legacy disabledTunings entries that lack compound "instrument:name" format
res["disabledTunings"] = [
e for e in res["disabledTunings"]
if isinstance(e, str) and ":" in e
]
return res
except Exception:
return defaults
def _write(data: dict) -> None:
config_dir.mkdir(parents=True, exist_ok=True)
current = _read()
# Strip keys that belong to core, not to this plugin's config.
for key in ("defaultTunings", "referencePitch"):
data = {k: v for k, v in data.items() if k != key}
current.update(data)
config_file.write_text(json.dumps(current, indent=2), encoding="utf-8")
def _get_custom_tunings() -> dict:
"""Return custom tunings in DEFAULT_TUNINGS format: {instrument: {name: [freqs]}}."""
cfg = _read()
result: dict[str, dict] = {}
for name, val in cfg.get("customTunings", {}).items():
inst = val.get("instrument", "guitar-6")
strings = val.get("strings", [])
if strings:
result.setdefault(inst, {})[name] = strings
return result
# Register this plugin as a tuning provider for its custom tunings.
context["register_tuning_provider"]("tuner", _get_custom_tunings)
log.info("tuner: registered custom tuning provider")
_viz_dir = Path(__file__).parent / "visualization"
_viz_assets_dir = Path(__file__).parent / "visualization" / "assets"
_workers_dir = Path(__file__).parent / "workers"
_utils_dir = Path(__file__).parent / "utils"
_ASSET_MEDIA_TYPES = {".svg": "image/svg+xml", ".png": "image/png"}
def _serve_js_from(base_dir: Path, filename: str) -> Response:
target = (base_dir / filename).resolve()
try:
target.relative_to(base_dir.resolve())
except ValueError:
return Response("", status_code=404)
if target.suffix == ".js" and target.is_file():
return Response(target.read_text(encoding="utf-8"), media_type="application/javascript")
return Response("", status_code=404)
def _serve_asset_from(base_dir: Path, filename: str) -> Response:
target = (base_dir / filename).resolve()
try:
target.relative_to(base_dir.resolve())
except ValueError:
return Response("", status_code=404)
media_type = _ASSET_MEDIA_TYPES.get(target.suffix.lower())
if media_type and target.is_file():
return Response(target.read_bytes(), media_type=media_type)
return Response("", status_code=404)
@app.get("/api/plugins/tuner/visualization/{filename}")
def get_viz_file(filename: str):
return _serve_js_from(_viz_dir, filename)
@app.get("/api/plugins/tuner/viz-assets/{filename}")
def get_viz_asset(filename: str):
return _serve_asset_from(_viz_assets_dir, filename)
@app.get("/api/plugins/tuner/workers/{filename}")
def get_worker_file(filename: str):
return _serve_js_from(_workers_dir, filename)
@app.get("/api/plugins/tuner/utils/{filename}")
def get_utils_file(filename: str):
return _serve_js_from(_utils_dir, filename)
@app.get("/api/plugins/tuner/config")
def get_config():
return _read()
@app.post("/api/plugins/tuner/config")
async def set_config(req: Request):
body = await req.json()
_write(body)
return {"ok": True}
+500
View File
@@ -0,0 +1,500 @@
// Guitar/Bass Tuner Plugin for Slopsmith
(function() {
'use strict';
const _TUNER_STORAGE_KEY = 'slopsmith_tuner_settings';
// ── Player sync state ─────────────────────────────────────────────
let _onScreenChanged = null;
let _onSongReady = null;
let _outsideClickClose = null;
// ── Auto-open on tuning change (in-memory only) ───────────────────
let _lastTuningKey = null;
let _lastAutoOpenSessionKey = null;
let _autoOpenDismissedSessionKey = null;
let _autoOpenGeneration = 0;
let _onAutoOpenSongLoading = null;
let _onAutoOpenSongReady = null;
// ── Shared mutable state (read/written by screen.js; UI reads via closure) ──
const _state = {
uiContainer: null,
vizContainer: null,
instrumentSelect: null,
tuningSelect: null,
stringNoteContainer: null,
saveAsCustomContainer: null,
activeViz: null,
selectedInstrument: 'guitar-6',
selectedTuning: null,
selectedTuningName: 'Standard',
manualTargetFreq: null,
tunings: {},
_allTunings: {},
referencePitch: 440,
visualizationMode: 'default',
showFloatingButton: true,
currentSongOffsets: null,
currentSongIsBass: false,
currentSongStringCount: 0,
_serverConfig: null,
useFlats: false,
enabled: false,
_instrumentSentinel: null,
selectedDeviceId: '',
selectedChannel: 'mono',
audioInputMode: 'auto',
freeTune: false,
freeTuneToggle: null,
};
let _tunerUIApi = null;
// ── Script loader ─────────────────────────────────────────────────
const _loadedScripts = new Set();
function _loadScript(url) {
if (_loadedScripts.has(url)) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = () => { _loadedScripts.add(url); resolve(); };
s.onerror = () => reject(new Error(`Tuner: failed to load "${url}"`));
document.head.appendChild(s);
});
}
function _loadVizScript(name) {
return _loadScript(`/api/plugins/tuner/visualization/${name}.js`);
}
async function _setVisualization(name) {
if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; }
try {
await _loadVizScript(name);
const factory = window[`_tunerViz_${name}`];
if (typeof factory !== 'function') throw new Error(`Tuner: _tunerViz_${name} not defined`);
_state.activeViz = factory(_state.vizContainer);
} catch (e) {
console.error(e);
if (name !== 'default') {
_state.visualizationMode = 'default';
await _setVisualization('default');
}
}
}
// ── Tuning helpers ────────────────────────────────────────────────
function _isTuningEnabled(instrument, name) {
return !((_state._serverConfig ? _state._serverConfig.disabledTunings : null) || []).includes(instrument + ':' + name);
}
function _instrumentForTuning(name) {
for (var key in _state._allTunings) {
if (_state._allTunings[key] && _state._allTunings[key][name]) return key;
}
return 'guitar-6';
}
function _buildTuningsForInstrument(instrument) {
const all = _state._allTunings[instrument] || {};
const disabled = (_state._serverConfig ? _state._serverConfig.disabledTunings : null) || [];
return Object.fromEntries(
Object.entries(all).filter(([name]) => !disabled.includes(instrument + ':' + name))
);
}
function _tuningIdentityKey(songInfo) {
if (!songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return null;
const ctx = (typeof window.slopsmith?.songTuningContext === 'function')
? window.slopsmith.songTuningContext(songInfo)
: {
stringCount: songInfo.stringCount,
arrangement: songInfo.arrangement,
arrangement_smart_name: songInfo.arrangement_smart_name,
};
const isBass = (typeof window.slopsmith?.isBassArrangement === 'function')
? window.slopsmith.isBassArrangement(ctx)
: (songInfo.arrangement || '').toLowerCase().includes('bass');
const sc = (typeof window.slopsmith?.effectiveStringCount === 'function')
? window.slopsmith.effectiveStringCount(songInfo.tuning, ctx)
: (songInfo.stringCount || songInfo.tuning.length);
if (!sc || sc <= 0) return null;
const offsets = songInfo.tuning.slice(0, sc);
if (!offsets.length) return null;
return (isBass ? 'b' : 'g') + ':' + sc + ':' + offsets.join(',');
}
function _autoOpenSessionKey(songInfo) {
if (!songInfo) return '';
const cur = window.slopsmith?.currentSong;
const filename = (cur && cur.filename) || songInfo.filename || songInfo.title || 'unknown';
const arr = (cur && cur.arrangementIndex != null)
? cur.arrangementIndex
: (songInfo.arrangement_index != null ? songInfo.arrangement_index : (songInfo.arrangement || ''));
return filename + '::' + arr;
}
function _onAutoOpenSongLoadingHandler() {
_autoOpenGeneration++;
_autoOpenDismissedSessionKey = null;
_lastAutoOpenSessionKey = null;
}
async function _maybeAutoOpenOnTuningChange() {
if (!document.getElementById('player')?.classList.contains('active')) return;
const songInfo = window.highway?.getSongInfo?.() || window.slopsmith?.currentSong;
if (!songInfo) return;
const tuningKey = _tuningIdentityKey(songInfo);
if (!tuningKey) return;
const sessionKey = _autoOpenSessionKey(songInfo);
const myGen = _autoOpenGeneration;
if (_lastTuningKey === null) {
_lastTuningKey = tuningKey;
return;
}
if (tuningKey === _lastTuningKey) return;
_lastTuningKey = tuningKey;
if (_autoOpenDismissedSessionKey === sessionKey) return;
if (_state.enabled) return;
if (_lastAutoOpenSessionKey === sessionKey) return;
if (!window.tuner || typeof window.tuner.enable !== 'function') return;
_lastAutoOpenSessionKey = sessionKey;
try {
await window.tuner.enable();
if (myGen !== _autoOpenGeneration) return;
} catch (e) {
console.warn('Tuner: auto-open failed:', e && e.message ? e.message : e);
if (_lastAutoOpenSessionKey === sessionKey) _lastAutoOpenSessionKey = null;
// NOTE: _lastTuningKey stays committed here. Rolling it back to retry
// a failed enable on the same tuning would defeat the duplicate-
// song:ready dedup this gate also enforces; a transient enable failure
// (e.g. mic denied) is therefore not auto-retried until the tuning
// changes. A proper retry needs a separate flag, deferred.
}
}
function _installAutoOpenListeners() {
if (_onAutoOpenSongLoading || !window.slopsmith?.on) return;
_onAutoOpenSongLoading = _onAutoOpenSongLoadingHandler;
_onAutoOpenSongReady = () => { _maybeAutoOpenOnTuningChange(); };
window.slopsmith.on('song:loading', _onAutoOpenSongLoading);
window.slopsmith.on('song:ready', _onAutoOpenSongReady);
}
// ── Player sync helpers ───────────────────────────────────────────
function _syncCurrentTuning() {
const songInfo = window.highway?.getSongInfo();
const onPlayer = document.getElementById('player')?.classList.contains('active');
const wantCurrent = _state.selectedTuningName === '_current'
|| (onPlayer && songInfo?.tuning?.length);
if (songInfo?.tuning?.length && wantCurrent) {
_state.selectedTuningName = '_current';
const ctx = (typeof window.slopsmith?.songTuningContext === 'function')
? window.slopsmith.songTuningContext(songInfo)
: {
stringCount: songInfo.stringCount,
arrangement: songInfo.arrangement,
arrangement_smart_name: songInfo.arrangement_smart_name,
};
const isBass = (typeof window.slopsmith?.isBassArrangement === 'function')
? window.slopsmith.isBassArrangement(ctx)
: (songInfo.arrangement || '').toLowerCase().includes('bass');
const sc = (typeof window.slopsmith?.effectiveStringCount === 'function')
? window.slopsmith.effectiveStringCount(songInfo.tuning, ctx)
: (songInfo.stringCount || songInfo.tuning.length);
_state.currentSongOffsets = songInfo.tuning.slice(0, sc);
_state.currentSongIsBass = isBass;
_state.currentSongStringCount = sc;
const _refScale = _state.referencePitch / 440;
_state.selectedTuning = window._tunerUtils.offsetsToFreqs(_state.currentSongOffsets, isBass).map(f => f * _refScale);
const songInstrument = isBass
? ('bass-' + (sc === 5 ? 5 : 4))
: (sc === 8 ? 'guitar-8' : sc === 7 ? 'guitar-7' : 'guitar-6');
if (songInstrument !== _state.selectedInstrument) {
_state.selectedInstrument = songInstrument;
_state.tunings = _buildTuningsForInstrument(_state.selectedInstrument);
_tunerUIApi?.updateInstrumentDisplay();
}
if (_state.tuningSelect) _state.tuningSelect.value = '_current';
} else {
const first = Object.keys(_state.tunings)[0];
if (first) {
_state.selectedTuningName = first;
_state.selectedTuning = _state.tunings[first];
if (_state.tuningSelect) _state.tuningSelect.value = first;
const derivedInstrument = _instrumentForTuning(first);
if (derivedInstrument && derivedInstrument !== _state.selectedInstrument) {
_state.selectedInstrument = derivedInstrument;
if (_state.instrumentSelect) { _state.instrumentSelect.value = derivedInstrument; _tunerUIApi?.updateInstrumentDisplay(); }
}
}
}
_tunerUIApi?.renderStringNotes();
_tunerUIApi?.updateSaveAsCustomVisibility();
}
// ── Persistence ───────────────────────────────────────────────────
function loadSettings() {
try {
const s = JSON.parse(localStorage.getItem(_TUNER_STORAGE_KEY) || '{}');
if (s.deviceId !== undefined) _state.selectedDeviceId = s.deviceId;
if (['mono', 'left', 'right'].includes(s.channel)) _state.selectedChannel = s.channel;
} catch (e) { /* unavailable */ }
}
function saveSettings() {
try {
localStorage.setItem(_TUNER_STORAGE_KEY, JSON.stringify({
deviceId: _state.selectedDeviceId,
channel: _state.selectedChannel,
}));
} catch (e) { /* unavailable */ }
}
async function loadConfig() {
try {
const [config, tuningsData] = await Promise.all([
fetch('/api/plugins/tuner/config').then(r => r.json()),
fetch('/api/tunings').then(r => r.json()),
]);
_state._serverConfig = config;
_state._allTunings = tuningsData.tunings || {};
_state.referencePitch = tuningsData.referencePitch || 440;
_state.showFloatingButton = config.showFloatingButton !== false;
_state.visualizationMode = config.visualizationMode || 'default';
_state.audioInputMode = config.audioInputMode || 'auto';
if (config.lastInstrument && _state._allTunings[config.lastInstrument]) {
_state.selectedInstrument = config.lastInstrument;
}
if (_state.instrumentSelect) { _state.instrumentSelect.value = _state.selectedInstrument; _tunerUIApi?.updateInstrumentDisplay(); }
_state.tunings = _buildTuningsForInstrument(_state.selectedInstrument);
const lastName = config.lastTuning;
// Legacy saves stored 'free-tune' as lastTuning; treat that as
// freeTune=true with no specific named tuning.
const legacyFreeTune = lastName === 'free-tune';
if (!legacyFreeTune && lastName && _state.tunings[lastName]) {
_state.selectedTuningName = lastName;
_state.selectedTuning = _state.tunings[lastName];
} else {
const first = Object.keys(_state.tunings)[0];
if (first) { _state.selectedTuningName = first; _state.selectedTuning = _state.tunings[first]; }
}
_state.freeTune = legacyFreeTune || !!config.freeTune;
_state.useFlats = window._tunerUtils
? window._tunerUtils.preferFlats(_state.selectedTuningName)
: /\b[A-G]b\b/.test(_state.selectedTuningName || '');
if (_state.tuningSelect) _tunerUIApi?.renderTuningOptions();
if (_state.uiContainer && !_state.uiContainer.classList.contains('hidden')) _tunerUIApi?.renderStringNotes();
_tunerUIApi?.updateSaveAsCustomVisibility();
_tunerUIApi?.updateFreeTuneUI();
_tunerUIApi?.updateFloatingButtonVisibility();
} catch (e) {
console.error('Tuner: Failed to load config', e);
}
}
window._tunerReloadConfig = loadConfig;
async function saveConfig() {
// '_current' is the live song tuning; 'free-tune' is now tracked via the
// freeTune boolean — neither should land in lastTuning.
const tuningToSave = (_state.selectedTuningName === '_current' || _state.selectedTuningName === 'free-tune')
? null : _state.selectedTuningName;
try {
await fetch('/api/plugins/tuner/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lastTuning: tuningToSave,
lastInstrument: _state.selectedInstrument,
visualizationMode: _state.visualizationMode,
freeTune: _state.freeTune,
}),
});
} catch (e) {
console.error('Tuner: Failed to save config', e);
}
}
// ── Audio lifecycle ───────────────────────────────────────────────
async function restartAudio() {
_state.uiContainer?.querySelector('.tuner-mic-error')?.remove();
try {
await window._tunerAudio.restart({ deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode });
} catch (e) {
console.error('Tuner: Failed to restart audio', e);
disable();
_tunerUIApi?.showMicError(e);
}
}
async function enable() {
if (_state.enabled) return;
await _loadScript('/api/plugins/tuner/utils/tuning-utils.js');
await _loadScript('/api/plugins/tuner/utils/audio.js');
await _loadScript('/api/plugins/tuner/utils/ui.js');
loadSettings();
await loadConfig();
if (document.querySelector('.screen.active')?.id === 'player') _state.selectedTuningName = '_current';
if (!_tunerUIApi) {
_tunerUIApi = window._tunerUI(_state, {
saveConfig, loadConfig, saveSettings, disable, restartAudio,
setVisualization: _setVisualization,
buildTuningsForInstrument: _buildTuningsForInstrument,
});
}
_tunerUIApi.initUI();
_tunerUIApi.renderInstrumentOptions();
_tunerUIApi.renderTuningOptions();
if (_state.selectedTuningName === '_current') _syncCurrentTuning();
else if (_state.selectedTuning) _tunerUIApi.renderStringNotes();
_tunerUIApi.updateSaveAsCustomVisibility();
await _setVisualization(_state.visualizationMode);
_state.uiContainer.classList.remove('hidden');
_state.uiContainer.classList.add('flex');
_tunerUIApi.positionPanel();
_tunerUIApi.updateFreeTuneUI();
// Close when clicking outside the panel. Deferred so the badge's
// opening click doesn't bubble up to the document and fire immediately.
if (_outsideClickClose) document.removeEventListener('click', _outsideClickClose);
_outsideClickClose = () => { if (_state.enabled) disable(); };
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
if (window.slopsmith && !_onScreenChanged) {
_onScreenChanged = () => { disable(); };
_onSongReady = () => {
_tunerUIApi.renderTuningOptions();
if (_state.selectedTuningName === '_current') _syncCurrentTuning();
};
window.slopsmith.on('screen:changed', _onScreenChanged);
window.slopsmith.on('song:ready', _onSongReady);
}
_state.uiContainer?.querySelector('.tuner-mic-error')?.remove();
try {
// start() calls _doStop() internally, so this cleanly replaces any
// existing auto-start session and registers the full UI callback.
await window._tunerAudio.start(
{ deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode },
_tunerUIApi.updateUI
);
_state.enabled = true;
if (window.tuner?.updateButtons) window.tuner.updateButtons();
} catch (e) {
console.error('Tuner: Failed to start audio', e);
disable();
_tunerUIApi?.showMicError(e);
}
}
function disable() {
const wasEnabled = _state.enabled;
const onPlayer = document.getElementById('player')?.classList.contains('active');
_state.enabled = false;
_state.manualTargetFreq = null;
if (_outsideClickClose) { document.removeEventListener('click', _outsideClickClose); _outsideClickClose = null; }
if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; }
if (_state.uiContainer) { _state.uiContainer.classList.add('hidden'); _state.uiContainer.classList.remove('flex'); }
if (_onScreenChanged) { window.slopsmith?.off('screen:changed', _onScreenChanged); _onScreenChanged = null; }
if (_onSongReady) { window.slopsmith?.off('song:ready', _onSongReady); _onSongReady = null; }
if (window._tunerAudio) window._tunerAudio.stop();
if (_state.vizContainer) _state.vizContainer.innerHTML = '';
if (window.tuner?.updateButtons) window.tuner.updateButtons();
// Resume background audio so the live badge keeps updating after the panel closes.
if (window._tunerAudio && _tunerUIApi) {
window._tunerAudio.start(
{ deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode },
_tunerUIApi.updateUI
).catch(e => console.warn('Tuner: badge audio resume failed:', e && e.message ? e.message : e));
}
if (wasEnabled && onPlayer) {
const songInfo = window.highway?.getSongInfo?.() || window.slopsmith?.currentSong;
if (songInfo) _autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo);
}
}
window.tuner = {
enable,
disable,
toggle: () => _state.enabled ? disable() : enable(),
updateButtons: () => {
_tunerUIApi?.updateFloatingButton();
_tunerUIApi?.updatePlayerButton();
_tunerUIApi?.updateFloatingButtonVisibility();
},
};
// Boot: load scripts, add toggle button, then auto-start audio for the live badge
Promise.all([
_loadScript('/api/plugins/tuner/utils/tuning-utils.js'),
_loadScript('/api/plugins/tuner/utils/audio.js'),
_loadScript('/api/plugins/tuner/utils/ui.js'),
]).then(async () => {
_tunerUIApi = window._tunerUI(_state, {
saveConfig, loadConfig, saveSettings, disable, restartAudio,
setVisualization: _setVisualization,
buildTuningsForInstrument: _buildTuningsForInstrument,
});
_tunerUIApi.addButton();
loadSettings();
await loadConfig();
// Auto-start audio so the v3 badge receives live tuner:frame events from
// page load, without requiring the user to open the tuner panel first.
// Errors are silent — a permission prompt or missing device is non-fatal
// here; the user will see the mic error modal if they explicitly open the
// tuner via enable().
try {
await window._tunerAudio.start(
{ deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode },
_tunerUIApi.updateUI
);
} catch (e) {
console.warn('Tuner: auto-start audio failed (badge will be static):', e && e.message ? e.message : e);
}
_installAutoOpenListeners();
}).catch(e => console.error(e));
_installAutoOpenListeners();
window._tunerAutoOpen = {
tuningIdentityKey: _tuningIdentityKey,
sessionKey: _autoOpenSessionKey,
maybeAutoOpenOnTuningChange: _maybeAutoOpenOnTuningChange,
onSongLoading: _onAutoOpenSongLoadingHandler,
getState() {
return {
lastTuningKey: _lastTuningKey,
lastAutoOpenSessionKey: _lastAutoOpenSessionKey,
autoOpenDismissedSessionKey: _autoOpenDismissedSessionKey,
autoOpenGeneration: _autoOpenGeneration,
enabled: _state.enabled,
};
},
resetState() {
_lastTuningKey = null;
_lastAutoOpenSessionKey = null;
_autoOpenDismissedSessionKey = null;
_autoOpenGeneration = 0;
},
setEnabledForTests(value) {
_state.enabled = !!value;
},
};
console.log('Tuner plugin loaded. Use window.tuner.toggle() to open.');
})();
+340
View File
@@ -0,0 +1,340 @@
<div class="space-y-6 py-2">
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<div>
<h3 class="text-sm font-medium text-gray-200">Floating Button</h3>
<p class="text-[11px] text-gray-500">Show the tuner button on the main interface.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="tuner-show-floating" class="sr-only peer" onchange="window._tunerToggleFloating(this.checked)">
<div class="w-9 h-5 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-accent"></div>
</label>
</div>
<script>
if (window.slopsmithDesktop && window.slopsmithDesktop.isDesktop) {
document.currentScript.insertAdjacentHTML('beforebegin', `
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<div>
<h3 class="text-sm font-medium text-gray-200">Force Browser Audio</h3>
<p class="text-[11px] text-gray-500">Use the browser microphone pipeline instead of the desktop audio engine.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="tuner-force-browser-audio" class="sr-only peer" onchange="window._tunerToggleBrowserAudio(this.checked)">
<div class="w-9 h-5 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-accent"></div>
</label>
</div>`);
}
</script>
<div>
<h3 class="text-sm font-medium text-gray-400 mb-3">Tuning Visibility</h3>
<p class="text-xs text-gray-500 mb-4">Toggle which built-in tunings appear in the tuner menu.</p>
<div id="tuner-visibility-list" class="space-y-2 pr-2">
<!-- Populated by JS -->
</div>
</div>
<div class="pt-4 border-t border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Custom Tunings</h3>
<div id="tuner-custom-list" class="space-y-2 mb-4">
<!-- Populated by JS -->
</div>
<div class="bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<p class="text-[11px] font-bold text-gray-500 uppercase tracking-wider mb-3">Add Custom Tuning</p>
<div class="space-y-3">
<input type="text" id="tuner-new-name" placeholder="Tuning Name (e.g. DADGAD)"
class="w-full bg-dark-800 border-none rounded-lg px-3 py-2 text-xs text-white focus:ring-1 focus:ring-accent/30 outline-none">
<select id="tuner-new-instrument"
class="w-full bg-dark-800 border-none rounded-lg px-3 py-2 text-xs text-white focus:ring-1 focus:ring-accent/30 outline-none">
<optgroup label="Guitar">
<option value="guitar-6">Guitar 6-string</option>
<option value="guitar-7">Guitar 7-string</option>
<option value="guitar-8">Guitar 8-string</option>
</optgroup>
<optgroup label="Bass">
<option value="bass-4">Bass 4-string</option>
<option value="bass-5">Bass 5-string</option>
</optgroup>
</select>
<div class="flex flex-col gap-1">
<label class="text-[10px] text-gray-500 ml-1">Notes or Frequencies (e.g. E2, A2, D3, G3 or 82.41, 110.0...)</label>
<input type="text" id="tuner-new-freqs" placeholder="E2, A2, D3, G3, B3, E4"
class="w-full bg-dark-800 border-none rounded-lg px-3 py-2 text-xs text-white focus:ring-1 focus:ring-accent/30 outline-none font-mono">
</div>
<button onclick="window._tunerAddCustom()"
class="w-full bg-accent/20 hover:bg-accent/30 text-accent text-xs font-bold py-2 rounded-lg transition-colors border border-accent/20">
Add Tuning
</button>
</div>
</div>
</div>
<p class="text-[11px] text-gray-600 italic">Settings are saved automatically.</p>
<script>
(function() {
let config = { customTunings: {}, disabledTunings: [], defaultTunings: {}, showFloatingButton: true, audioInputMode: 'auto' };
let expandedGroups = [];
var _INSTRUMENT_CAPTIONS = {
"guitar-6": "Guitar",
"guitar-7": "Guitar 7-string",
"guitar-8": "Guitar 8-string",
"bass-4": "Bass 4-string",
"bass-5": "Bass 5-string"
};
var _instrumentLabels = {
"guitar-6": "Guitar 6-string",
"guitar-7": "Guitar 7-string",
"guitar-8": "Guitar 8-string",
"bass-4": "Bass 4-string",
"bass-5": "Bass 5-string"
};
async function load() {
try {
const resp = await fetch('/api/plugins/tuner/config');
config = await resp.json();
const floatingToggle = document.getElementById('tuner-show-floating');
if (floatingToggle) floatingToggle.checked = config.showFloatingButton !== false;
const browserAudioToggle = document.getElementById('tuner-force-browser-audio');
if (browserAudioToggle) browserAudioToggle.checked = config.audioInputMode === 'browser';
render();
} catch (e) { console.error('Tuner settings: load failed', e); }
}
window._tunerToggleFloating = (enabled) => {
config.showFloatingButton = enabled;
save();
};
window._tunerToggleBrowserAudio = (forceBrowser) => {
config.audioInputMode = forceBrowser ? 'browser' : 'auto';
save();
};
async function save(opts) {
try {
await fetch('/api/plugins/tuner/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (window._tunerReloadConfig) window._tunerReloadConfig();
if (opts && opts.tuningsChanged) window.slopsmith?.emit('tunings:updated');
} catch (e) { console.error('Tuner settings: save failed', e); }
}
function render() {
const visList = document.getElementById('tuner-visibility-list');
visList.innerHTML = '';
const defaultTunings = config.defaultTunings || {};
Object.keys(defaultTunings).forEach(groupName => {
const group = defaultTunings[groupName];
const groupTunings = Object.keys(group);
const instrument = groupName;
// Compound keys for all tunings in this group
const compoundKeys = groupTunings.map(n => instrument + ':' + n);
const groupWrapper = document.createElement('div');
groupWrapper.className = 'mb-4';
const header = document.createElement('div');
header.className = 'flex items-center justify-between p-2 mt-2 bg-dark-900/80 rounded-t-lg border-x border-t border-gray-800/50 cursor-pointer hover:bg-dark-900 transition-colors';
const left = document.createElement('div');
left.className = 'flex items-center gap-2';
const chevron = document.createElement('span');
chevron.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>';
chevron.className = 'text-gray-500 transition-transform duration-200 rotate-0';
const groupLabel = document.createElement('span');
groupLabel.className = 'text-[11px] font-bold text-gray-400 uppercase tracking-wider';
groupLabel.textContent = _INSTRUMENT_CAPTIONS[groupName] || groupName;
left.appendChild(chevron);
left.appendChild(groupLabel);
const groupToggle = document.createElement('input');
groupToggle.type = 'checkbox';
const allEnabled = compoundKeys.every(k => !config.disabledTunings.includes(k));
const someEnabled = compoundKeys.some(k => !config.disabledTunings.includes(k));
groupToggle.checked = allEnabled;
groupToggle.indeterminate = someEnabled && !allEnabled;
groupToggle.className = 'accent-accent';
groupToggle.onclick = (e) => e.stopPropagation();
groupToggle.onchange = () => {
if (groupToggle.checked) {
config.disabledTunings = config.disabledTunings.filter(k => !compoundKeys.includes(k));
} else {
compoundKeys.forEach(k => {
if (!config.disabledTunings.includes(k)) config.disabledTunings.push(k);
});
}
save();
render();
};
header.appendChild(left);
header.appendChild(groupToggle);
groupWrapper.appendChild(header);
const groupContainer = document.createElement('div');
groupContainer.className = 'border-x border-b border-gray-800/50 rounded-b-lg overflow-hidden';
const isExpanded = expandedGroups.includes(groupName);
if (!isExpanded) {
groupContainer.classList.add('hidden');
chevron.classList.remove('rotate-90');
} else {
chevron.classList.add('rotate-90');
}
header.onclick = () => {
const idx = expandedGroups.indexOf(groupName);
if (idx === -1) {
expandedGroups.push(groupName);
} else {
expandedGroups.splice(idx, 1);
}
render();
};
groupTunings.forEach((name, idx) => {
const compoundKey = instrument + ':' + name;
const div = document.createElement('div');
div.className = `flex items-center justify-between p-2 bg-dark-800/30 hover:bg-dark-800/50 transition-colors ${idx === 0 ? 'border-t-0' : 'border-t border-gray-800/20'}`;
const label = document.createElement('span');
label.className = 'text-xs text-gray-300';
label.textContent = name;
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = !config.disabledTunings.includes(compoundKey);
toggle.className = 'accent-accent';
toggle.onchange = () => {
if (toggle.checked) {
config.disabledTunings = config.disabledTunings.filter(k => k !== compoundKey);
} else {
if (!config.disabledTunings.includes(compoundKey)) config.disabledTunings.push(compoundKey);
}
save();
render();
};
div.appendChild(label);
div.appendChild(toggle);
groupContainer.appendChild(div);
});
groupWrapper.appendChild(groupContainer);
visList.appendChild(groupWrapper);
});
const customList = document.getElementById('tuner-custom-list');
customList.innerHTML = '';
const customNames = Object.keys(config.customTunings);
if (customNames.length === 0) {
customList.innerHTML = '<p class="text-[10px] text-gray-600 italic ml-1">No custom tunings added.</p>';
} else {
customNames.forEach(name => {
const val = config.customTunings[name];
const strings = Array.isArray(val) ? val : (val.strings || []);
const instrument = Array.isArray(val) ? 'guitar-6' : (val.instrument || 'guitar-6');
const instrLabel = _instrumentLabels[instrument] || instrument;
const div = document.createElement('div');
div.className = 'flex items-center justify-between p-2 bg-dark-800/30 rounded-lg border border-gray-800/50';
const info = document.createElement('div');
info.className = 'flex flex-col';
const nEl = document.createElement('span');
nEl.className = 'text-xs text-white font-medium';
nEl.textContent = name + ' · ' + instrLabel;
const fEl = document.createElement('span');
fEl.className = 'text-[10px] text-gray-500 font-mono';
fEl.textContent = strings.join(', ');
info.appendChild(nEl);
info.appendChild(fEl);
const del = document.createElement('button');
del.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>';
del.className = 'text-gray-600 hover:text-red-400 transition-colors p-1';
del.onclick = () => {
delete config.customTunings[name];
save({ tuningsChanged: true });
render();
};
div.appendChild(info);
div.appendChild(del);
customList.appendChild(div);
});
}
}
function noteToFreq(note) {
const notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const res = note.toUpperCase().match(/^([A-G]#?)(\d)$/);
if (!res) return null;
const name = res[1];
const octave = parseInt(res[2]);
const semitones = notes.indexOf(name) + (octave + 1) * 12;
return 440 * Math.pow(2, (semitones - 69) / 12);
}
window._tunerAddCustom = () => {
const nameInput = document.getElementById('tuner-new-name');
const instrInput = document.getElementById('tuner-new-instrument');
const freqsInput = document.getElementById('tuner-new-freqs');
const name = nameInput.value.trim();
const instrument = instrInput.value;
const inputStr = freqsInput.value.trim();
let errP = document.getElementById('tuner-add-error');
if (!errP) {
errP = document.createElement('p');
errP.id = 'tuner-add-error';
errP.className = 'text-[10px] text-red-400 mt-1 ml-1 hidden';
document.getElementById('tuner-new-freqs').parentElement.parentElement.appendChild(errP);
}
const showErr = (msg) => { errP.textContent = msg; errP.classList.remove('hidden'); };
const clearErr = () => errP.classList.add('hidden');
if (!name || !inputStr) return showErr('Enter a name and notes/frequencies.');
const freqs = inputStr.split(',').map(s => {
s = s.trim();
const f = parseFloat(s);
if (!isNaN(f)) return Math.round(f * 100) / 100;
const nf = noteToFreq(s);
return nf !== null ? Math.round(nf * 100) / 100 : null;
}).filter(f => f !== null && !isNaN(f));
if (freqs.length === 0) return showErr('Invalid notes or frequencies — use E2, A2 or Hz values.');
clearErr();
config.customTunings[name] = { instrument, strings: freqs };
save({ tuningsChanged: true });
render();
nameInput.value = '';
instrInput.value = 'guitar-6';
freqsInput.value = '';
};
load();
})();
</script>
</div>
+284
View File
@@ -0,0 +1,284 @@
(function() {
const _TUNER_MIN_YIN_SAMPLES = 4096;
const _TUNER_FRAME_SIZE = 2048;
const _TUNER_MIN_DETECTABLE_HZ = 20;
const _FREQ_HISTORY_LEN = 3;
const _WARMUP_FRAMES = 2;
// If a frame is posted to the worker and no message/error comes back within
// this window, clear the in-flight guard so the poll loop can't latch shut.
const _FRAME_WATCHDOG_MS = 500;
let _audioCtx = null;
let _sourceNode = null;
let _stream = null;
let _processor = null;
let _gainNode = null;
let _accumBuffer = new Float32Array(0);
let _pendingBuffer = null;
let _detectInterval = null;
let _processingFrame = false;
let _yinWorker = null;
let _freqHistory = [];
let _validFrameCount = 0;
let _lastFreq = 0;
let _onResult = null;
let _usingDesktopBridge = false;
let _bridgeInterval = null;
// Bumped on every start/stop. An async start captures the value and aborts
// if it changes mid-flight, so a re-entrant start()/restart() can't orphan
// a worker + interval created by a superseded call.
let _startGen = 0;
// Timestamp of the last frame posted to the worker (watchdog, see above).
let _frameSentAt = 0;
function _octaveFold(freq, ref) {
if (!ref || freq <= 0) return freq;
while (freq > ref * 1.414) freq /= 2;
while (freq < ref / 1.414) freq *= 2;
return freq;
}
function _median(arr) {
if (!arr.length) return 0;
var s = arr.slice().sort(function(a, b) { return a - b; });
var mid = Math.floor(s.length / 2);
return s.length % 2 !== 0 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
function _handleYinResult(result) {
const rms = result ? result.rms : 0;
const hasSignal = rms > 0.01;
if (!result || (!hasSignal && result.confidence < 0.5) || (result.freq < _TUNER_MIN_DETECTABLE_HZ && result.freq !== 0)) {
_validFrameCount = 0; _freqHistory = []; _lastFreq = 0;
if (_onResult) _onResult({ smoothedFreq: null, rms, hasSignal: false });
return;
}
if (result.confidence < 0.5 && hasSignal) {
_validFrameCount = 0; _freqHistory = []; _lastFreq = 0;
if (_onResult) _onResult({ smoothedFreq: null, rms, hasSignal: false });
return;
}
_freqHistory.push(_octaveFold(result.freq, _lastFreq));
if (_freqHistory.length > _FREQ_HISTORY_LEN) _freqHistory.shift();
_validFrameCount++;
if (_validFrameCount <= _WARMUP_FRAMES) {
if (_onResult) _onResult({ smoothedFreq: null, rms, hasSignal });
return;
}
const smoothedFreq = _median(_freqHistory);
_lastFreq = smoothedFreq;
if (_onResult) _onResult({ smoothedFreq, rms, hasSignal });
}
// True while a frame is being processed by the worker, unless that frame is
// older than the watchdog window — in which case the worker is assumed wedged
// and the guard is released so the poll loop can recover.
function _frameBusy() {
if (!_processingFrame) return false;
if (Date.now() - _frameSentAt > _FRAME_WATCHDOG_MS) { _processingFrame = false; return false; }
return true;
}
async function _tryBridgeStart(audioInputMode, myGen) {
if (audioInputMode === 'browser') return false;
var desktop = (typeof window !== 'undefined') ? window.slopsmithDesktop : null;
if (!desktop || !desktop.isDesktop || !desktop.audio
|| typeof desktop.audio.isAvailable !== 'function') return false;
var available = false;
try { available = await desktop.audio.isAvailable(); } catch (_) {}
if (myGen !== _startGen) return false;
if (!available) return false;
// The tuner runs its own tuning-optimised YIN over the raw sample frame.
// The engine's getRawPitch endpoint is deliberately NOT used as a
// fallback — it produces a jittery readout. A build without
// getRawAudioFrame can't feed our pipeline, so fall back to getUserMedia
// instead of claiming the bridge.
if (typeof desktop.audio.getRawAudioFrame !== 'function') return false;
var started = false;
try {
var running = typeof desktop.audio.isAudioRunning === 'function'
? await desktop.audio.isAudioRunning() : false;
if (!running && typeof desktop.audio.startAudio === 'function') {
await desktop.audio.startAudio();
started = true;
}
} catch (e) {
// A failed startAudio means frames will never arrive — surface it
// rather than silently claiming a dead bridge.
console.warn('[tuner] bridge startAudio failed:', e && e.message ? e.message : e);
}
if (myGen !== _startGen) {
// Superseded by a newer start/stop while awaiting — undo any engine
// start we triggered and bail without claiming the bridge.
if (started && typeof desktop.audio.stopAudio === 'function') {
try { desktop.audio.stopAudio(); } catch (_) {}
}
return false;
}
var bridgeSampleRate = 48000;
try {
if (typeof desktop.audio.getSampleRate === 'function') {
var sr = await desktop.audio.getSampleRate();
if (typeof sr === 'number' && Number.isFinite(sr) && sr > 0) bridgeSampleRate = sr;
}
} catch (_) {}
if (myGen !== _startGen) return false;
_usingDesktopBridge = true;
console.log('[tuner] using desktop JUCE bridge with raw audio + YIN');
_yinWorker = new Worker('/api/plugins/tuner/workers/yin.js');
_yinWorker.onmessage = function(e) { _handleYinResult(e.data); _processingFrame = false; };
_yinWorker.onerror = function(e) { console.error('Tuner: YIN worker error', e); _processingFrame = false; };
_bridgeInterval = setInterval(async function() {
if (_frameBusy() || !_yinWorker) return;
try {
var samples = await desktop.audio.getRawAudioFrame(_TUNER_MIN_YIN_SAMPLES);
if (!_yinWorker) return; // torn down while awaiting the frame
if (!(samples instanceof Float32Array) || samples.length < _TUNER_MIN_YIN_SAMPLES) return;
// Copy rather than transfer: the engine may hand back a view onto
// a buffer it reuses, and transferring would detach it. A 4096-
// sample copy every 30 ms is negligible.
var frame = samples.slice();
_processingFrame = true;
_frameSentAt = Date.now();
_yinWorker.postMessage({ samples: frame, sampleRate: bridgeSampleRate }, [frame.buffer]);
} catch (e) {
console.warn('[tuner] bridge raw audio poll failed:', e && e.message ? e.message : e);
if (_onResult) _onResult({ smoothedFreq: null, rms: 0, hasSignal: false });
}
}, 30);
return true;
}
async function _doStart(deviceId, channel, audioInputMode) {
// Tear down any existing session first so a double start() can't orphan a
// worker/interval; _doStop bumps _startGen, which also aborts any start
// still suspended on an await.
_doStop();
const myGen = _startGen;
var bridgeStarted = await _tryBridgeStart(audioInputMode || 'auto', myGen);
if (myGen !== _startGen) return; // superseded while probing the bridge
if (bridgeStarted) return;
const constraints = {
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 2 }
};
if (deviceId) constraints.audio.deviceId = { exact: deviceId };
try {
_stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (e) {
if (e.name === 'OverconstrainedError' && deviceId) {
delete constraints.audio.deviceId;
delete constraints.audio.channelCount;
} else if (e.name === 'NotFoundError' && deviceId) {
delete constraints.audio.deviceId;
} else if (e.name === 'OverconstrainedError') {
delete constraints.audio.channelCount;
} else {
throw e;
}
_stream = await navigator.mediaDevices.getUserMedia(constraints);
}
if (myGen !== _startGen) {
// Superseded while awaiting mic permission — release the stream.
if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; }
return;
}
_audioCtx = new (window.AudioContext || window.webkitAudioContext)();
_sourceNode = _audioCtx.createMediaStreamSource(_stream);
_gainNode = _audioCtx.createGain();
_gainNode.gain.value = 1.0;
if (_sourceNode.channelCount >= 2 && channel !== 'mono') {
const splitter = _audioCtx.createChannelSplitter(2);
const merger = _audioCtx.createChannelMerger(1);
_sourceNode.connect(splitter);
splitter.connect(merger, channel === 'left' ? 0 : 1, 0);
merger.connect(_gainNode);
} else {
_sourceNode.connect(_gainNode);
}
_processor = _audioCtx.createScriptProcessor(_TUNER_FRAME_SIZE, 1, 1);
_processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0);
const combined = new Float32Array(_accumBuffer.length + input.length);
combined.set(_accumBuffer);
combined.set(input, _accumBuffer.length);
if (combined.length >= _TUNER_MIN_YIN_SAMPLES) {
_pendingBuffer = combined.slice(combined.length - _TUNER_MIN_YIN_SAMPLES);
_accumBuffer = combined.slice(input.length);
} else {
_accumBuffer = combined;
}
};
_gainNode.connect(_processor);
_processor.connect(_audioCtx.destination);
_yinWorker = new Worker('/api/plugins/tuner/workers/yin.js');
_yinWorker.onmessage = (e) => { _handleYinResult(e.data); _processingFrame = false; };
_yinWorker.onerror = (e) => { console.error('Tuner: YIN worker error', e); _processingFrame = false; };
_detectInterval = setInterval(() => {
if (_frameBusy() || !_pendingBuffer || !_yinWorker) return;
const buf = _pendingBuffer;
_pendingBuffer = null;
_processingFrame = true;
_frameSentAt = Date.now();
_yinWorker.postMessage({ samples: buf, sampleRate: _audioCtx.sampleRate }, [buf.buffer]);
}, 30);
}
function _doStop() {
// Invalidate any start still suspended on an await so it aborts instead
// of installing a worker/interval after we've torn down.
_startGen++;
if (_bridgeInterval) { clearInterval(_bridgeInterval); _bridgeInterval = null; }
_usingDesktopBridge = false;
if (_detectInterval) { clearInterval(_detectInterval); _detectInterval = null; }
if (_yinWorker) { _yinWorker.terminate(); _yinWorker = null; }
_processingFrame = false;
_pendingBuffer = null;
_accumBuffer = new Float32Array(0);
_freqHistory = [];
_validFrameCount = 0;
_lastFreq = 0;
if (_processor) { _processor.disconnect(); _processor = null; }
if (_gainNode) { _gainNode.disconnect(); _gainNode = null; }
if (_sourceNode) { _sourceNode.disconnect(); _sourceNode = null; }
if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; }
if (_audioCtx) { _audioCtx.close(); _audioCtx = null; }
}
window._tunerAudio = {
start: async function(options, onResult) {
_onResult = onResult;
await _doStart(options.deviceId, options.channel, options.audioInputMode || 'auto');
},
stop: function() {
_onResult = null;
_doStop();
},
restart: async function(options) {
_doStop();
await _doStart(options.deviceId, options.channel, options.audioInputMode || 'auto');
},
get usingBridge() { return _usingDesktopBridge; },
};
})();
+80
View File
@@ -0,0 +1,80 @@
(function () {
'use strict';
function freqToMidi(f) { return 69 + 12 * Math.log2(f / 440); }
function midiToFreq(m) { return Math.pow(2, (m - 69) / 12) * 440; }
const _NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const _NOTE_FLAT = ['C','Db','D','Eb', 'E','F','Gb','G','Ab','A','Bb', 'B'];
// Returns true when the tuning name implies flat notation (e.g. "Eb Standard", "Bb Standard").
function preferFlats(tuningName) {
return typeof tuningName === 'string' && /\b[A-G]b\b/.test(tuningName);
}
function midiToNote(m, useFlats) {
return (useFlats ? _NOTE_FLAT : _NOTE_NAMES)[((Math.round(m) % 12) + 12) % 12];
}
// Per-string-count standard open-string MIDI arrays
const _BASE_MIDI = {
4: [28, 33, 38, 43], // bass 4: E1 A1 D2 G2
5: [23, 28, 33, 38, 43], // bass 5: B0 E1 A1 D2 G2
6: [40, 45, 50, 55, 59, 64], // guitar 6: E2 A2 D3 G3 B3 E4
7: [35, 40, 45, 50, 55, 59, 64], // guitar 7: B1 E2 A2 D3 G3 B3 E4
8: [30, 35, 40, 45, 50, 55, 59, 64], // guitar 8: F#1 B1 E2 A2 D3 G3 B3 E4
};
function offsetsToFreqs(offsets, isBass) {
const len = offsets.length;
let base;
if (len === 4 || len === 5) {
base = isBass ? _BASE_MIDI[len] : _BASE_MIDI[6];
} else {
base = _BASE_MIDI[len] || _BASE_MIDI[6];
}
return offsets.map((offset, i) => {
const root = i < base.length ? base[i] : base[base.length - 1];
return midiToFreq(root + offset);
});
}
function getTuningName(offsets) {
if (!offsets || offsets.length === 0) return 'Unknown';
const len = offsets.length;
if (len < 4 || len > 8) return offsets.join(' ');
// First-string open MIDI for this length (same table as _BASE_MIDI column 0).
const firstStringMidi = (_BASE_MIDI[len] || _BASE_MIDI[6])[0];
const noteNames = ['C','C#','D','Eb','E','F','F#','G','Ab','A','A#','B'];
// All-equal: name by the note the lowest string becomes at this offset.
if (offsets.every(o => o === offsets[0])) {
const noteIdx = ((firstStringMidi + offsets[0]) % 12 + 12) % 12;
return noteNames[noteIdx] + ' Standard';
}
// Drop tuning: first string is exactly 2 semitones below the rest (all equal).
if (offsets[0] === offsets[1] - 2 && offsets.slice(1).every(o => o === offsets[1])) {
const noteIdx = ((firstStringMidi + offsets[0]) % 12 + 12) % 12;
return 'Drop ' + noteNames[noteIdx];
}
// Named lookup table for specific patterns
const named = {
// 6-string
'-2,0,0,0,0,0': 'Drop D', '-4,-2,-2,-2,-2,-2': 'Drop C',
'-2,-2,0,0,0,0': 'Double Drop D', '0,0,0,-1,0,0': 'Open G',
'-2,-2,0,0,-2,-2': 'Open D', '-2,0,0,0,-2,0': 'DADGAD',
'0,2,2,1,0,0': 'Open E', '-2,0,0,2,3,2': 'Open D (alt)',
// 7-string
'-2,0,0,0,0,0,0': 'Drop A',
// 8-string
'-2,0,0,0,0,0,0,0': 'Drop E',
};
const key = offsets.join(',');
if (named[key]) return named[key];
return offsets.join(' ');
}
window._tunerUtils = { freqToMidi, midiToFreq, midiToNote, offsetsToFreqs, getTuningName, preferFlats };
})();
+750
View File
@@ -0,0 +1,750 @@
window._tunerUI = function(state, actions) {
const _AUTO_TARGET_HYSTERESIS_CENTS = 40;
let _lastAutoTargetFreq = null;
let _lastTuningRef = null;
// Octave-aware nearest-string match. YIN frequently reports a sub-octave on
// low strings (D1 instead of D2) and a common sub-harmonic on polyphonic
// plucks (D+G together → G0), which a raw-distance match snaps to the wrong
// (lower) string. Fold the detected frequency into each candidate string's
// octave before measuring distance so an octave-off reading still resolves
// to the right string. Ties — a tuning with the same pitch class in two
// octaves, e.g. guitar E2/E4 — resolve to the smallest octave shift, i.e.
// the octave actually being played. Returns the matched string frequency
// and the detected frequency folded into that string's octave.
function _matchString(detected, strings) {
let bestFreq = null, bestResidual = Infinity, bestShift = Infinity;
for (const f of strings) {
if (!Number.isFinite(f) || f <= 0) continue; // skip malformed tuning entries
const shift = Math.round(Math.log2(f / detected));
const corrected = detected * Math.pow(2, shift);
const residual = Math.abs(Math.log2(corrected / f)) * 1200;
if (residual < bestResidual - 1
|| (Math.abs(residual - bestResidual) <= 1 && Math.abs(shift) < bestShift)) {
bestFreq = f; bestResidual = residual; bestShift = Math.abs(shift);
}
}
return bestFreq; // null when no usable string frequency exists
}
// Fold a detected frequency into the same octave as a known target, so cents
// and the displayed Hz reflect deviation within the octave rather than a
// ±1200 swing when the detector reports the wrong octave.
function _foldToOctaveOf(detected, target) {
return detected * Math.pow(2, Math.round(Math.log2(target / detected)));
}
const _INSTRUMENT_DISPLAY = {
'guitar-6': 'Guitar (6)', 'guitar-7': 'Guitar (7)', 'guitar-8': 'Guitar (8)',
'bass-4': 'Bass (4)', 'bass-5': 'Bass (5)',
};
function _freqsEqual(a, b) {
if (!a || !b || a.length !== b.length) return false;
return a.every(function(f, i) { return Math.round(f * 100) === Math.round(b[i] * 100); });
}
function _tuningAlreadyKnown(freqs) {
if (!freqs || !freqs.length) return false;
const instrument = state.selectedInstrument;
const known = (state._allTunings && state._allTunings[instrument]) || {};
for (var name in known) {
if (_freqsEqual(freqs, known[name])) return true;
}
return false;
}
function _updateInstrumentDisplay() {
if (state._instrumentSentinel) {
state._instrumentSentinel.textContent = _INSTRUMENT_DISPLAY[state.selectedInstrument] || state.selectedInstrument;
if (state.instrumentSelect) state.instrumentSelect.value = '__display__';
}
}
function _syncStringHighlight(targetFreq) {
if (!state.stringNoteContainer) return;
Array.from(state.stringNoteContainer.children).forEach(btn => {
const match = targetFreq !== null && Math.abs(parseFloat(btn.dataset.freq) - targetFreq) < 0.1;
btn.className = match
? 'flex-1 py-1.5 text-xs font-bold rounded bg-accent text-white border border-accent transition-colors'
: 'flex-1 py-1.5 text-xs font-bold rounded bg-fb-cardMuted text-fb-textDim border border-fb-border/50 hover:border-fb-border transition-colors';
});
}
function _syncActiveStringFromFreq(targetFreq, isManual) {
if (!state.stringNoteContainer) return;
Array.from(state.stringNoteContainer.children).forEach(btn => {
const match = Math.abs(parseFloat(btn.dataset.freq) - targetFreq) < 0.1;
if (match) {
btn.className = isManual
? 'flex-1 py-1.5 text-xs font-bold rounded bg-accent text-white border border-accent transition-colors'
: 'flex-1 py-1.5 text-xs font-bold rounded bg-fb-cardMuted text-accent border border-accent transition-colors';
} else {
btn.className = 'flex-1 py-1.5 text-xs font-bold rounded bg-fb-cardMuted text-fb-textDim border border-fb-border/50 hover:border-fb-border transition-colors';
}
});
}
function _updateSaveAsCustomVisibility() {
if (!state.saveAsCustomContainer) return;
const show = state.selectedTuningName === '_current'
&& state.selectedTuning
&& state.selectedTuning.length > 0
&& !_tuningAlreadyKnown(state.selectedTuning);
if (show) {
state.saveAsCustomContainer.classList.remove('hidden');
} else {
state.saveAsCustomContainer.classList.add('hidden');
const inp = state.saveAsCustomContainer.querySelector('.tuner-save-inline');
if (inp) inp.remove();
}
}
function _showSaveAsCustomInput() {
if (state.saveAsCustomContainer.querySelector('.tuner-save-inline')) return;
const labelBtn = state.saveAsCustomContainer.querySelector('.tuner-save-label');
if (labelBtn) labelBtn.classList.add('hidden');
const inline = document.createElement('div');
inline.className = 'tuner-save-inline flex gap-2 w-full';
const suggestedName = (state.currentSongOffsets && window._tunerUtils)
? (window._tunerUtils.getTuningName(state.currentSongOffsets) || 'Custom Tuning')
: 'Custom Tuning';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = suggestedName;
nameInput.className = 'flex-1 bg-fb-cardMuted border border-fb-border/50 rounded px-2 py-1 text-xs text-fb-text outline-none focus:border-accent';
const confirmBtn = document.createElement('button');
confirmBtn.textContent = 'Save';
confirmBtn.className = 'bg-accent/20 hover:bg-accent/30 border border-accent/40 text-accent text-xs px-3 py-1 rounded transition-colors';
const doSave = async () => {
const name = nameInput.value.trim();
if (!name || !state.selectedTuning || state.selectedTuning.length === 0) return;
const rounded = state.selectedTuning.map(f => Math.round(f * 100) / 100);
const sc = rounded.length;
const instrument = (sc === 4 || sc === 5)
? (state.currentSongIsBass ? 'bass-' + sc : 'guitar-6')
: (sc === 7 ? 'guitar-7' : sc === 8 ? 'guitar-8' : 'guitar-6');
try {
const config = await fetch('/api/plugins/tuner/config').then(r => r.json());
const custom = config.customTunings || {};
custom[name] = { instrument, strings: rounded };
await fetch('/api/plugins/tuner/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customTunings: custom }),
});
state.selectedInstrument = instrument;
state.selectedTuningName = name;
state.selectedTuning = rounded;
_updateInstrumentDisplay();
await actions.loadConfig();
state.selectedTuningName = name;
state.selectedTuning = state.tunings[name] || rounded;
if (state.tuningSelect) state.tuningSelect.value = name;
renderStringNotes();
actions.saveConfig();
window.slopsmith?.emit('tunings:updated');
} catch (e) {
console.error('Tuner: Failed to save custom tuning', e);
}
};
confirmBtn.onclick = doSave;
nameInput.onkeydown = (e) => { if (e.key === 'Enter') doSave(); };
inline.appendChild(nameInput);
inline.appendChild(confirmBtn);
state.saveAsCustomContainer.appendChild(inline);
nameInput.focus();
nameInput.select();
}
function renderInstrumentOptions() {
if (!state.instrumentSelect) return;
state.instrumentSelect.innerHTML = '';
state._instrumentSentinel = document.createElement('option');
state._instrumentSentinel.value = '__display__';
state._instrumentSentinel.textContent = _INSTRUMENT_DISPLAY[state.selectedInstrument] || state.selectedInstrument;
state._instrumentSentinel.style.display = 'none';
state.instrumentSelect.appendChild(state._instrumentSentinel);
const guitarGroup = document.createElement('optgroup');
guitarGroup.label = 'Guitar';
[['guitar-6', '6-string'], ['guitar-7', '7-string'], ['guitar-8', '8-string']].forEach(([val, label]) => {
const opt = document.createElement('option');
opt.value = val; opt.textContent = label;
guitarGroup.appendChild(opt);
});
const bassGroup = document.createElement('optgroup');
bassGroup.label = 'Bass';
[['bass-4', '4-string'], ['bass-5', '5-string']].forEach(([val, label]) => {
const opt = document.createElement('option');
opt.value = val; opt.textContent = label;
bassGroup.appendChild(opt);
});
state.instrumentSelect.appendChild(guitarGroup);
state.instrumentSelect.appendChild(bassGroup);
state.instrumentSelect.value = '__display__';
}
function renderTuningOptions() {
if (!state.tuningSelect) return;
state.tuningSelect.innerHTML = '';
const isPlayer = document.getElementById('player')?.classList.contains('active');
if (isPlayer && typeof window.highway?.getSongInfo === 'function') {
const info = window.highway.getSongInfo();
if (info && info.tuning) {
const ctx = (typeof window.slopsmith?.songTuningContext === 'function')
? window.slopsmith.songTuningContext(info)
: {
stringCount: info.stringCount,
arrangement: info.arrangement,
arrangement_smart_name: info.arrangement_smart_name,
};
const isBass = (typeof window.slopsmith?.isBassArrangement === 'function')
? window.slopsmith.isBassArrangement(ctx)
: (info.arrangement || '').toLowerCase().includes('bass');
const sc = (typeof window.slopsmith?.effectiveStringCount === 'function')
? window.slopsmith.effectiveStringCount(info.tuning, ctx)
: (info.stringCount || info.tuning.length);
const sliced = info.tuning.slice(0, sc);
const freqs = window._tunerUtils.offsetsToFreqs(sliced, isBass);
const tName = (typeof window.displayTuningName === 'function')
? window.displayTuningName(null, sliced)
: window._tunerUtils.getTuningName(sliced);
const opt = document.createElement('option');
opt.value = '_current';
opt.textContent = `Current Song [${tName || 'Custom Tuning'}]`;
state.tuningSelect.appendChild(opt);
if (state.selectedTuningName === '_current') state.selectedTuning = freqs;
} else if (state.selectedTuningName === '_current') {
state.selectedTuning = null;
}
} else if (state.selectedTuningName === '_current') {
state.selectedTuning = null;
}
const freeTuneOpt = document.createElement('option');
freeTuneOpt.value = 'free-tune';
freeTuneOpt.textContent = 'Free Tune';
state.tuningSelect.appendChild(freeTuneOpt);
Object.keys(state.tunings).forEach(name => {
const opt = document.createElement('option');
opt.value = name; opt.textContent = name;
state.tuningSelect.appendChild(opt);
});
if (state.selectedTuningName) state.tuningSelect.value = state.selectedTuningName;
}
function _noteLabelForFreq(f) {
const midi = window._tunerUtils.freqToMidi(f);
const rounded = Math.round(midi);
const name = window._tunerUtils.midiToNote(rounded, state.useFlats);
const octave = Math.floor(rounded / 12) - 1;
return name + octave;
}
function _noteNameOnly(f) {
return window._tunerUtils.midiToNote(
Math.round(window._tunerUtils.freqToMidi(f)),
state.useFlats
);
}
function _stringOrdinal(n) {
const v = n % 100;
if (v >= 11 && v <= 13) return n + 'th';
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
return n + suffix;
}
function _stringButtonLabel(index, total, f) {
if (state.selectedTuningName === '_current') {
const stringNum = total - index;
const note = _noteNameOnly(f);
return {
text: note,
title: _stringOrdinal(stringNum) + ' string: ' + _noteLabelForFreq(f),
};
}
return { text: _noteLabelForFreq(f), title: '' };
}
function _syncStringOrderHelp(total) {
if (!state.stringOrderHelpContainer) return;
const show = state.selectedTuningName === '_current'
&& state.selectedTuning
&& state.selectedTuning.length > 0;
if (!show) {
state.stringOrderHelpContainer.classList.add('hidden');
state.stringOrderHelpContainer.innerHTML = '';
return;
}
const count = total || state.selectedTuning.length;
const notes = state.selectedTuning.map((f) => _noteNameOnly(f)).join(' ');
state.stringOrderHelpContainer.classList.remove('hidden');
state.stringOrderHelpContainer.innerHTML =
'<div class="text-fb-textDim">Tune low-to-high: <span class="text-fb-text font-semibold tracking-wide">' + notes + '</span></div>'
+ '<div class="text-fb-textDim/70 mt-0.5">' + _stringOrdinal(count) + ' string → 1st string</div>';
}
function renderStringNotes() {
if (!state.stringNoteContainer) return;
state.stringNoteContainer.innerHTML = '';
if (!state.selectedTuning || state.selectedTuning.length === 0) {
_syncStringOrderHelp(0);
return;
}
const total = state.selectedTuning.length;
state.selectedTuning.forEach((f, index) => {
const btn = document.createElement('button');
btn.dataset.freq = f;
btn.className = 'flex-1 py-1.5 text-xs font-bold rounded bg-fb-cardMuted text-fb-textDim border border-fb-border/50 hover:border-fb-border transition-colors';
const label = _stringButtonLabel(index, total, f);
btn.textContent = label.text;
if (label.title) btn.title = label.title;
btn.onclick = () => {
state.manualTargetFreq = state.manualTargetFreq === f ? null : f;
_syncStringHighlight(state.manualTargetFreq);
};
state.stringNoteContainer.appendChild(btn);
});
_syncStringOrderHelp(total);
}
function updateUI(result) {
const { smoothedFreq, rms, hasSignal } = result;
const vizMode = state.manualTargetFreq ? 'manual'
: (state.freeTune || !(state.selectedTuning && state.selectedTuning.length > 0) ? 'free' : 'auto');
// Treat null / non-finite / non-positive as no-signal. A 0, NaN or
// negative frequency would otherwise propagate through log2/division
// below into NaN cents and a garbage readout.
const referencePitch = state.referencePitch || 440;
if (smoothedFreq === null || !Number.isFinite(smoothedFreq) || smoothedFreq <= 0) {
_lastAutoTargetFreq = null;
if (state.activeViz) state.activeViz.update(null, 0, 0, vizMode, null, referencePitch);
_syncStringHighlight(state.manualTargetFreq);
if (window.slopsmith && window.slopsmith.emit) {
window.slopsmith.emit('tuner:frame', { note: null, cents: 0, freq: 0, hasSignal: false });
}
return;
}
// Reset the committed target when the tuning selection changes.
if (state.selectedTuning !== _lastTuningRef) {
_lastAutoTargetFreq = null;
_lastTuningRef = state.selectedTuning;
}
let targetFreq, isManual = false, displayFreq = smoothedFreq;
if (state.manualTargetFreq) {
targetFreq = state.manualTargetFreq;
isManual = true;
} else if (!state.freeTune && state.selectedTuning && state.selectedTuning.length > 0
&& _matchString(smoothedFreq, state.selectedTuning) !== null) {
let nearest = _matchString(smoothedFreq, state.selectedTuning);
// Hysteresis: once committed to a string, only switch when the new
// match is clearly closer (≥40 cents), so a pluck that lands between
// two strings can't flicker. Octave-aware residuals keep an octave
// error from ever masquerading as a different string.
if (_lastAutoTargetFreq !== null && nearest !== _lastAutoTargetFreq) {
const residualNew = Math.abs(Math.log2(_foldToOctaveOf(smoothedFreq, nearest) / nearest)) * 1200;
const residualPrev = Math.abs(Math.log2(_foldToOctaveOf(smoothedFreq, _lastAutoTargetFreq) / _lastAutoTargetFreq)) * 1200;
if (residualPrev - residualNew < _AUTO_TARGET_HYSTERESIS_CENTS) nearest = _lastAutoTargetFreq;
}
_lastAutoTargetFreq = nearest;
targetFreq = nearest;
} else {
targetFreq = window._tunerUtils.midiToFreq(Math.round(window._tunerUtils.freqToMidi(smoothedFreq)));
}
// Fold the reading into the target's octave so a sub-octave detection
// (D1 read for a D2 string) shows the right note and real cents.
if (!isManual && targetFreq) displayFreq = _foldToOctaveOf(smoothedFreq, targetFreq);
const cents = (window._tunerUtils.freqToMidi(displayFreq) - window._tunerUtils.freqToMidi(targetFreq)) * 100;
const note = window._tunerUtils.midiToNote(window._tunerUtils.freqToMidi(targetFreq), state.useFlats);
if (state.activeViz) state.activeViz.update(note, cents, displayFreq, vizMode, targetFreq, referencePitch, state.useFlats);
if (state.freeTune) _syncStringHighlight(null);
else _syncActiveStringFromFreq(targetFreq, isManual);
if (window.slopsmith && window.slopsmith.emit) {
window.slopsmith.emit('tuner:frame', { note, cents, freq: displayFreq, hasSignal: true });
}
}
function updateFloatingButtonVisibility() {
const btn = document.getElementById('tuner-toggle-btn');
if (!btn) return;
const isPlayer = document.querySelector('.screen.active')?.id === 'player';
if (!state.showFloatingButton || isPlayer || window.slopsmith?.isPlaying) {
btn.classList.add('hidden');
} else {
btn.classList.remove('hidden');
}
}
function updateFloatingButton() {
const btn = document.getElementById('tuner-toggle-btn');
if (!btn) return;
const isHidden = btn.classList.contains('hidden');
btn.className = state.enabled
? 'fixed bottom-5 right-5 px-4 py-2.5 bg-accent/20 hover:bg-accent/30 border border-accent text-accent rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-2xl z-[1001]'
: 'fixed bottom-5 right-5 px-4 py-2.5 bg-dark-700 hover:bg-dark-500 border border-gray-800 text-gray-300 hover:text-white rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-2xl z-[1001]';
if (isHidden) btn.classList.add('hidden');
updateFloatingButtonVisibility();
}
function updatePlayerButton() {
const btn = document.getElementById('btn-tuner-player');
if (!btn) return;
btn.className = state.enabled
? 'px-3 py-1.5 bg-accent/20 hover:bg-accent/30 border border-accent rounded-lg text-xs text-accent transition'
: 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-400 transition';
}
function showSettings() {
let panel = state.uiContainer.querySelector('.tuner-settings-panel');
if (panel) { panel.remove(); return; }
panel = document.createElement('div');
panel.className = 'tuner-settings-panel w-full bg-fb-cardMuted border border-fb-border/30 rounded-lg p-3 mb-3 text-xs';
panel.innerHTML = `
<div class="mb-2">
<span class="text-fb-textDim font-semibold uppercase tracking-tighter">Audio Settings</span>
</div>
<div class="tuner-mic-section">
<label class="block text-fb-textDim mb-1">Microphone</label>
<select class="tuner-device-select w-full bg-fb-cardMuted border border-fb-border/50 rounded px-2 py-1 text-fb-text mb-2 outline-none focus:border-accent">
<option value="">Default</option>
</select>
</div>
<div class="tuner-channel-section">
<label class="block text-fb-textDim mb-1">Input Channel</label>
<select class="tuner-channel-select w-full bg-fb-cardMuted border border-fb-border/50 rounded px-2 py-1 text-fb-text outline-none focus:border-accent">
<option value="mono" ${state.selectedChannel === 'mono' ? 'selected' : ''}>Mono (mix both)</option>
<option value="left" ${state.selectedChannel === 'left' ? 'selected' : ''}>Left (Channel 1)</option>
<option value="right" ${state.selectedChannel === 'right' ? 'selected' : ''}>Right (Channel 2)</option>
</select>
</div>
<label class="block text-fb-textDim mb-1 mt-2">Visualization</label>
<select class="tuner-viz-select w-full bg-fb-cardMuted border border-fb-border/50 rounded px-2 py-1 text-fb-text outline-none focus:border-accent">
<option value="default" ${state.visualizationMode === 'default' ? 'selected' : ''}>Default</option>
<option value="strobe" ${state.visualizationMode === 'strobe' ? 'selected' : ''}>Strobe</option>
<option value="analogue-gauge" ${state.visualizationMode === 'analogue-gauge' ? 'selected' : ''}>Analogue Gauge</option>
<option value="mace-fx-iii" ${state.visualizationMode === 'mace-fx-iii' ? 'selected' : ''}>Mace-Fx III</option>
<option value="pp-tiny" ${state.visualizationMode === 'pp-tiny' ? 'selected' : ''}>Bender PP-Tiny</option>
<option value="chef-mt3" ${state.visualizationMode === 'chef-mt3' ? 'selected' : ''}>CHEF MT-3</option>
<option value="toilet-tuner" ${state.visualizationMode === 'toilet-tuner' ? 'selected' : ''}>Toilet Tuner</option>
</select>
`;
state.uiContainer.insertBefore(panel, state.stringNoteContainer);
panel.querySelector('.tuner-device-select').onchange = (e) => {
state.selectedDeviceId = e.target.value;
actions.saveSettings();
if (state.enabled) actions.restartAudio();
};
panel.querySelector('.tuner-channel-select').onchange = (e) => {
state.selectedChannel = e.target.value;
actions.saveSettings();
if (state.enabled) actions.restartAudio();
};
panel.querySelector('.tuner-viz-select').onchange = async (e) => {
state.visualizationMode = e.target.value;
await actions.setVisualization(state.visualizationMode);
actions.saveConfig();
const vizMode = state.manualTargetFreq ? 'manual' : (state.freeTune || !(state.selectedTuning && state.selectedTuning.length > 0) ? 'free' : 'auto');
if (state.activeViz) state.activeViz.update(null, 0, 0, vizMode);
};
populateDevices(panel);
if (window._tunerAudio && window._tunerAudio.usingBridge) {
var micSec = panel.querySelector('.tuner-mic-section');
var chanSec = panel.querySelector('.tuner-channel-section');
if (micSec) micSec.classList.add('hidden');
if (chanSec) chanSec.classList.add('hidden');
}
}
async function populateDevices(panel) {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const sel = panel.querySelector('.tuner-device-select');
if (!sel) return;
sel.innerHTML = '<option value="">Default</option>';
for (const d of devices) {
if (d.kind !== 'audioinput') continue;
const opt = document.createElement('option');
opt.value = d.deviceId;
opt.textContent = d.label || `Input ${d.deviceId.slice(0, 8)}`;
if (d.deviceId === state.selectedDeviceId) opt.selected = true;
sel.appendChild(opt);
}
} catch (e) { /* permission not yet granted */ }
}
function showMicError(e) {
const name = e?.name || '';
let msg, hint;
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
msg = 'Microphone access denied.';
hint = 'On macOS open System Settings → Privacy &amp; Security → Microphone and enable your browser, then refresh the page.';
} else if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
msg = 'No audio input found.';
hint = 'Make sure your Real Tone Cable (or other audio input) is plugged in and recognised by macOS (check Audio MIDI Setup).';
} else if (name === 'NotReadableError' || name === 'AbortError' || name === 'TrackStartError') {
msg = 'Could not open the audio device.';
hint = 'On macOS: (1) open Audio MIDI Setup (Applications → Utilities) and confirm the device appears with a compatible sample rate (44100 or 48000 Hz); (2) check System Settings → Privacy &amp; Security → Microphone — your browser must be listed and enabled; (3) try unplugging and replugging the cable.';
} else {
msg = 'Could not access microphone.';
hint = `Error: ${name || e?.message || 'unknown'}`;
}
if (!state.uiContainer) { alert(`Tuner: ${msg}\n${hint.replace(/&amp;/g, '&')}`); return; }
let errEl = state.uiContainer.querySelector('.tuner-mic-error');
if (!errEl) {
errEl = document.createElement('div');
errEl.className = 'tuner-mic-error relative w-full mt-2 p-3 bg-red-900/40 border border-red-700/60 rounded-lg text-xs text-red-300 leading-relaxed';
state.uiContainer.appendChild(errEl);
}
errEl.innerHTML = `<strong>${msg}</strong><br>${hint}`;
const dismissBtn = document.createElement('button');
dismissBtn.className = 'absolute top-1.5 right-2 text-red-400 hover:text-red-200 text-sm font-bold leading-none';
dismissBtn.textContent = '×';
dismissBtn.onclick = () => errEl.remove();
errEl.appendChild(dismissBtn);
state.uiContainer.classList.remove('hidden');
state.uiContainer.classList.add('flex');
}
function updateFreeTuneUI() {
if (!state.freeTuneToggle) return;
const on = state.freeTune;
state.freeTuneToggle.style.backgroundColor = on ? '#4080e0' : '#334155';
state.freeTuneToggle.setAttribute('aria-checked', String(on));
const knob = state.freeTuneToggle.querySelector('span');
if (knob) knob.style.left = on ? '18px' : '2px';
if (state.stringNoteContainer) {
state.stringNoteContainer.style.opacity = on ? '0.35' : '';
state.stringNoteContainer.style.pointerEvents = on ? 'none' : '';
state.stringNoteContainer.style.transition = 'opacity 0.15s';
}
_syncStringOrderHelp();
}
function initUI() {
if (state.uiContainer) return;
state.uiContainer = document.createElement('div');
state.uiContainer.id = 'tuner-plugin-ui';
state.uiContainer.className = 'fixed w-72 bg-fb-card border border-fb-border/50 rounded-xl p-4 text-white z-[1000] hidden flex-col items-center shadow-2xl backdrop-blur-md';
const header = document.createElement('div');
header.className = 'flex justify-center items-center w-full mb-3 relative';
const title = document.createElement('div');
title.className = 'font-bold text-xs text-fb-textDim uppercase tracking-wider';
title.textContent = 'TUNER';
header.appendChild(title);
const settingsBtn = document.createElement('button');
settingsBtn.className = 'absolute right-0 text-fb-textDim hover:text-fb-text transition-colors';
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`;
settingsBtn.onclick = showSettings;
header.appendChild(settingsBtn);
state.uiContainer.appendChild(header);
state.stringOrderHelpContainer = document.createElement('div');
state.stringOrderHelpContainer.className = 'tuner-string-order-help hidden w-full mb-2 text-center text-[10px] leading-snug';
state.uiContainer.appendChild(state.stringOrderHelpContainer);
state.stringNoteContainer = document.createElement('div');
state.stringNoteContainer.className = 'flex justify-between w-full mb-3 gap-1';
state.uiContainer.appendChild(state.stringNoteContainer);
renderStringNotes();
state.saveAsCustomContainer = document.createElement('div');
state.saveAsCustomContainer.className = 'w-full mb-3 hidden';
const labelBtn = document.createElement('button');
labelBtn.className = 'tuner-save-label w-full text-[11px] text-accent/70 hover:text-accent border border-accent/20 hover:border-accent/50 rounded-lg py-1.5 transition-colors';
labelBtn.textContent = 'Save as Custom Tuning';
labelBtn.onclick = _showSaveAsCustomInput;
state.saveAsCustomContainer.appendChild(labelBtn);
state.uiContainer.appendChild(state.saveAsCustomContainer);
const freeTuneRow = document.createElement('div');
freeTuneRow.className = 'flex items-center justify-between w-full mb-3';
const freeTuneLabel = document.createElement('span');
freeTuneLabel.className = 'text-xs text-fb-textDim select-none';
freeTuneLabel.textContent = 'Free Tune';
const toggleTrack = document.createElement('button');
toggleTrack.type = 'button';
toggleTrack.setAttribute('role', 'switch');
toggleTrack.setAttribute('aria-checked', String(state.freeTune));
toggleTrack.style.cssText = 'position:relative;width:2.25rem;height:1.25rem;border-radius:9999px;border:none;cursor:pointer;transition:background-color 0.15s;outline:none;flex-shrink:0;background-color:' + (state.freeTune ? '#4080e0' : '#334155');
const toggleKnob = document.createElement('span');
toggleKnob.style.cssText = 'position:absolute;top:2px;width:1rem;height:1rem;border-radius:9999px;background:white;transition:left 0.15s;left:' + (state.freeTune ? '18px' : '2px');
toggleTrack.appendChild(toggleKnob);
state.freeTuneToggle = toggleTrack;
toggleTrack.addEventListener('click', () => {
state.freeTune = !state.freeTune;
if (state.freeTune) state.manualTargetFreq = null;
updateFreeTuneUI();
actions.saveConfig();
});
freeTuneRow.appendChild(freeTuneLabel);
freeTuneRow.appendChild(toggleTrack);
state.uiContainer.appendChild(freeTuneRow);
state.vizContainer = document.createElement('div');
state.vizContainer.className = 'w-full';
state.uiContainer.appendChild(state.vizContainer);
document.body.appendChild(state.uiContainer);
state.uiContainer.addEventListener('click', (e) => e.stopPropagation());
}
function positionPanel() {
if (!state.uiContainer) return;
const isPlayer = document.getElementById('player')?.classList.contains('active');
const playerEl = document.getElementById('player');
const wrap = document.getElementById('v3-tuner-wrap');
// #player is a full-screen overlay (z-index 100) that covers the v3
// topbar — anchoring the panel to #v3-tuner-wrap hides it underneath.
if (isPlayer && playerEl) {
if (state.uiContainer.parentElement !== document.body) {
document.body.appendChild(state.uiContainer);
}
state.uiContainer.className = state.uiContainer.className
.replace('absolute', 'fixed')
.replace('right-0', '')
.replace('top-full', '')
.trim();
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
return;
}
if (!wrap) {
// Non-v3 fallback: fixed bottom-right above the floating button.
if (state.uiContainer.parentElement !== document.body) document.body.appendChild(state.uiContainer);
state.uiContainer.className = state.uiContainer.className
.replace('absolute', 'fixed')
.replace('right-0', '')
.replace('top-full', '')
.trim();
state.uiContainer.style.cssText = 'bottom:5rem;right:1.25rem';
return;
}
// Move panel into the relative wrapper so right-0 aligns its right edge
// with the right edge of the badge button, exactly like the instrument panel.
if (state.uiContainer.parentElement !== wrap) wrap.appendChild(state.uiContainer);
state.uiContainer.className = state.uiContainer.className
.replace('fixed', 'absolute')
.trim();
state.uiContainer.style.cssText = 'top:100%;right:0;margin-top:8px';
}
function addButton() {
if (document.getElementById('tuner-toggle-btn')) return;
const btn = document.createElement('button');
btn.id = 'tuner-toggle-btn';
btn.textContent = 'Tuner';
btn.title = 'Open Tuner';
btn.onclick = window.tuner.toggle;
document.body.appendChild(btn);
updateFloatingButton();
updateFloatingButtonVisibility();
const handlePlay = () => {
updateFloatingButtonVisibility();
if (state.enabled) actions.disable();
};
const handleStop = () => updateFloatingButtonVisibility();
if (window.slopsmith) {
window.slopsmith.on('song:play', handlePlay);
window.slopsmith.on('song:pause', handleStop);
window.slopsmith.on('song:ended', handleStop);
window.slopsmith.on('screen:changed', (e) => {
if (e.detail.id === 'player') { handlePlay(); injectPlayerButton(); }
else handleStop();
});
if (window.slopsmith.isPlaying || document.querySelector('.screen.active')?.id === 'player') {
handlePlay();
if (document.querySelector('.screen.active')?.id === 'player') injectPlayerButton();
} else {
updateFloatingButtonVisibility();
}
}
}
function injectPlayerButton() {
// v3: mount into the host's stable plugin-control slot (Plugins rail
// popover). The legacy `button:last-child` anchor resolves to a NESTED
// transport button in v3 and would throw on insertBefore; the slot is
// always present in v3, so that anchor is only used in the classic UI.
const isV3 = !!(window.slopsmith && window.slopsmith.uiVersion === 'v3');
let slot = null;
if (isV3 && window.slopsmith.ui && typeof window.slopsmith.ui.playerControlSlot === 'function') {
try { const _s = window.slopsmith.ui.playerControlSlot(); if (_s instanceof Element) slot = _s; }
catch (_e) { /* host slot API failure → fall back to legacy container */ }
}
const controls = slot || document.getElementById('player-controls');
if (!controls || document.getElementById('btn-tuner-player')) return;
const btn = document.createElement('button');
btn.id = 'btn-tuner-player';
btn.textContent = 'Tuner';
btn.title = 'Open Tuner';
btn.onclick = window.tuner.toggle;
const closeBtn = isV3 ? null : controls.querySelector('button:last-child');
if (closeBtn) controls.insertBefore(btn, closeBtn);
else controls.appendChild(btn);
updatePlayerButton();
}
return {
initUI,
positionPanel,
renderInstrumentOptions,
renderTuningOptions,
renderStringNotes,
updateUI,
updateInstrumentDisplay: _updateInstrumentDisplay,
updateSaveAsCustomVisibility: _updateSaveAsCustomVisibility,
updateFreeTuneUI,
updateFloatingButton,
updatePlayerButton,
updateFloatingButtonVisibility,
showMicError,
addButton,
injectPlayerButton,
};
};
@@ -0,0 +1,388 @@
/**
* Analogue gauge tuner visualization for the Slopsmith tuner plugin.
*
* Contract: window['_tunerViz_analogue-gauge'](container) → { update(note, cents, freq), destroy() }
* - note: string | null (null = no signal)
* - cents: number (deviation from target, 50…+50)
* - freq: number (detected frequency in Hz)
*
* Layout (vintage analogue instrument panel):
* - Off-white panel face
* - Full-width black gauge section; frequency drum window centred inside it
* - Red SVG needle sweeps over the freq drum window
* - Note name drum + lightbulb below the gauge
*/
(function () {
'use strict';
// ── Constants ─────────────────────────────────────────────────────
var _TUNER_LABEL_H = 12; // px height of each drum label
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
var _TUNER_IN_TUNE_THRESHOLD = 2;
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
var _TUNER_NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
var _TUNER_NOTE_FLAT = ['C','Db','D','Eb', 'E','F','Gb','G','Ab','A','Bb', 'B'];
// SVG gauge geometry — viewBox 200 × 110, pivot at bottom-centre
// R=95 keeps arc endpoints ~5 SVG units from the viewBox edges to prevent clipping
var _SVG_CX = 100, _SVG_CY = 110, _SVG_R = 95, _SVG_NEEDLE_LEN = 88;
window['_tunerViz_analogue-gauge'] = function (container) {
'use strict';
var svgNS = 'http://www.w3.org/2000/svg';
function _midiToFreq(m) { return Math.pow(2, (m - 69) / 12) * 440; }
// ── Panel (off-white, vintage) ────────────────────────────────
var panel = document.createElement('div');
panel.className = 'w-full relative flex flex-col items-center gap-2 p-3 rounded-lg';
panel.style.backgroundColor = '#e8e0cc';
panel.style.border = '2px solid #b0a080';
// ── AUTO lamp (top-left; lit in free-tune mode) ──────────────
var autoWrap = document.createElement('div');
autoWrap.style.position = 'absolute';
autoWrap.style.top = '8px';
autoWrap.style.left = '10px';
autoWrap.style.zIndex = '20';
autoWrap.style.display = 'flex';
autoWrap.style.alignItems = 'center';
autoWrap.style.gap = '4px';
var autoLamp = document.createElement('div');
autoLamp.style.width = '8px';
autoLamp.style.height = '8px';
autoLamp.style.backgroundColor = '#2a0000';
autoLamp.style.border = '1px solid #5a2020';
autoLamp.style.flexShrink = '0';
var autoLabel = document.createElement('span');
autoLabel.style.fontSize = '9px';
autoLabel.style.fontFamily = 'monospace';
autoLabel.style.fontWeight = 'bold';
autoLabel.style.color = '#888';
autoLabel.textContent = 'AUTO';
autoWrap.appendChild(autoLamp);
autoWrap.appendChild(autoLabel);
panel.appendChild(autoWrap);
// ── A=440 label (top-right) ───────────────────────────────────
var refLabel = document.createElement('span');
refLabel.style.position = 'absolute';
refLabel.style.top = '8px';
refLabel.style.right = '10px';
refLabel.style.zIndex = '20';
refLabel.style.fontSize = '9px';
refLabel.style.fontFamily = 'monospace';
refLabel.style.fontWeight = 'bold';
refLabel.style.color = '#888';
refLabel.textContent = 'A=440';
panel.appendChild(refLabel);
// ── Gauge section (full-width, black face) ────────────────────
var gaugeFace = document.createElement('div');
gaugeFace.className = 'w-full relative';
gaugeFace.style.backgroundColor = '#e8e0cc';
gaugeFace.style.height = '95px'; // matches cropped viewBox height (110-15)
// Frequency drum window — centred inside the gauge, behind the needle
var freqWindow = document.createElement('div');
freqWindow.style.position = 'absolute';
freqWindow.style.overflow = 'hidden';
freqWindow.style.backgroundColor = '#fff';
freqWindow.style.border = '1px solid #bbb';
freqWindow.style.width = '104px';
freqWindow.style.height = (_TUNER_LABEL_H * 2) + 'px';
freqWindow.style.left = 'calc(50% - 52px)';
freqWindow.style.top = '39px'; // half needle from pivot: 95 - 88/2 - 24/2 = 39
freqWindow.style.zIndex = '1';
// Inset shadows top & bottom → suggests a curved drum surface receding at the edges.
// Top is shorter/lighter, bottom taller/darker — reads as a drum lit from above.
freqWindow.style.boxShadow = 'inset 0 4px 5px -4px rgba(0,0,0,0.35), inset 0 -7px 7px -4px rgba(0,0,0,0.55)';
var freqStrip = document.createElement('div');
freqStrip.style.position = 'absolute';
freqStrip.style.width = '100%';
// "---" is index 0; actual notes start at index 1
function _makeDrumLabel(text) {
var el = document.createElement('div');
el.style.height = _TUNER_LABEL_H + 'px';
el.style.display = 'flex';
el.style.alignItems = 'center';
el.style.justifyContent = 'center';
el.style.userSelect = 'none';
el.textContent = text;
return el;
}
var fIdleLabel = _makeDrumLabel('---');
fIdleLabel.style.fontSize = '11px';
fIdleLabel.style.fontFamily = 'monospace';
fIdleLabel.style.fontWeight = 'bold';
fIdleLabel.style.color = '#111';
freqStrip.appendChild(fIdleLabel);
freqStrip.appendChild(_makeDrumLabel('')); // separator: keeps real labels out of view at idle
for (var fm = _TUNER_STRIP_START_MIDI; fm <= _TUNER_STRIP_END_MIDI; fm++) {
var fLabel = _makeDrumLabel(_midiToFreq(fm).toFixed(1) + ' Hz');
fLabel.style.fontSize = '11px';
fLabel.style.fontFamily = 'monospace';
fLabel.style.fontWeight = 'bold';
fLabel.style.color = '#111';
freqStrip.appendChild(fLabel);
}
freqWindow.appendChild(freqStrip);
gaugeFace.appendChild(freqWindow);
// SVG — arc, tick marks, needle, pivot (z above freq window)
var svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 15 200 95'); // crop 15px dead space above arc top
svg.setAttribute('preserveAspectRatio', 'none');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.zIndex = '2';
svg.style.overflow = 'visible'; // prevent viewBox from clipping arc edges
// Arc: R=95 keeps endpoints ~5 SVG units from the viewBox edges
var arcPath = document.createElementNS(svgNS, 'path');
arcPath.setAttribute('d', 'M 5 110 A 95 95 0 0 1 195 110');
arcPath.setAttribute('fill', 'none');
arcPath.setAttribute('stroke', '#222');
arcPath.setAttribute('stroke-width', '1.5');
svg.appendChild(arcPath);
// Tick marks: long every 10 cents, 4 short between each (every 2 cents).
// 5 outermost marks on each side (|c| >= 42) in red.
for (var tc = -50; tc <= 50; tc += 2) {
var isLong = (tc % 10 === 0);
var isRed = Math.abs(tc) >= 42;
var tLen = isLong ? 10 : 5;
var tColor = isRed ? '#cc2200' : '#222';
var tWidth = isLong ? 1.5 : 1;
var tAngleRad = ((tc / 50) * _TUNER_NEEDLE_HALF_SWEEP - 90) * Math.PI / 180;
var ttick = document.createElementNS(svgNS, 'line');
ttick.setAttribute('x1', (_SVG_CX + (_SVG_R - tLen) * Math.cos(tAngleRad)).toFixed(1));
ttick.setAttribute('y1', (_SVG_CY + (_SVG_R - tLen) * Math.sin(tAngleRad)).toFixed(1));
ttick.setAttribute('x2', (_SVG_CX + _SVG_R * Math.cos(tAngleRad)).toFixed(1));
ttick.setAttribute('y2', (_SVG_CY + _SVG_R * Math.sin(tAngleRad)).toFixed(1));
ttick.setAttribute('stroke', tColor);
ttick.setAttribute('stroke-width', String(tWidth));
svg.appendChild(ttick);
}
// Inner labels — dominant-baseline="central" so y = vertical centre of text
[
{ c: -50, text: '-50', extreme: true },
{ c: -30, text: '-30', yOff: -1 },
{ c: 0, text: '0', yOff: -1 },
{ c: 30, text: '+30', yOff: -1 },
{ c: 50, text: '+50', extreme: true }
].forEach(function (m) {
var aRad = ((m.c / 50) * _TUNER_NEEDLE_HALF_SWEEP - 90) * Math.PI / 180;
var lx = (_SVG_CX + 76 * Math.cos(aRad)).toFixed(1);
// extreme labels: centre at arc baseline (y=_SVG_CY)
// others: on arc circle at r=76 plus per-label vertical nudge
var ly = m.extreme
? String(_SVG_CY)
: (_SVG_CY + 76 * Math.sin(aRad) + (m.yOff || 0)).toFixed(1);
var lbl = document.createElementNS(svgNS, 'text');
lbl.setAttribute('x', lx);
lbl.setAttribute('y', ly);
lbl.setAttribute('text-anchor', 'middle');
lbl.setAttribute('dominant-baseline', 'central');
lbl.setAttribute('font-size', '8');
lbl.setAttribute('font-family', 'monospace');
lbl.setAttribute('fill', m.extreme ? '#cc2200' : '#555');
lbl.textContent = m.text;
svg.appendChild(lbl);
});
// Needle line (pivot at SVG bottom-centre; x2/y2 updated in RAF)
var needleLine = document.createElementNS(svgNS, 'line');
needleLine.setAttribute('x1', '100');
needleLine.setAttribute('y1', '110');
needleLine.setAttribute('x2', '100');
needleLine.setAttribute('y2', String(110 - _SVG_NEEDLE_LEN)); // initial: 0 cents
needleLine.setAttribute('stroke', '#cc2200');
needleLine.setAttribute('stroke-width', '2');
needleLine.setAttribute('stroke-linecap', 'round');
svg.appendChild(needleLine);
// Pivot cap
var pivotCap = document.createElementNS(svgNS, 'circle');
pivotCap.setAttribute('cx', '100');
pivotCap.setAttribute('cy', '110');
pivotCap.setAttribute('r', '5');
pivotCap.setAttribute('fill', '#cc2200');
svg.appendChild(pivotCap);
gaugeFace.appendChild(svg);
panel.appendChild(gaugeFace);
// ── Note drum + lightbulb row (below gauge) ───────────────────
var noteRow = document.createElement('div');
noteRow.className = 'w-full relative flex justify-center items-center';
var noteWindow = document.createElement('div');
noteWindow.style.position = 'relative';
noteWindow.style.overflow = 'hidden';
noteWindow.style.backgroundColor = '#fff';
noteWindow.style.border = '1px solid #999';
noteWindow.style.width = '48px';
noteWindow.style.height = (_TUNER_LABEL_H * 2) + 'px';
// Inset shadows top & bottom → suggests a curved drum surface receding at the edges.
// Top is shorter/lighter, bottom taller/darker — reads as a drum lit from above.
noteWindow.style.boxShadow = 'inset 0 4px 5px -4px rgba(0,0,0,0.35), inset 0 -7px 7px -4px rgba(0,0,0,0.55)';
var noteStrip = document.createElement('div');
noteStrip.style.position = 'absolute';
noteStrip.style.width = '100%';
var nIdleLabel = _makeDrumLabel('---');
nIdleLabel.style.fontSize = '10px';
nIdleLabel.style.fontWeight = 'bold';
nIdleLabel.style.color = '#111';
noteStrip.appendChild(nIdleLabel);
noteStrip.appendChild(_makeDrumLabel('')); // separator
var _drumLabels = []; // {el, nm} — for flat/sharp relabeling
for (var nm = _TUNER_STRIP_START_MIDI; nm <= _TUNER_STRIP_END_MIDI; nm++) {
var nLabel = _makeDrumLabel(_TUNER_NOTE_NAMES[nm % 12]);
nLabel.style.fontSize = '10px';
nLabel.style.fontWeight = 'bold';
nLabel.style.color = '#111';
noteStrip.appendChild(nLabel);
_drumLabels.push({ el: nLabel, nm: nm });
}
noteWindow.appendChild(noteStrip);
// Lightbulb — absolutely offset from panel centre so note window stays centred
// noteWindow is 48px wide → bulb left edge = 50% + 24px (half window) + 6px gap
var bulbEl = document.createElement('div');
bulbEl.style.position = 'absolute';
bulbEl.style.left = 'calc(50% + 30px)';
bulbEl.style.top = '50%';
bulbEl.style.transform = 'translateY(-50%)';
bulbEl.style.width = '20px';
bulbEl.style.height = '20px';
bulbEl.style.borderRadius = '50%';
bulbEl.style.backgroundColor = '#2a1010';
bulbEl.style.border = '2px solid #4a2020';
noteRow.appendChild(noteWindow);
noteRow.appendChild(bulbEl);
panel.appendChild(noteRow);
container.appendChild(panel);
// ── State ─────────────────────────────────────────────────────
// Must be defined before currentDrumY initialisation (var hoisting trap)
var _IDLE_DRUM_Y = _TUNER_LABEL_H * 0.5; // centres --- label (index 0) in window
var currentDrumY = _IDLE_DRUM_Y, targetDrumY = _IDLE_DRUM_Y;
var _lastUseFlats = false;
var currentAngle = 0, targetAngle = 0;
var lastTime = performance.now();
var rafId = null;
// ── Needle SVG update ─────────────────────────────────────────
function _setNeedle(angleDeg) {
var rad = (angleDeg - 90) * Math.PI / 180;
needleLine.setAttribute('x2', (_SVG_CX + _SVG_NEEDLE_LEN * Math.cos(rad)).toFixed(1));
needleLine.setAttribute('y2', (_SVG_CY + _SVG_NEEDLE_LEN * Math.sin(rad)).toFixed(1));
}
// ── Drum position ─────────────────────────────────────────────
function _computeDrumY(freq, cents) {
if (!freq || freq <= 0) return _IDLE_DRUM_Y;
var midi = 69 + 12 * Math.log2(freq / 440);
var targetMidi = midi - cents / 100;
var clamped = Math.max(-50, Math.min(50, cents));
// +2: index 0 = ---, index 1 = separator, index 2+ = real notes
var idx = Math.max(2, Math.min(_TUNER_STRIP_END_MIDI - _TUNER_STRIP_START_MIDI + 2, Math.round(targetMidi) - _TUNER_STRIP_START_MIDI + 2));
return _TUNER_LABEL_H * (0.5 - idx) - (clamped / 50) * (_TUNER_LABEL_H / 2);
}
// ── Animation loop ────────────────────────────────────────────
function _animate() {
var now = performance.now();
var dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
var lf = 1 - Math.exp(-10 * dt);
currentDrumY += (targetDrumY - currentDrumY) * lf;
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
currentAngle += (targetAngle - currentAngle) * lf;
_setNeedle(currentAngle);
rafId = requestAnimationFrame(_animate);
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────
function _setAutoLamp(lit) {
autoLamp.style.backgroundColor = lit ? '#cc2200' : '#2a0000';
autoLamp.style.border = lit ? '1px solid #ff4422' : '1px solid #5a2020';
autoLamp.style.boxShadow = lit ? '0 0 5px 2px rgba(200,50,0,0.7)' : 'none';
}
function update(note, cents, freq, mode, targetFreq, referencePitch, useFlats) {
if (typeof referencePitch === 'number' && referencePitch > 0) {
refLabel.textContent = 'A=' + Math.round(referencePitch);
}
var wantFlats = !!useFlats;
if (wantFlats !== _lastUseFlats) {
_lastUseFlats = wantFlats;
var names = wantFlats ? _TUNER_NOTE_FLAT : _TUNER_NOTE_NAMES;
for (var _di = 0; _di < _drumLabels.length; _di++) {
_drumLabels[_di].el.textContent = names[_drumLabels[_di].nm % 12];
}
}
// AUTO lamp: free → always lit; auto → lit on signal; manual/unknown → off
if (mode === 'free') {
_setAutoLamp(true);
} else if (mode === 'auto') {
_setAutoLamp(note !== null);
} else {
_setAutoLamp(false);
}
if (note === null) {
targetDrumY = _IDLE_DRUM_Y;
targetAngle = 0;
bulbEl.style.backgroundColor = '#2a1010';
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
return;
}
targetDrumY = _computeDrumY(freq, cents);
targetAngle = (Math.max(-50, Math.min(50, cents)) / 50) * _TUNER_NEEDLE_HALF_SWEEP;
if (Math.abs(cents) <= _TUNER_IN_TUNE_THRESHOLD) {
bulbEl.style.backgroundColor = '#cc3300';
bulbEl.style.border = '2px solid #ff5522';
bulbEl.style.boxShadow = '0 0 10px 4px rgba(200,50,0,0.85)';
} else {
bulbEl.style.backgroundColor = '#2a1010';
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
}
}
function destroy() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
panel.remove();
}
return { update: update, destroy: destroy };
};
})();
@@ -0,0 +1,302 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.0"
viewBox="0 0 1024 1024"
id="svg128"
width="1024"
height="1024"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs128" />
<g
id="g132"
style="display:inline"
>
<path
style="display:inline;fill:#7cb8b7;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 0,877.33008 107.64,775.73995 1024,775.85547 V 817.12 L 107.64,816.90184 0,941.53906 Z"
id="path132"
/>
<path
style="display:inline;fill:#8acfca;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 0,877.33008 107.64,775.73995 1024,775.85547 V 0 H 0 Z"
id="path133"
/>
<path
style="fill:#dbdbdb;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 0,941.53906 107.64,816.90184 1024,817.12 V 1024 H 0 Z"
id="path134"
/>
<path
style="fill:none;stroke:#407473;stroke-width:5.9;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 0,877.33008 107.64,775.73995 1024,775.85547"
id="path129"
/>
<path
style="fill:none;stroke:#407473;stroke-width:5.9;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 0,941.53906 107.64,816.90184 1024,817.12"
id="path130"
/>
<path
style="fill:none;stroke:#407473;stroke-width:5.9;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 107.64,816.90184 107.20507,0"
id="path131"
/>
</g>
<path
fill="#303332"
d="m 880.44,100.72 12.55,1.47 a 0.16,0.16 0 0 1 0,0.31 l -11.7,1.2 -130.6,-0.05 a 0.55,0.54 0 0 0 -0.55,0.54 v 54.92 a 0.52,0.52 0 0 0 0.52,0.52 h 197.01 a 0.53,0.54 0 0 0 0.53,-0.54 l 0.03,-45.02 c 1.72,-1.01 2.57,1.12 2.56,2.41 q -0.1,17.63 0.16,34.73 l -0.03,213.46 a 1.51,1.51 0 0 1 -1.51,1.51 l -194.95,0.01 -6.01,-0.06 a 1.09,1.09 0 0 1 -1.08,-1.09 V 102.03 a 1.27,1.27 0 0 1 1.27,-1.27 h 79.46 a 0.51,0.5 86.9 0 0 0.5,-0.56 c -1.27,-12.04 8.01,-24.14 20.26,-24.1 13.18,0.04 21.86,11.09 21.07,24.08 a 0.59,0.59 0 0 0 0.59,0.63 z"
id="path56" />
<path
fill="#cad5d9"
d="m 849.27,79 q 3.65,0 6.27,1.16 12.72,5.61 11.62,19.95 a 0.72,0.72 0 0 1 -0.67,0.66 l -2.63,0.15 a 0.62,0.63 1.9 0 1 -0.65,-0.7 q 1.19,-9.27 -6.39,-14.39 a 2.36,2.28 6.1 0 1 -0.83,-0.99 q -2.14,-4.88 -6.72,-4.88 -4.58,0 -6.71,4.89 a 2.28,2.36 83.8 0 1 -0.82,1 q -7.57,5.13 -6.36,14.4 a 0.63,0.62 87.9 0 1 -0.65,0.7 l -2.63,-0.15 a 0.72,0.72 0 0 1 -0.67,-0.65 q -1.13,-14.34 11.58,-19.98 2.61,-1.16 6.26,-1.17 z"
id="path57" />
<circle
fill="#cad5d9"
cx="849.33002"
cy="86.360001"
r="3.6600001"
id="circle57" />
<path
fill="#8acfca"
d="m 849.32,92.65 c 2.98,0 4.99,-1.25 6.08,-4.01 a 0.35,0.36 29.3 0 1 0.54,-0.16 q 5.47,4.14 4.59,11.41 a 1.08,1.07 2.4 0 1 -1.03,0.94 q -0.66,0.03 -10.17,0.04 -9.5,0.01 -10.17,-0.01 a 1.07,1.08 87.4 0 1 -1.03,-0.94 q -0.9,-7.27 4.56,-11.42 a 0.36,0.35 60.5 0 1 0.55,0.15 c 1.09,2.76 3.11,4.01 6.08,4 z"
id="path58" />
<path
fill="#2c4e56"
d="m 951.09,108.53 -0.14,42.68 q -0.26,-17.1 -0.16,-34.73 c 0.01,-1.29 -0.84,-3.42 -2.56,-2.41 l 0.02,-9.66 a 0.71,0.72 0 0 0 -0.71,-0.72 l -66.25,0.01 11.7,-1.2 a 0.16,0.16 0 0 0 0,-0.31 l -12.55,-1.47 69.01,0.05 a 1.62,1.62 0 0 1 1.62,1.61 z"
id="path59" />
<path
fill="#b0d5e7"
d="m 881.29,103.7 66.25,-0.01 a 0.71,0.72 0 0 1 0.71,0.72 l -0.02,9.66 -0.03,45.02 a 0.53,0.54 0 0 1 -0.53,0.54 H 750.66 a 0.52,0.52 0 0 1 -0.52,-0.52 v -54.92 a 0.55,0.54 0 0 1 0.55,-0.54 z"
id="path60" />
<path
fill="#7cb8b7"
d="m 754.46,366.19 194.95,-0.01 a 1.51,1.51 0 0 0 1.51,-1.51 l 0.03,-213.46 0.14,-42.68 4.88,0.07 a 1.25,1.25 0 0 1 1.23,1.25 v 261.69 a 1.08,1.08 0 0 1 -1.07,1.08 c -25.42,0.16 -47.71,-0.41 -71.04,-0.13 -22.61,0.26 -53.51,-0.33 -75.5,-0.15 -26.8,0.22 -41.06,0.95 -52.82,-0.59 a 1.35,1.34 86.4 0 1 -1.12,-0.99 z"
id="path61" />
<path
fill="#e8f7fc"
d="m 948.3,362.83 a 0.58,0.58 0 0 1 -0.58,0.58 H 750.74 a 0.58,0.58 0 0 1 -0.58,-0.58 V 162.89 a 0.58,0.58 0 0 1 0.58,-0.58 h 196.98 a 0.58,0.58 0 0 1 0.58,0.58 z"
id="path62" />
<path
fill="#303332"
d="m 622.02,774.15 -2.38,3.3 q -11.53,15.67 -26.1,26.61 -2.9,2.18 -8.95,5.9 -4.48,2.75 -9.05,5.74 -0.1,1.96 0.32,2.78 6.41,40.45 13.06,80.7 c 1,6.03 1.12,10.17 -2.83,14.46 -4.92,5.35 -13.13,8.68 -19.89,10.83 -23.4,7.43 -50.48,9.44 -75.17,6.74 q -20.39,-2.23 -35.43,-7.53 c -6.84,-2.41 -13.32,-5.22 -17.94,-10.58 q -3.76,-4.34 -2.81,-10.39 6.59,-42.14 13.46,-84.39 0.7,-0.48 0.32,-2.34 -3.89,-2.78 -7.82,-5.13 c -14.33,-8.62 -26.7,-20.54 -36.72,-33.81 q -0.81,-1.73 -1.53,-2.37 c -8.31,-12.93 -14.1,-24.79 -18.18,-38.76 q -0.73,-2.51 -1.83,-5.92 a 3.06,3.07 11.7 0 0 -0.9,-1.37 c -8.37,-7.39 -10.1,-15.14 -9.98,-26.63 q 0.14,-13.39 0.21,-25.87 c 0.1,-19.59 24.1,-31.14 38.65,-38.38 a 0.67,0.68 4 0 0 0.26,-0.23 l 12.23,-17.49 a 0.38,0.39 17.8 0 0 -0.31,-0.61 q -6.87,0 -20.35,0.16 c -8.7,0.1 -16.14,-1.08 -21.66,-6.72 q -5.16,-5.25 -5.65,-12.86 -1.5,-23.19 -9.77,-145 a 0.86,0.87 1.6 0 0 -0.75,-0.8 c -3.53,-0.47 -7.9,-0.45 -11.98,-1.4 -3.82,-0.89 -6.26,-4.35 -5.62,-8.09 0.76,-4.37 3.71,-6.03 8.13,-6.34 q 3.12,-0.23 8.53,-0.45 a 0.52,0.52 0 0 0 0.5,-0.55 l -1.07,-18.74 a 0.73,0.74 4.9 0 0 -0.55,-0.66 q -0.11,-0.03 -4.44,-0.04 -5.05,-0.02 -6.35,-4.47 a 3.22,3.3 35.4 0 1 -0.13,-0.84 q -0.04,-3.89 0.26,-8.86 0.98,-15.76 16.95,-15.97 38.17,-0.49 65.62,-1.06 a 1.07,1.11 20.9 0 0 0.79,-0.37 c 25.71,-29.81 63.98,-45.4 102.12,-34.09 21.22,6.29 37.54,17.95 51.73,34.08 a 1.14,1.12 69.4 0 0 0.82,0.38 q 40.39,0.89 62.15,1 8.65,0.04 11.79,1.66 c 8.82,4.53 8.93,13.98 8.63,22.86 -0.22,6.47 -6.29,5.63 -10.73,5.61 a 0.77,0.77 0 0 0 -0.77,0.8 q 0.12,2.84 -0.17,7.16 -2.02,30.7 -11.51,172.46 -1,14.96 -13.98,19.75 -4.42,1.62 -12.64,1.46 -10.85,-0.22 -21.04,0 A 0.34,0.34 0 0 0 601.3,620 l 11.37,16.77 a 4.33,4.42 85 0 0 1.92,1.58 q 12.17,5.15 21.57,12.41 c 9.33,7.2 16.08,14.46 16.1,26.23 q 0.02,12.16 0.25,23.25 c 0.23,10.62 -1.03,20.82 -9.58,27.75 a 5.19,5.16 77.4 0 0 -1.79,2.84 q -5.27,22.2 -19.12,43.32 z"
id="path83" />
<path
fill="#cad5d9"
d="m 512.21,351.41 c 28.39,0.02 54.98,14.39 73.45,35.52 q 27.41,31.37 37,72.79 c 11.84,51.2 4.91,106.12 -26.26,149.15 -3.89,5.38 -8.94,9.62 -15.67,9.54 q -2.62,-0.03 -7.39,0.12 a 0.86,0.87 80.2 0 1 -0.85,-0.59 q -1.77,-5.55 -9.24,-5.54 -25.9,0.05 -51.23,0.03 -25.33,-0.02 -51.24,-0.1 -7.47,-0.02 -9.25,5.52 a 0.87,0.86 9.9 0 1 -0.85,0.59 q -4.77,-0.16 -7.39,-0.13 c -6.73,0.07 -11.77,-4.18 -15.65,-9.56 -31.11,-43.08 -37.96,-98.01 -26.05,-149.19 q 9.65,-41.41 37.11,-72.73 c 18.5,-21.11 45.11,-35.44 73.51,-35.42 z"
id="path84" />
<path
fill="#e8f7fc"
d="m 592.82,389.8 q 5.7,-0.2 7.72,-0.15 34.39,0.86 51.54,1.03 8.42,0.07 11.14,1.62 c 6.73,3.82 6.36,11.76 6.35,19.22 q -0.01,3.56 -2.77,3.56 -12.82,0.07 -57.52,-0.09 a 0.62,0.65 75.9 0 1 -0.56,-0.32 q -6.92,-12.3 -16.18,-24.29 a 0.36,0.36 0 0 1 0.28,-0.58 z"
id="path85" />
<path
fill="#e8f7fc"
d="m 431.78,390.35 q -9.3,12.03 -16.25,24.36 a 0.65,0.63 14.1 0 1 -0.56,0.32 q -44.85,0.13 -57.71,0.06 -2.77,-0.01 -2.78,-3.58 c 0,-7.48 -0.37,-15.45 6.39,-19.28 q 2.73,-1.55 11.17,-1.62 17.21,-0.15 51.72,-0.99 2.02,-0.05 7.74,0.15 a 0.36,0.36 0 0 1 0.28,0.58 z"
id="path86" />
<path
fill="#e8f7fc"
d="m 405.22,437.95 a 0.46,0.46 0 0 1 -0.82,0.11 q -3.26,-5.17 -8.43,-6.74 c -6.7,-2.03 -11.58,0.49 -16.09,5.13 a 1.86,1.88 20.9 0 1 -1.23,0.56 l -11.01,0.63 A 0.61,0.61 0 0 1 367,437.07 l -1.07,-18.67 a 0.51,0.51 0 0 1 0.51,-0.54 h 46.99 a 0.3,0.3 0 0 1 0.26,0.45 q -5.25,9.24 -8.47,19.64 z"
id="path87" />
<path
fill="#e8f7fc"
d="m 593.59,616.55 a 0.29,0.28 27 0 1 -0.17,-0.51 q 1.68,-1.23 3.16,-2.94 4.6,-5.32 9.71,-13.8 c 18.31,-30.42 25.79,-65.67 24.42,-101.05 q -1.65,-42.75 -19.94,-79.91 a 0.35,0.36 76.7 0 1 0.32,-0.51 h 46.89 a 0.36,0.35 2.2 0 1 0.36,0.38 q -4.03,61.42 -11.7,174.44 -0.75,11.15 -2.42,14.19 -2.98,5.43 -9.07,8.21 -3.28,1.5 -12.11,1.53 -18.31,0.08 -29.45,-0.03 z"
id="path88" />
<path
fill="#cad5d9"
d="m 402.86,441.04 a 2.95,2.96 39.2 0 1 0.19,1.93 q -1.02,4.32 -2.85,10.47 -0.6,2.01 -2.84,3 -8.96,3.96 -15.35,-3.49 a 2.9,2.89 70.2 0 0 -2.16,-1.02 q -13.91,-0.29 -25.05,-1.56 c -6.22,-0.7 -7.05,-8.44 -0.22,-9.07 q 10.77,-1 25.21,-1.29 a 2.8,2.8 0 0 0 2.29,-1.27 c 5.37,-8.18 16.91,-6.04 20.78,2.3 z"
id="path89" />
<path
fill="#e8f7fc"
d="m 398.43,459.9 c -11.09,48.51 -5.11,101.36 22.19,143.75 q 5.4,8.38 10.17,12.52 a 0.24,0.24 0 0 1 -0.16,0.42 q -28.55,0.12 -33.83,-0.09 -12.1,-0.5 -16.98,-9.94 -1.73,-3.35 -2.5,-14.84 -8.61,-128.96 -9.12,-137.11 a 0.39,0.39 0 0 1 0.41,-0.42 l 10.16,0.59 a 1.8,1.75 67.7 0 1 1.1,0.48 c 0.82,0.79 2.1,2.14 3.16,2.85 q 6.85,4.58 14.9,1.37 a 0.37,0.37 0 0 1 0.5,0.42 z"
id="path90" />
<rect
fill="#4987a9"
x="119.94"
y="566.21002"
width="240.60001"
height="2.78"
rx="1.37"
id="rect90" />
<rect
fill="#4987a9"
x="666.07001"
y="566.23999"
width="345.48001"
height="2.74"
rx="1.34"
id="rect91" />
<rect
fill="#4987a9"
x="242.7493"
y="573.5766"
transform="rotate(-0.1)"
width="2.3199999"
height="45.82"
rx="1.14"
id="rect92" />
<rect
fill="#4987a9"
x="770.96826"
y="574.59918"
transform="rotate(-0.1)"
width="3.04"
height="45.860001"
rx="1.5"
id="rect93" />
<rect
fill="#4987a9"
x="904.82001"
y="573.03998"
width="2.4200001"
height="46.16"
rx="1.1900001"
id="rect94" />
<path
fill="#cad5d9"
d="m 467.38,628.28 q -0.03,0.03 -0.07,0.06 a 0.04,0.22 44.2 0 1 -0.14,0.1 l -12.2,-0.05 a 0.7,0.7 0 0 1 -0.7,-0.7 c -0.03,-3.17 -0.59,-9.32 1.44,-11.31 2.03,-1.99 8.16,-1.31 11.34,-1.22 a 0.7,0.7 0 0 1 0.68,0.72 l -0.19,12.2 a 0.04,0.22 46.9 0 1 -0.1,0.14 q -0.03,0.03 -0.06,0.06 z"
id="path94" />
<rect
fill="#cad5d9"
x="470.64999"
y="615.21002"
width="82.580002"
height="13.16"
rx="0.75999999"
id="rect95" />
<path
fill="#cad5d9"
d="m 568.98,628.3 h -12.09 a 0.59,0.58 0 0 1 -0.59,-0.58 V 615.7 a 0.36,0.37 89.2 0 1 0.36,-0.36 q 4.87,-0.06 8.65,0.05 c 3.24,0.09 4.64,2.61 4.56,5.99 q -0.11,4.66 -0.12,6.16 a 0.77,0.77 0 0 1 -0.77,0.76 z"
id="path95" />
<path
fill="#b0d5e7"
d="m 416.02,635.53 a 0.14,0.14 0 0 1 -0.16,-0.21 l 10.46,-15.29 a 0.96,0.96 0 0 1 0.66,-0.42 q 5.8,-0.75 8.06,-0.04 7.7,2.43 15.38,1.74 a 0.76,0.76 0 0 1 0.84,0.76 l -0.03,5.63 a 0.84,0.84 0 0 1 -0.88,0.83 q -9.94,-0.53 -13.56,0.32 -8.89,2.1 -20.77,6.68 z"
id="path96" />
<path
fill="#b0d5e7"
d="m 608.32,635.71 q -11.93,-4.66 -20.87,-6.81 -3.63,-0.87 -13.64,-0.38 a 0.84,0.84 0 0 1 -0.88,-0.84 v -5.66 a 0.77,0.76 2.7 0 1 0.84,-0.76 q 7.73,0.73 15.49,-1.69 2.27,-0.7 8.1,0.08 a 0.96,0.97 77.2 0 1 0.67,0.42 l 10.45,15.43 a 0.14,0.14 0 0 1 -0.16,0.21 z"
id="path97" />
<rect
fill="#4987a9"
x="162.21001"
y="623.14001"
width="205.72"
height="2.9000001"
rx="1.4299999"
id="rect97" />
<rect
fill="#4987a9"
x="680.59003"
y="623.15997"
width="292.20001"
height="2.9200001"
rx="1.4400001"
id="rect98" />
<rect
fill="#4987a9"
x="772.07001"
y="629.89001"
width="2.96"
height="60.18"
rx="1.46"
id="rect99" />
<rect
fill="#4987a9"
x="903.69696"
y="631.39026"
transform="rotate(-0.1)"
width="2.3199999"
height="60.060001"
rx="1.14"
id="rect100" />
<rect
fill="#4987a9"
x="245.01134"
y="629.40143"
transform="rotate(0.1)"
width="2.26"
height="60.099998"
rx="1.11"
id="rect101" />
<path
fill="#dbdbdb"
d="m 512.09,631.19 q 33.75,0 68.91,0.11 c 5.66,0.02 8.48,0.93 14.26,2.74 17.22,5.39 62.33,23.34 52.76,48.95 -3.26,8.71 -13.54,16.51 -21.82,20.95 -19.43,10.4 -41.29,16.23 -63.43,19.59 q -24.09,3.65 -50.69,3.65 -26.6,-0.01 -50.69,-3.66 c -22.14,-3.37 -44,-9.2 -63.43,-19.61 -8.28,-4.44 -18.56,-12.24 -21.81,-20.95 -9.57,-25.61 35.55,-43.55 52.77,-48.94 5.78,-1.81 8.6,-2.72 14.26,-2.74 q 35.16,-0.1 68.91,-0.09 z"
id="path101" />
<path
fill="#303332"
d="m 412.72,673.72 a 99.35,31.12 0 0 1 99.35,-31.12 99.35,31.12 0 0 1 99.35,31.12 99.35,31.12 0 0 1 -99.35,31.12 99.35,31.12 0 0 1 -99.35,-31.12 z"
id="path102" />
<path
fill="#b0d5e7"
d="m 512.07,645.44 q 27.67,0 53.32,4.94 c 9.84,1.89 43.24,10.11 43.24,23.37 -0.01,13.27 -33.41,21.47 -43.24,23.36 q -25.66,4.93 -53.32,4.93 -27.67,-0.01 -53.32,-4.94 c -9.84,-1.9 -43.24,-10.11 -43.24,-23.38 0.01,-13.26 33.41,-21.47 43.25,-23.36 q 25.65,-4.93 53.31,-4.92 z"
id="path103" />
<path
fill="#e8f7fc"
d="m 512.06,758.97 c -36.86,0 -71.27,-4.36 -104.51,-18.45 Q 393,734.35 382.36,725.24 c -5.64,-4.83 -7.35,-11.15 -7.68,-19.29 q -0.41,-9.84 -0.13,-18.43 a 0.31,0.3 30.7 0 1 0.57,-0.14 c 7.23,12.16 22.54,20.74 35.51,25.76 31.91,12.35 66.39,16.74 101.44,16.75 35.04,0 69.53,-4.38 101.44,-16.72 12.97,-5.02 28.28,-13.6 35.52,-25.76 a 0.3,0.31 59.3 0 1 0.57,0.14 q 0.27,8.59 -0.14,18.43 c -0.33,8.14 -2.04,14.46 -7.68,19.29 q -10.64,9.11 -25.2,15.27 c -33.24,14.09 -67.65,18.44 -104.52,18.43 z"
id="path104" />
<rect
fill="#4987a9"
x="117.78"
y="694.95001"
width="238.72"
height="2.1600001"
rx="1.0700001"
id="rect104" />
<rect
fill="#4987a9"
x="666.19"
y="694.90997"
width="332.78"
height="2.26"
rx="1.11"
id="rect105" />
<rect
fill="#4987a9"
x="243.99001"
y="701.87"
width="1.9"
height="64.720001"
rx="0.93000001"
id="rect106" />
<rect
fill="#4987a9"
x="904.91998"
y="701.76001"
width="2.2"
height="65.419998"
rx="1.08"
id="rect107" />
<path
fill="#303332"
d="m 785.23,815.55 -0.39,3.26 c -1.63333,12.71333 -3.43333,26.27 -5.4,40.67 -0.56,4.16 -4.24,5.81 -8.06,7.14 -11.28,3.91 -22.66,5.01 -35.43,5.48 -15.36,0.56667 -29.91333,-0.79667 -43.66,-4.09 -5.01,-1.2 -12.08,-3.01 -12.84,-8.49 -1.93333,-13.78 -3.83333,-27.52667 -5.7,-41.24 l -0.38,-2.25 -5.39,-39.21 c -0.46,-3.57 0.0743,0.0726 -0.46,-3.57 -2.7,-19.28 -4.52,-32.20333 -5.46,-38.77 -0.6,-4.18 1.14,-5.69 4.91,-7.74 2.94667,-1.6 7.67333,-3.04 14.18,-4.32 6.21333,-1.22 12.25,-2.10667 18.11,-2.66 24.17333,-2.28667 47.66333,-1.86667 70.47,1.26 8.44,1.16 16.84,2.7 23.88,6.84 2.47333,1.44667 3.51333,3.55667 3.12,6.33 -0.3566,1.66195 -4.93784,37.60422 -11.5,81.36 z"
id="path107"
/>
<path
fill="#a6876e"
d="m 729.37,721.24 c 14.87,0.01 28.95,0.8 42.34,3.06 3.8,0.64 22.36,4.26 22.36,8.47 0,4.21 -18.57,7.8 -22.37,8.43 -13.39,2.24 -27.48,3 -42.34,2.99 -14.87,-0.01 -28.96,-0.79 -42.34,-3.05 -3.8,-0.64 -22.37,-4.26 -22.36,-8.47 0,-4.21 18.57,-7.8 22.37,-8.44 13.39,-2.24 27.47,-3 42.34,-2.99 z"
id="path108" />
<path
fill="#e8f7fc"
d="m 512.05,761.72 c 40.54,0.01 80.55,-5.15 116.53,-23.82 q 4.84,-2.51 8.71,-5.54 a 0.21,0.21 0 0 1 0.33,0.21 q -3.48,14.17 -10.39,26.9 -27,49.78 -81.58,64.66 -15.67,4.27 -33.62,4.27 -17.95,-0.01 -33.61,-4.29 -54.58,-14.91 -81.55,-64.71 -6.9,-12.73 -10.37,-26.91 a 0.21,0.21 0 0 1 0.33,-0.21 q 3.87,3.04 8.71,5.55 c 35.96,18.69 75.97,23.88 116.51,23.89 z"
id="path109" />
<path
fill="#d1ae92"
d="m 729.35,747.04 c 17.81,0 35.43,-1.25 51.93,-4.85 q 6.12,-1.34 11.44,-4.19 a 0.22,0.22 0 0 1 0.32,0.22 q -8.25,61.43 -16.49,119.8 -0.34,2.43 -1.29,3.45 -1.2,1.3 -6.51,3.02 c -12.09,3.91 -26.05,4.73 -39.36,4.73 -13.31,0 -27.28,-0.8 -39.37,-4.71 q -5.31,-1.71 -6.51,-3.01 -0.95,-1.02 -1.29,-3.45 -8.28,-58.37 -16.56,-119.79 a 0.22,0.22 0 0 1 0.32,-0.22 q 5.32,2.84 11.45,4.18 c 16.49,3.59 34.11,4.83 51.92,4.82 z"
id="path110" />
<path
fill="#e8f7fc"
d="m 512.06,831.28 c 20.96,0 41.44,-4.81 60.17,-13.97 a 0.39,0.39 0 0 1 0.56,0.28 q 9.13,56.81 13.3,82.32 0.82,5.02 0.47,6.64 c -2.41,11.21 -25.79,17.01 -35.32,18.95 q -19.93,4.04 -39.18,4.04 -19.25,0 -39.18,-4.05 c -9.53,-1.93 -32.91,-7.73 -35.32,-18.94 q -0.35,-1.62 0.47,-6.64 4.17,-25.51 13.31,-82.32 a 0.39,0.39 0 0 1 0.55,-0.29 c 18.73,9.16 39.21,13.98 60.17,13.98 z"
id="path124" />
<path
fill="#a0a7a5"
d="m 288.52,1024 h -0.75 q 0.08,-0.76 -0.39,-0.95 a 1.46,1.5 21.1 0 1 -0.82,-1.91 q 8.4,-22.47 18.01,-47.76 a 0.55,0.55 0 0 0 -0.51,-0.75 q -93.61,-1.16 -188.79,0.19 a 2.29,2.29 0 0 0 -1.81,0.94 L 80.4,1019.6 a 1.38,1.39 37.5 0 1 -1.99,0.27 l -0.34,-0.28 a 1.14,1.13 37.5 0 1 -0.21,-1.55 l 32.38,-44.9 a 0.4,0.4 0 0 0 -0.32,-0.63 H 52.97 a 0.92,0.93 88.7 0 1 -0.93,-0.88 q -0.05,-1.26 0.67,-1.41 1.82,-0.39 2.59,-0.38 33.31,0.27 56.92,-0.16 a 0.98,0.99 17.6 0 0 0.78,-0.41 l 58.12,-80.59 a 0.51,0.52 16.6 0 0 -0.44,-0.82 q -6.93,0.4 -9.43,0.39 -56.58,-0.28 -98.96,-0.32 a 1.37,1.37 0 0 1 -1.37,-1.45 l 0.01,-0.1 a 1.58,1.58 0 0 1 1.58,-1.47 c 33.36,0.07 64.66,0.31 94.44,-0.21 q 6.97,-0.12 14.93,0.48 a 2.78,2.77 20 0 0 2.47,-1.15 l 40.68,-56.44 a 1.33,1.33 0 0 1 1.9,-0.27 l 0.06,0.04 a 1.48,1.48 0 0 1 0.29,2.02 l -39.49,55.01 a 0.36,0.36 0 0 0 0.3,0.57 h 158.9 a 0.86,0.85 10.4 0 0 0.8,-0.55 l 21.86,-57.87 a 1.87,1.85 8.3 0 1 1.62,-1.2 h 0.05 a 1.23,1.23 0 0 1 1.1,1.66 l -21.22,57.4 a 0.46,0.46 0 0 0 0.43,0.61 h 78.19 a 1.29,1.28 2.8 0 1 1.28,1.41 l -0.06,0.55 a 1.16,1.15 2.7 0 1 -1.15,1.04 H 340.9 a 1.92,1.93 10.3 0 0 -1.8,1.25 l -29.82,79.88 a 0.73,0.72 10.3 0 0 0.68,0.98 h 200.42 a 0.66,0.65 0 0 0 0.66,-0.65 v -27.39 a 0.75,0.76 0 0 1 0.75,-0.76 h 0.46 a 0.87,0.87 0 0 1 0.87,0.87 v 26.93 a 1.03,1.03 0 0 0 1.03,1.03 h 183.36 a 0.63,0.62 79.6 0 0 0.58,-0.85 l -31.05,-80.92 a 0.94,0.95 78.6 0 0 -0.91,-0.6 c -3.21,0.09 -5.82,0.42 -8.56,0.39 q -28.86,-0.24 -57.4,-0.18 a 1.42,1.42 0 0 1 -1.43,-1.5 l 0.01,-0.1 a 1.55,1.54 1.5 0 1 1.56,-1.47 q 29.9,0.36 58.94,-0.16 2.36,-0.04 5.94,0.36 a 0.45,0.45 0 0 0 0.47,-0.62 c -1.44,-3.4 -3.19,-7.04 -4.3,-10.16 q -8.1,-22.75 -17.58,-46.41 a 0.97,0.97 0 0 1 0.97,-1.33 l 0.47,0.05 a 1.95,1.96 82.4 0 1 1.63,1.24 l 21.74,56.66 a 0.88,0.87 80.2 0 0 0.78,0.56 q 4.96,0.18 7.37,0.02 10.7,-0.68 21.34,-0.43 8.4,0.2 18.84,0.2 61.72,-0.01 123.58,0.18 c 14.6,0.05 29.71,-0.57 43.98,0.11 a 0.25,0.26 72.9 0 0 0.22,-0.41 l -42.62,-56.8 a 1.37,1.37 0 0 1 0.32,-1.95 l 0.07,-0.05 a 1.44,1.44 0 0 1 1.97,0.32 l 43.65,58.09 a 1.6,1.6 0 0 0 1.28,0.64 h 109.77 a 1.54,1.54 0 0 1 1.52,1.29 l 0.08,0.51 A 1,1 0 0 1 999.75,888 H 891.54 a 0.42,0.42 0 0 0 -0.34,0.68 l 60.39,80.31 a 2.37,2.37 0 0 0 1.89,0.94 h 64.38 a 0.9,0.9 0 0 1 0.84,0.58 l 0.27,0.7 a 0.9,0.9 0 0 1 -0.84,1.22 h -63.32 a 0.33,0.33 0 0 0 -0.27,0.53 l 29.25,38.89 a 0.97,0.96 56 0 1 -0.27,1.4 l -0.37,0.23 a 1.59,1.58 55.6 0 1 -2.09,-0.4 l -30.04,-39.81 a 1.5,1.5 0 0 0 -1.2,-0.6 q -14.08,0.01 -33.04,-0.23 c -37.13,-0.47 -73.55,-0.31 -144.9,-0.33 -24.38,-0.01 -46.16,0.49 -68.75,0.55 a 0.36,0.35 79.7 0 0 -0.33,0.48 l 18.56,48.36 a 1.08,1.09 69 0 1 -0.63,1.4 l -0.62,0.24 a 1.22,1.21 68.8 0 1 -1.57,-0.7 l -18.67,-48.67 a 1.82,1.81 79.4 0 0 -1.69,-1.16 c -14.35,0.1 -27.87,-0.42 -42.29,-0.45 q -72.81,-0.14 -141.78,0.05 a 0.96,0.95 0 0 0 -0.96,0.95 v 49.01 a 0.97,0.96 86.7 0 1 -0.85,0.96 l -0.39,0.04 a 0.79,0.78 86.5 0 1 -0.88,-0.78 v -49.35 a 0.73,0.73 0 0 0 -0.69,-0.73 q -5.78,-0.3 -11.09,-0.29 -32.95,0.1 -126.89,0.13 c -18.31,0 -42.15,0.52 -63.44,0.48 a 1.44,1.44 0 0 0 -1.35,0.94 z"
id="path126" />
<path
fill="#dbdbdb"
d="m 116.67,969.67 a 0.13,0.13 0 0 1 -0.1,-0.21 l 58.24,-80.75 a 1.62,1.64 18.1 0 1 1.32,-0.68 h 160.02 a 0.49,0.49 0 0 1 0.46,0.65 c -1.79,5.21 -5.33,12.79 -7.86,19.63 q -8.33,22.49 -22.68,60.79 a 1.11,1.09 9.6 0 1 -1.02,0.71 q -91.98,0.77 -188.38,-0.14 z"
id="path127" />
<path
fill="#dbdbdb"
d="m 948.1,969.31 a 0.32,0.32 0 0 1 -0.26,0.51 c -82.94,0.37 -169.69,0.59 -245.75,-0.02 a 0.99,0.99 0 0 1 -0.91,-0.63 l -30.96,-80.7 a 0.42,0.42 0 0 1 0.32,-0.57 q 1.29,-0.23 4.21,-0.2 c 4.6,0.04 12.97,0.84 19.02,0.7 32.16,-0.71 66.97,-0.15 98.56,-0.34 q 36.78,-0.22 81.48,0.08 c 3.4,0.03 7.79,-0.25 12.19,-0.45 a 1.36,1.33 69.9 0 1 1.13,0.54 z"
id="path128" />
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="29.814716mm"
height="69.541504mm"
viewBox="0 0 29.814716 69.541504"
version="1.1"
id="svg1"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-118.16809,-81.184232)">
<path
id="rect134"
style="fill:#ffc986;fill-opacity:1;stroke:#303332;stroke-width:0.661458;stroke-dasharray:none;stroke-opacity:1"
d="m 133.0757,81.514962 c -1.97967,-1e-4 -2.87969,1.093515 -2.77916,1.760617 l -0.009,54.876751 h 5.57641 l -0.009,-54.876751 c 0.10049,-0.666985 -0.79916,-1.760498 -2.77864,-1.760617 z"
sodipodi:nodetypes="sccccss" />
<path
id="path135"
style="fill:#ff5339;fill-opacity:1;stroke:#303332;stroke-width:0.661458;stroke-dasharray:none;stroke-opacity:1"
d="m 133.0757,137.17203 c -7.21636,-5e-5 -13.06641,5.75899 -13.06638,10.25209 -0.83439,1.3e-4 -1.51068,0.66531 -1.51051,1.4857 1.2e-4,0.82018 0.67632,1.48505 1.51051,1.48518 h 26.13225 c 0.83418,-1.3e-4 1.51039,-0.665 1.5105,-1.48518 1.8e-4,-0.82039 -0.67611,-1.48557 -1.5105,-1.4857 3e-5,-4.49298 -5.84971,-10.25197 -13.06587,-10.25209 z"
sodipodi:nodetypes="cccccccc"
inkscape:export-filename="Plunger.svg"
inkscape:export-xdpi="150"
inkscape:export-ydpi="150" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="74.313499mm"
height="68.430984mm"
viewBox="0 0 74.313499 68.430984"
version="1.1"
id="svg1"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-64.250391,-129.42155)">
<path
id="path83"
style="fill:#303332;fill-opacity:1;stroke-width:0.264583"
d="m 64.359379,129.42156 c -0.0227,0.20997 -0.05057,0.41824 -0.05168,0.63562 -0.01235,2.20133 -0.03112,4.4827 -0.05581,6.84454 -0.03175,3.04006 0.426107,5.09081 2.640666,7.04608 a 0.80962499,0.81227082 11.7 0 1 0.238228,0.36225 c 0.194028,0.60149 0.355445,1.12358 0.484208,1.56632 1.079498,3.69621 2.61136,6.83412 4.810043,10.25519 0.126998,0.11288 0.261751,0.32219 0.404626,0.62735 2.651119,3.511 5.924215,6.66448 9.715686,8.94519 0.693206,0.41451 1.382973,0.86718 2.069124,1.35754 0.06703,0.32808 0.03872,0.53441 -0.08475,0.61908 -1.21179,7.45241 -2.399139,14.89534 -3.56154,22.32835 -0.167568,1.06715 0.0804,1.98314 0.743624,2.74867 1.222373,1.41816 2.936733,2.16167 4.746481,2.79932 2.652883,0.93484 5.777542,1.59929 9.374104,1.99264 6.532551,0.71438 13.697481,0.18248 19.888711,-1.78335 1.78858,-0.56886 3.96098,-1.44994 5.26273,-2.86546 1.04511,-1.13506 1.01338,-2.2307 0.74879,-3.82613 -1.17298,-7.09963 -2.32495,-14.21675 -3.4556,-21.35166 -0.0741,-0.14464 -0.10242,-0.38964 -0.0847,-0.73536 0.80609,-0.5274 1.60447,-1.0337 2.39468,-1.51877 1.06716,-0.65617 1.85629,-1.17662 2.36782,-1.56115 2.56998,-1.92968 4.87228,-4.27639 6.90604,-7.04039 l 0.62942,-0.87333 c 2.44297,-3.72533 4.12955,-7.54601 5.05912,-11.46184 a 1.3731875,1.36525 77.4 0 1 0.47336,-0.75137 c 2.26216,-1.83357 2.59562,-4.53232 2.53472,-7.34219 -0.0407,-1.95615 -0.0628,-4.00668 -0.0661,-6.15156 -5.2e-4,-0.30242 -0.0315,-0.58459 -0.0646,-0.86558 h -11.48043 c 1e-5,0.003 0.001,0.005 0.001,0.008 -0.003,3.511 -8.83928,5.68095 -11.44013,6.18101 -4.52612,0.8696 -9.22876,1.30432 -14.10766,1.30432 -4.880682,-0.002 -9.583308,-0.43806 -14.107675,-1.30742 -2.603494,-0.50271 -11.440645,-2.67465 -11.440645,-6.18567 v -5.1e-4 z" />
<path
id="path101"
style="fill:#dfdfdf;fill-opacity:1;stroke-width:0.264583"
d="m 65.12419,129.42156 c -0.09341,0.78171 -0.0036,1.59756 0.313159,2.44533 0.859895,2.3045 3.579445,4.36806 5.770191,5.54281 5.140844,2.75431 10.925092,4.29668 16.782955,5.18831 4.2492,0.64358 8.719648,0.96642 13.411585,0.96842 4.69193,0 9.16239,-0.3215 13.41158,-0.96532 5.85787,-0.88898 11.6416,-2.432 16.78244,-5.18366 2.19075,-1.17475 4.91074,-3.23829 5.77329,-5.54281 0.31785,-0.85057 0.40752,-1.66895 0.31265,-2.45308 h -10.73527 c 1e-5,0.003 5.2e-4,0.005 5.2e-4,0.008 -0.003,3.511 -8.83928,5.68095 -11.44013,6.18101 -4.52612,0.8696 -9.22876,1.30432 -14.10766,1.30432 -4.880678,-0.002 -9.583304,-0.43806 -14.107671,-1.30742 -2.603494,-0.50271 -11.440645,-2.67465 -11.440645,-6.18567 v -5.1e-4 z" />
<path
id="path102"
style="display:inline;fill:#303332;fill-opacity:1;stroke-width:0.264583"
d="m 75.113245,129.42156 a 26.286354,8.2338332 0 0 0 0,5.1e-4 26.286354,8.2338332 0 0 0 26.286255,8.23361 26.286354,8.2338332 0 0 0 26.28625,-8.23361 26.286354,8.2338332 0 0 0 0,-5.1e-4 h -0.73898 c 1e-5,0.003 5.2e-4,0.005 5.2e-4,0.008 -0.003,3.511 -8.83928,5.68095 -11.44013,6.18101 -4.52612,0.8696 -9.22876,1.30432 -14.10766,1.30432 -4.880678,-0.002 -9.583304,-0.43806 -14.107671,-1.30742 -2.603494,-0.50271 -11.440645,-2.67465 -11.440645,-6.18567 v -5.1e-4 z" />
<path
fill="#e8f7fc"
d="m 101.39673,151.97772 c -9.752545,0 -18.856857,-1.15359 -27.651607,-4.88156 q -3.849687,-1.63248 -6.664854,-4.04284 c -1.49225,-1.27794 -1.944687,-2.9501 -2.032,-5.10381 q -0.108479,-2.6035 -0.0344,-4.87627 a 0.08202083,0.079375 30.7 0 1 0.150813,-0.037 c 1.912937,3.21733 5.963708,5.48746 9.395354,6.81566 8.442854,3.26761 17.565687,4.42913 26.839334,4.43177 9.271,0 18.39648,-1.15887 26.83934,-4.42383 3.43164,-1.32821 7.48241,-3.59833 9.398,-6.81567 a 0.079375,0.08202083 59.3 0 1 0.15081,0.037 q 0.0714,2.27277 -0.037,4.87627 c -0.0873,2.15371 -0.53975,3.82587 -2.032,5.10381 q -2.81517,2.41035 -6.6675,4.04019 c -8.79475,3.72798 -17.89907,4.87891 -27.65425,4.87627 z"
id="path104"
style="stroke-width:0.264583" />
<path
fill="#e8f7fc"
d="m 101.39408,152.70532 c 10.72621,0.003 21.31219,-1.3626 30.8319,-6.30237 q 1.28058,-0.66411 2.30452,-1.46579 a 0.0555625,0.0555625 0 0 1 0.0873,0.0556 q -0.92075,3.74914 -2.74902,7.11729 -7.14375,13.17096 -21.58471,17.10796 -4.14602,1.12977 -8.89529,1.12977 -4.749271,-0.003 -8.892646,-1.13506 -14.440958,-3.94494 -21.576771,-17.12119 -1.825625,-3.36815 -2.743729,-7.11994 a 0.0555625,0.0555625 0 0 1 0.08731,-0.0556 q 1.023937,0.80433 2.30452,1.46844 c 9.514417,4.94506 20.100396,6.31825 30.826606,6.32089 z"
id="path109"
style="stroke-width:0.264583" />
<path
fill="#e8f7fc"
d="m 101.39673,171.10974 c 5.54566,0 10.96433,-1.27265 15.91998,-3.69623 a 0.1031875,0.1031875 0 0 1 0.14816,0.0741 q 2.41565,15.03098 3.51896,21.7805 0.21696,1.32821 0.12436,1.75684 c -0.63765,2.96597 -6.82361,4.50056 -9.34509,5.01385 q -5.27314,1.06892 -10.36637,1.06892 -5.093232,0 -10.366378,-1.07157 c -2.521479,-0.51064 -8.707437,-2.04523 -9.345083,-5.0112 q -0.0926,-0.42863 0.124354,-1.75684 1.103313,-6.74952 3.521604,-21.7805 a 0.1031875,0.1031875 0 0 1 0.145521,-0.0767 c 4.955646,2.42359 10.374312,3.69888 15.919982,3.69888 z"
id="path124"
style="stroke-width:0.264583" />
<rect
style="display:none;fill:#303332;fill-opacity:1;stroke-width:1.56104"
id="rect5"
width="121.91432"
height="92.455063"
x="45.702045"
y="36.966751" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

+690
View File
@@ -0,0 +1,690 @@
/**
* CHEF MT-3 tuner visualization for the Slopsmith tuner plugin.
*
* Inspired by classic chromatic pedal tuners:
* - Shiny black rectangular panel with chamfered edges and corner screws
* - 90° curved glass gauge arc spanning between the screw inner edges
* - 51 tick marks; glow fades in/out with audio signal presence
* - Red 7-segment display (bottom centre) with "#" symbol
* - Two rubber buttons flanked by panel labels: MODE (left), BRGHT. (right)
* - Standard mode: nearest tick cluster glows for current deviation
* - Strobe mode: groups of 3 bright ticks + lightspill drift with deviation
*
* Contract: window['_tunerViz_chef-mt3'](container) → { update(note, cents, freq, mode), destroy() }
*/
(function () {
'use strict';
// ── Module-level constants ─────────────────────────────────────────
var _TUNER_MT3_IN_TUNE_THR = 2;
var _TUNER_MT3_GAUGE_CENTS = 50;
var _TUNER_MT3_TICK_COUNT = 51; // 0¢ centre + 25×2¢ per side
var _TUNER_MT3_STROBE_GROUP_COUNT = 5;
// ── Colours ────────────────────────────────────────────────────────
var _MT3_COL_BG = '#0a0a0a';
var _MT3_COL_GAUGE_ARC = 'rgba(255,255,255,0.18)';
var _MT3_COL_TICK_DIM = 'rgba(255,255,255,0.45)';
// Tick glow colours are computed per-tick at factory construction (orange→yellow gradient)
var _MT3_COL_SEG_UNLIT = '#2a0000';
// Brightness levels: [brightScale for tick glow, SVG filter flood-opacity, lit segment fill, segment drop-shadow]
var _MT3_BRIGHTNESS = [
{ brightScale: 0.66, floodOpacity: '0.45', litFill: '#cc1500', glow: 'drop-shadow(0 0 2px #bb1100)' }, // low
{ brightScale: 1.0, floodOpacity: '0.65', litFill: '#ff2200', glow: 'drop-shadow(0 0 5px #ff2200)' }, // medium
{ brightScale: 1.0, floodOpacity: '1.0', litFill: '#ff5533', glow: 'drop-shadow(0 0 9px #ff4400)' }, // high
];
var _MT3_COL_BUTTON = '#1a1a1a';
var _MT3_COL_LABEL = '#c8c8c8';
// ── Gauge SVG geometry ─────────────────────────────────────────────
// viewBox "0 0 200 66": ratio 3.03 matches SVG element (width:100% height:auto on 5:3 panel)
// 90° arc: 225°(50¢) → 270°(apex) → 315°(+50¢); R=124, cy=138 → apex at y=14
var _MT3_cx = 100;
var _MT3_cy = 138;
var _MT3_ARC_R = 124;
var _MT3_ARC_START = 5 * Math.PI / 4;
var _MT3_ARC_SPAN = Math.PI / 2;
var _MT3_ARC_SX = _MT3_cx + _MT3_ARC_R * Math.cos(_MT3_ARC_START);
var _MT3_ARC_SY = _MT3_cy + _MT3_ARC_R * Math.sin(_MT3_ARC_START);
var _MT3_ARC_EX = _MT3_cx + _MT3_ARC_R * Math.cos(_MT3_ARC_START + _MT3_ARC_SPAN);
var _MT3_ARC_EY = _MT3_cy + _MT3_ARC_R * Math.sin(_MT3_ARC_START + _MT3_ARC_SPAN);
var _SVG_NS = 'http://www.w3.org/2000/svg';
// ── 8-segment lookup table ─────────────────────────────────────────
var _TUNER_MT3_SEGMENTS = {
// a b c d e f g1 g2
'A': [ true, true, true, false, true, true, true, true ],
'B': [ false, false, true, true, true, true, true, true ],
'C': [ true, false, false, true, true, true, false, false ],
'D': [ false, true, true, true, true, false, true, true ],
'E': [ true, false, false, true, true, true, true, false ],
'F': [ true, false, false, false, true, true, true, false ],
'G': [ true, false, true, true, true, true, false, true ],
' ': [ false, false, false, false, false, false, false, false ],
};
var _segKeys = ['a', 'b', 'c', 'd', 'e', 'f', 'g1', 'g2'];
window['_tunerViz_chef-mt3'] = function (container) {
'use strict';
// ── Root panel ────────────────────────────────────────────────
var panel = document.createElement('div');
panel.style.position = 'relative';
panel.style.overflow = 'hidden';
panel.style.aspectRatio = '5 / 3';
panel.style.minHeight = '120px';
panel.style.backgroundColor = _MT3_COL_BG;
panel.style.border = '2px solid #505050';
panel.style.borderRadius = '6px';
panel.style.userSelect = 'none';
panel.style.fontFamily = 'monospace';
// ── Corner screws ─────────────────────────────────────────────
[['top','left'],['top','right'],['bottom','left'],['bottom','right']].forEach(function (pos) {
var s = document.createElement('div');
s.style.position = 'absolute';
s.style[pos[0]] = '3%';
s.style[pos[1]] = '2%';
s.style.width = '4%';
s.style.height = '0';
s.style.paddingBottom = '4%';
s.style.borderRadius = '50%';
s.style.background = 'radial-gradient(circle at 35% 35%, #666, #222)';
s.style.boxShadow = '0 1px 3px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.12)';
s.style.zIndex = '5';
var slot = document.createElement('div');
slot.style.position = 'absolute';
slot.style.top = '45%';
slot.style.left = '15%';
slot.style.right = '15%';
slot.style.height = '10%';
slot.style.backgroundColor = '#111';
s.appendChild(slot);
panel.appendChild(s);
});
// ── Gauge SVG ─────────────────────────────────────────────────
var gaugeSvg = document.createElementNS(_SVG_NS, 'svg');
gaugeSvg.setAttribute('viewBox', '0 0 200 66');
gaugeSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
gaugeSvg.style.position = 'absolute';
gaugeSvg.style.top = '5%';
gaugeSvg.style.left = '0';
gaugeSvg.style.width = '100%';
gaugeSvg.style.height = 'auto';
gaugeSvg.style.overflow = 'visible';
// SVG glow filter — applied to the glow tick group as a whole
var _glowId = 'mt3-glow-' + Math.random().toString(36).slice(2, 7);
var _svgDefs = document.createElementNS(_SVG_NS, 'defs');
var _svgFilter = document.createElementNS(_SVG_NS, 'filter');
_svgFilter.setAttribute('id', _glowId);
_svgFilter.setAttribute('x', '-60%'); _svgFilter.setAttribute('y', '-60%');
_svgFilter.setAttribute('width', '220%'); _svgFilter.setAttribute('height', '220%');
var _sfBlur = document.createElementNS(_SVG_NS, 'feGaussianBlur');
_sfBlur.setAttribute('stdDeviation', '1.8'); _sfBlur.setAttribute('result', 'blur');
var _sfFlood = document.createElementNS(_SVG_NS, 'feFlood');
_sfFlood.setAttribute('flood-color', '#ff8800'); _sfFlood.setAttribute('flood-opacity', '0.65');
_sfFlood.setAttribute('result', 'col');
var _sfComp = document.createElementNS(_SVG_NS, 'feComposite');
_sfComp.setAttribute('in', 'col'); _sfComp.setAttribute('in2', 'blur');
_sfComp.setAttribute('operator', 'in'); _sfComp.setAttribute('result', 'glow');
var _sfMerge = document.createElementNS(_SVG_NS, 'feMerge');
var _sfMn1 = document.createElementNS(_SVG_NS, 'feMergeNode'); _sfMn1.setAttribute('in', 'glow');
var _sfMn2 = document.createElementNS(_SVG_NS, 'feMergeNode'); _sfMn2.setAttribute('in', 'SourceGraphic');
_sfMerge.appendChild(_sfMn1); _sfMerge.appendChild(_sfMn2);
_svgFilter.appendChild(_sfBlur); _svgFilter.appendChild(_sfFlood);
_svgFilter.appendChild(_sfComp); _svgFilter.appendChild(_sfMerge);
// Shared blur filter for highlight and shadow arcs
var _glassBlurId = 'mt3-gb-' + Math.random().toString(36).slice(2, 7);
var _glassBlur = document.createElementNS(_SVG_NS, 'filter');
_glassBlur.setAttribute('id', _glassBlurId);
_glassBlur.setAttribute('x', '-30%'); _glassBlur.setAttribute('y', '-30%');
_glassBlur.setAttribute('width', '160%'); _glassBlur.setAttribute('height', '160%');
var _gbFe = document.createElementNS(_SVG_NS, 'feGaussianBlur');
_gbFe.setAttribute('stdDeviation', '1.8');
_glassBlur.appendChild(_gbFe);
_svgDefs.appendChild(_glassBlur);
// Gradient for specular highlight: transparent at arc ends, white at centre
var _hlGradId = 'mt3-hl-' + Math.random().toString(36).slice(2, 7);
var _hlGrad = document.createElementNS(_SVG_NS, 'linearGradient');
_hlGrad.setAttribute('id', _hlGradId);
_hlGrad.setAttribute('gradientUnits', 'userSpaceOnUse');
_hlGrad.setAttribute('x1', _MT3_ARC_SX.toFixed(2)); _hlGrad.setAttribute('y1', '0');
_hlGrad.setAttribute('x2', _MT3_ARC_EX.toFixed(2)); _hlGrad.setAttribute('y2', '0');
[['0%','rgba(255,255,255,0.65)'],['4%','rgba(255,255,255,0)'],['20%','rgba(255,255,255,0)'],['35%','rgba(255,255,255,0.68)'],['65%','rgba(255,255,255,0.68)'],['80%','rgba(255,255,255,0)'],['96%','rgba(255,255,255,0)'],['100%','rgba(255,255,255,0.65)']]
.forEach(function(s){var st=document.createElementNS(_SVG_NS,'stop');st.setAttribute('offset',s[0]);st.setAttribute('stop-color',s[1]);_hlGrad.appendChild(st);});
_svgDefs.appendChild(_hlGrad);
// Gradient for outer shadow: transparent at arc ends, dark at centre
var _shGradId = 'mt3-sh-' + Math.random().toString(36).slice(2, 7);
var _shGrad = document.createElementNS(_SVG_NS, 'linearGradient');
_shGrad.setAttribute('id', _shGradId);
_shGrad.setAttribute('gradientUnits', 'userSpaceOnUse');
_shGrad.setAttribute('x1', _MT3_ARC_SX.toFixed(2)); _shGrad.setAttribute('y1', '0');
_shGrad.setAttribute('x2', _MT3_ARC_EX.toFixed(2)); _shGrad.setAttribute('y2', '0');
[['0%','rgba(0,0,0,0.65)'],['4%','rgba(0,0,0,0)'],['20%','rgba(0,0,0,0.68)'],['80%','rgba(0,0,0,0.68)'],['96%','rgba(0,0,0,0)'],['100%','rgba(0,0,0,0.65)']]
.forEach(function(s){var st=document.createElementNS(_SVG_NS,'stop');st.setAttribute('offset',s[0]);st.setAttribute('stop-color',s[1]);_shGrad.appendChild(st);});
_svgDefs.appendChild(_shGrad);
_svgDefs.appendChild(_svgFilter);
gaugeSvg.appendChild(_svgDefs);
var _arcD = 'M ' + _MT3_ARC_SX.toFixed(2) + ' ' + _MT3_ARC_SY.toFixed(2) +
' A ' + _MT3_ARC_R + ' ' + _MT3_ARC_R + ' 0 0 1 ' +
_MT3_ARC_EX.toFixed(2) + ' ' + _MT3_ARC_EY.toFixed(2);
// Glass arc body
var arcBody = document.createElementNS(_SVG_NS, 'path');
arcBody.setAttribute('d', _arcD);
arcBody.setAttribute('fill', 'none');
arcBody.setAttribute('stroke', _MT3_COL_GAUGE_ARC);
arcBody.setAttribute('stroke-width', '16');
arcBody.setAttribute('stroke-linecap', 'round');
gaugeSvg.appendChild(arcBody);
// Inner shadow — dark stroke on the inner-lower edge, simulates less light reaching far side
var _shadowR = _MT3_ARC_R - 6;
// Align shadow gradient vector to shadow arc's own endpoints (not the main arc's)
_shGrad.setAttribute('x1', (_MT3_cx + _shadowR * Math.cos(_MT3_ARC_START)).toFixed(2));
_shGrad.setAttribute('x2', (_MT3_cx + _shadowR * Math.cos(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2));
var arcShadow = document.createElementNS(_SVG_NS, 'path');
arcShadow.setAttribute('d',
'M ' + (_MT3_cx + _shadowR * Math.cos(_MT3_ARC_START)).toFixed(2) + ' ' +
(_MT3_cy + _shadowR * Math.sin(_MT3_ARC_START)).toFixed(2) +
' A ' + _shadowR + ' ' + _shadowR + ' 0 0 1 ' +
(_MT3_cx + _shadowR * Math.cos(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2) + ' ' +
(_MT3_cy + _shadowR * Math.sin(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2));
arcShadow.setAttribute('fill', 'none');
arcShadow.setAttribute('stroke', 'url(#' + _shGradId + ')');
arcShadow.setAttribute('stroke-width', '3.5');
arcShadow.setAttribute('stroke-linecap', 'round');
arcShadow.setAttribute('filter', 'url(#' + _glassBlurId + ')');
// Specular highlight — bright white arc fading to transparent at ends
var _hlR = _MT3_ARC_R + 2;
var arcHighlight = document.createElementNS(_SVG_NS, 'path');
arcHighlight.setAttribute('d',
'M ' + (_MT3_cx + _hlR * Math.cos(_MT3_ARC_START)).toFixed(2) + ' ' +
(_MT3_cy + _hlR * Math.sin(_MT3_ARC_START)).toFixed(2) +
' A ' + _hlR + ' ' + _hlR + ' 0 0 1 ' +
(_MT3_cx + _hlR * Math.cos(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2) + ' ' +
(_MT3_cy + _hlR * Math.sin(_MT3_ARC_START + _MT3_ARC_SPAN)).toFixed(2));
arcHighlight.setAttribute('fill', 'none');
arcHighlight.setAttribute('stroke', 'url(#' + _hlGradId + ')');
arcHighlight.setAttribute('stroke-width', '2.5');
arcHighlight.setAttribute('stroke-linecap', 'round');
arcHighlight.setAttribute('filter', 'url(#' + _glassBlurId + ')');
// dimGroup: base tick lines always shown at dim colour — constructed once, never updated
var _dimGroup = document.createElementNS(_SVG_NS, 'g');
// glowGroup: lit tick overlay with shared glow filter, opacity animated 0→1
var _glowGroup = document.createElementNS(_SVG_NS, 'g');
_glowGroup.setAttribute('filter', 'url(#' + _glowId + ')');
_glowGroup.setAttribute('opacity', '0');
// Z-order: arcBody → dimGroup (unlit ticks) → arcShadow → glowGroup (lit ticks) → arcHighlight
gaugeSvg.appendChild(_dimGroup);
gaugeSvg.appendChild(arcShadow);
gaugeSvg.appendChild(_glowGroup);
var _mt3GlowTickEls = [];
for (var i = 0; i < _TUNER_MT3_TICK_COUNT; i++) {
var isMajor = (i % 5 === 0);
var halfLen = isMajor ? 5 : 3;
var a = _MT3_ARC_START + (_MT3_ARC_SPAN / (_TUNER_MT3_TICK_COUNT - 1)) * i;
var cosA = Math.cos(a), sinA = Math.sin(a);
var x1 = _MT3_cx + (_MT3_ARC_R - halfLen) * cosA;
var y1 = _MT3_cy + (_MT3_ARC_R - halfLen) * sinA;
var x2 = _MT3_cx + (_MT3_ARC_R + halfLen) * cosA;
var y2 = _MT3_cy + (_MT3_ARC_R + halfLen) * sinA;
var dimTick = document.createElementNS(_SVG_NS, 'line');
dimTick.setAttribute('x1', String(x1)); dimTick.setAttribute('y1', String(y1));
dimTick.setAttribute('x2', String(x2)); dimTick.setAttribute('y2', String(y2));
dimTick.setAttribute('stroke', _MT3_COL_TICK_DIM);
dimTick.setAttribute('stroke-width', '1');
dimTick.setAttribute('stroke-linecap', 'round');
_dimGroup.appendChild(dimTick);
var glowTick = document.createElementNS(_SVG_NS, 'line');
glowTick.setAttribute('x1', String(x1)); glowTick.setAttribute('y1', String(y1));
glowTick.setAttribute('x2', String(x2)); glowTick.setAttribute('y2', String(y2));
glowTick.setAttribute('stroke', 'none');
glowTick.setAttribute('stroke-width', '1');
glowTick.setAttribute('stroke-linecap', 'round');
_glowGroup.appendChild(glowTick);
_mt3GlowTickEls.push(glowTick);
}
// Per-tick gradient colours: orange (#ff7700) at arc edges → yellow (#ffee00) at centre
// d = distance from centre (0 = centre tick, 1 = end ticks)
var _mt3TickColors = [];
var _mt3TickSpillColors = [];
for (var tc = 0; tc < _TUNER_MT3_TICK_COUNT; tc++) {
var d = Math.abs((tc / (_TUNER_MT3_TICK_COUNT - 1)) - 0.5) * 2;
var tg = Math.round(238 * (1 - d) + 119 * d); // G channel: 238 (yellow) → 119 (orange)
_mt3TickColors.push('rgb(255,' + tg + ',0)');
_mt3TickSpillColors.push('rgba(255,' + tg + ',0,0.52)');
}
// Arc-following labels — inside the arc at R18 (below the tube's inner edge with a gap)
// Tube inner edge at R8; labels at R18 give ~10 SVG-unit gap at apex, ~7 at ends
[
{ t: 0.0, text: '-50' },
{ t: 0.5, text: '0' },
{ t: 1.0, text: '+50' },
].forEach(function (lbl) {
var ang = _MT3_ARC_START + lbl.t * _MT3_ARC_SPAN;
var r = _MT3_ARC_R - 18;
var lx = _MT3_cx + r * Math.cos(ang);
var ly = _MT3_cy + r * Math.sin(ang);
var el = document.createElementNS(_SVG_NS, 'text');
el.setAttribute('x', String(lx));
el.setAttribute('y', String(ly));
el.setAttribute('text-anchor', 'middle');
el.setAttribute('dominant-baseline', 'middle');
el.setAttribute('font-size', '7');
el.setAttribute('fill', 'rgba(255,255,255,0.50)');
el.textContent = lbl.text;
gaugeSvg.appendChild(el);
});
// arcHighlight is topmost — appended last so it renders above ticks
gaugeSvg.appendChild(arcHighlight);
panel.appendChild(gaugeSvg);
// ── 7-segment display ─────────────────────────────────────────
var displayWrap = document.createElement('div');
displayWrap.style.position = 'absolute';
displayWrap.style.bottom = '5%';
displayWrap.style.left = '50%';
displayWrap.style.transform = 'translateX(-50%)';
displayWrap.style.width = '18%';
displayWrap.style.height = '45%';
displayWrap.style.background = '#0d0000';
displayWrap.style.borderRadius = '3px';
displayWrap.style.border = '1px solid #2a0000';
displayWrap.style.display = 'flex';
displayWrap.style.alignItems = 'center';
displayWrap.style.justifyContent = 'center';
displayWrap.style.padding = '4%';
displayWrap.style.boxSizing = 'border-box';
displayWrap.style.boxShadow = 'inset 0 0 8px #000';
panel.appendChild(displayWrap);
var segSvg = document.createElementNS(_SVG_NS, 'svg');
segSvg.setAttribute('viewBox', '0 0 100 200');
segSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
segSvg.style.width = '90%';
segSvg.style.aspectRatio = '1 / 2';
segSvg.style.flexShrink = '0';
segSvg.style.overflow = 'visible';
displayWrap.appendChild(segSvg);
var _mt3SegEls = {};
function _makeSegPoly(key, points) {
var el = document.createElementNS(_SVG_NS, 'polygon');
el.setAttribute('points', points);
el.setAttribute('fill', _MT3_COL_SEG_UNLIT);
el.setAttribute('shape-rendering', 'crispEdges');
segSvg.appendChild(el);
_mt3SegEls[key] = el;
}
_makeSegPoly('a', '11,5 89,5 95,13 89,21 11,21 5,13');
_makeSegPoly('b', '87,26 95,32 95,81 87,87 79,81 79,32');
_makeSegPoly('c', '87,113 95,119 95,168 87,174 79,168 79,119');
_makeSegPoly('d', '11,179 89,179 95,187 89,195 11,195 5,187');
_makeSegPoly('e', '13,113 21,119 21,168 13,174 5,168 5,119');
_makeSegPoly('f', '13,26 21,32 21,81 13,87 5,81 5,32');
_makeSegPoly('g1', '11,92 42.5,92 48.5,100 42.5,108 11,108 5,100');
_makeSegPoly('g2', '57.5,92 89,92 95,100 89,108 57.5,108 51.5,100');
var sharpSvg = document.createElementNS(_SVG_NS, 'svg');
sharpSvg.setAttribute('viewBox', '0 0 90 90');
sharpSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
sharpSvg.style.position = 'absolute';
sharpSvg.style.top = '57%';
sharpSvg.style.right = '3%';
sharpSvg.style.width = '23%';
sharpSvg.style.aspectRatio = '1 / 1';
sharpSvg.style.overflow = 'visible';
sharpSvg.style.pointerEvents = 'none';
displayWrap.appendChild(sharpSvg);
var _mt3SharpParts = [];
function _makeSharpPoly(pts) {
var el = document.createElementNS(_SVG_NS, 'polygon');
el.setAttribute('points', pts);
el.setAttribute('fill', _MT3_COL_SEG_UNLIT);
el.setAttribute('shape-rendering', 'crispEdges');
sharpSvg.appendChild(el);
_mt3SharpParts.push(el);
}
_makeSharpPoly('28.3,0 33.3,4 33.3,86 28.3,90 23.3,86 23.3,4');
_makeSharpPoly('61.7,0 66.7,4 66.7,86 61.7,90 56.7,86 56.7,4');
_makeSharpPoly('4,23.3 86,23.3 90,28.3 86,33.3 4,33.3 0,28.3');
_makeSharpPoly('4,56.7 86,56.7 90,61.7 86,66.7 4,66.7 0,61.7');
// "♭" symbol — same position as sharpSvg, shown in place of it for flat notes
var flatSvg = document.createElementNS(_SVG_NS, 'svg');
flatSvg.setAttribute('viewBox', '0 0 90 90');
flatSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
flatSvg.style.position = 'absolute';
flatSvg.style.top = '57%';
flatSvg.style.right = '3%';
flatSvg.style.width = '23%';
flatSvg.style.aspectRatio = '1 / 1';
flatSvg.style.overflow = 'visible';
flatSvg.style.pointerEvents = 'none';
flatSvg.style.display = 'none';
displayWrap.appendChild(flatSvg);
var _mt3FlatText = document.createElementNS(_SVG_NS, 'text');
_mt3FlatText.setAttribute('x', '45');
_mt3FlatText.setAttribute('y', '82');
_mt3FlatText.setAttribute('text-anchor', 'middle');
_mt3FlatText.setAttribute('font-size', '85');
_mt3FlatText.setAttribute('font-family', 'Georgia, serif');
_mt3FlatText.setAttribute('fill', _MT3_COL_SEG_UNLIT);
_mt3FlatText.textContent = '♭';
flatSvg.appendChild(_mt3FlatText);
// ── Buttons ───────────────────────────────────────────────────
// Strobe indicator LED — centred above the MODE button, lit when strobe mode is active
// Button: bottom:24%, height:8% → top edge at bottom:32%; LED sits at bottom:33.5%
// Button left:calc(50%-22%), width:10% → centre at calc(50%-17%); LED width:2.5% → left:calc(50%-18.25%)
var _mt3StrobeLed = document.createElement('div');
_mt3StrobeLed.style.position = 'absolute';
_mt3StrobeLed.style.bottom = '33.5%';
_mt3StrobeLed.style.left = 'calc(50% - 18.25%)';
_mt3StrobeLed.style.width = '2.5%';
_mt3StrobeLed.style.height = '0';
_mt3StrobeLed.style.paddingBottom = '2.5%';
_mt3StrobeLed.style.borderRadius = '50%';
_mt3StrobeLed.style.background = 'radial-gradient(circle at 35% 35%, #2a0000, #0a0000)';
_mt3StrobeLed.style.boxShadow = 'none';
_mt3StrobeLed.style.pointerEvents = 'none';
panel.appendChild(_mt3StrobeLed);
var _mt3ModeBtn = document.createElement('div');
_mt3ModeBtn.style.position = 'absolute';
_mt3ModeBtn.style.bottom = '24%';
_mt3ModeBtn.style.left = 'calc(50% - 22%)';
_mt3ModeBtn.style.width = '10%';
_mt3ModeBtn.style.height = '8%';
_mt3ModeBtn.style.backgroundColor = _MT3_COL_BUTTON;
_mt3ModeBtn.style.borderRadius = '3px';
_mt3ModeBtn.style.border = '1px solid #333';
_mt3ModeBtn.style.boxShadow = 'inset 0 1px 2px rgba(255,255,255,0.08), 0 2px 3px rgba(0,0,0,0.7)';
_mt3ModeBtn.style.cursor = 'pointer';
panel.appendChild(_mt3ModeBtn);
var brightBtn = document.createElement('div');
brightBtn.style.position = 'absolute';
brightBtn.style.bottom = '24%';
brightBtn.style.left = 'calc(50% + 12%)';
brightBtn.style.width = '10%';
brightBtn.style.height = '8%';
brightBtn.style.backgroundColor = _MT3_COL_BUTTON;
brightBtn.style.borderRadius = '3px';
brightBtn.style.border = '1px solid #333';
brightBtn.style.boxShadow = 'inset 0 1px 2px rgba(255,255,255,0.08), 0 2px 3px rgba(0,0,0,0.7)';
brightBtn.style.cursor = 'pointer';
panel.appendChild(brightBtn);
[{text: 'MODE', left: 'calc(50% - 22%)'}, {text: 'BRGHT', left: 'calc(50% + 12%)'}]
.forEach(function (lbl) {
var el = document.createElement('div');
el.style.position = 'absolute';
el.style.bottom = '14%';
el.style.left = lbl.left;
el.style.width = '10%';
el.style.textAlign = 'center';
el.style.color = _MT3_COL_LABEL;
el.style.fontSize = '0.75em';
el.style.letterSpacing = '0.05em';
el.style.pointerEvents = 'none';
el.textContent = lbl.text;
panel.appendChild(el);
});
var brandLbl = document.createElement('div');
brandLbl.style.position = 'absolute';
brandLbl.style.top = '2%';
brandLbl.style.right = '8%';
brandLbl.style.color = _MT3_COL_LABEL;
brandLbl.style.fontSize = '0.75em';
brandLbl.style.fontWeight = '600';
brandLbl.style.letterSpacing = '0.06em';
brandLbl.textContent = 'CHEF MT-3';
panel.appendChild(brandLbl);
container.appendChild(panel);
// ── Animation / glow state ────────────────────────────────────
var _mt3Mode = 'standard';
var _mt3CurrentCents = 0;
var _mt3SmoothedCents = 0;
var _mt3HasSignal = false;
var _mt3GlowOpacity = 0;
var _mt3RafId = null;
var _mt3BrightnessIdx = 1; // 0=low, 1=medium, 2=high
var _mt3LastLetter = ' '; // for re-render on brightness change
var _mt3LastSharp = false;
var _mt3LastFlat = false;
var _mt3LastTime = null;
var _mt3StrobeOffset = 0;
// Tick state: 0=dim, 1=spill, 2=bright (computed each frame, never persisted between calls)
var _mt3TickState = [];
var _mt3LastTickState = [];
for (var ti = 0; ti < _TUNER_MT3_TICK_COUNT; ti++) {
_mt3TickState.push(0);
_mt3LastTickState.push(-1); // -1 = unrendered, forces first paint
}
// ── Segment helpers ───────────────────────────────────────────
function _setSegment(el, lit) {
var brt = _MT3_BRIGHTNESS[_mt3BrightnessIdx];
el.setAttribute('fill', lit ? brt.litFill : _MT3_COL_SEG_UNLIT);
el.style.filter = lit ? brt.glow : 'drop-shadow(0 0 0px transparent)';
}
function _renderNote(letter) {
var map = _TUNER_MT3_SEGMENTS[letter] || _TUNER_MT3_SEGMENTS[' '];
for (var k = 0; k < _segKeys.length; k++) { _setSegment(_mt3SegEls[_segKeys[k]], map[k]); }
}
function _setSharp(lit) {
var brt = _MT3_BRIGHTNESS[_mt3BrightnessIdx];
for (var p = 0; p < _mt3SharpParts.length; p++) {
_mt3SharpParts[p].setAttribute('fill', lit ? brt.litFill : _MT3_COL_SEG_UNLIT);
}
sharpSvg.style.filter = lit ? brt.glow : 'none';
}
function _setFlat(lit) {
flatSvg.style.display = lit ? '' : 'none';
if (lit) {
var brt = _MT3_BRIGHTNESS[_mt3BrightnessIdx];
_mt3FlatText.setAttribute('fill', brt.litFill);
flatSvg.style.filter = brt.glow;
} else {
flatSvg.style.filter = 'none';
}
}
function _applyAccidental() {
if (_mt3LastFlat) {
sharpSvg.style.display = 'none';
_setFlat(true);
} else {
sharpSvg.style.display = '';
_setSharp(_mt3LastSharp);
_setFlat(false);
}
}
// ── Tick state helpers ────────────────────────────────────────
function _clearTickStates() {
for (var ti = 0; ti < _TUNER_MT3_TICK_COUNT; ti++) { _mt3TickState[ti] = 0; }
}
// Never downgrade: a bright centre tick won't be overwritten by a spill from another group
function _setTickState(idx, level) {
if (idx < 0 || idx >= _TUNER_MT3_TICK_COUNT) { return; }
if (level > _mt3TickState[idx]) { _mt3TickState[idx] = level; }
}
// Standard mode: 1 bright + ±1 spill
function _computeStandardStates(cents, hasSignal) {
_clearTickStates();
if (!hasSignal) { return; }
var clamped = Math.max(-_TUNER_MT3_GAUGE_CENTS, Math.min(_TUNER_MT3_GAUGE_CENTS, cents));
var targetIdx = Math.round((clamped + _TUNER_MT3_GAUGE_CENTS) /
(2 * _TUNER_MT3_GAUGE_CENTS / (_TUNER_MT3_TICK_COUNT - 1)));
targetIdx = Math.max(0, Math.min(_TUNER_MT3_TICK_COUNT - 1, targetIdx));
_setTickState(targetIdx, 2);
_setTickState(targetIdx - 1, 1);
_setTickState(targetIdx + 1, 1);
}
// Strobe mode: 3 bright ticks per group + ±1 spill on each outer edge
function _computeStrobeStates() {
_clearTickStates();
for (var g = 0; g < _TUNER_MT3_STROBE_GROUP_COUNT; g++) {
var baseAngle = _MT3_ARC_START + (g / _TUNER_MT3_STROBE_GROUP_COUNT) * _MT3_ARC_SPAN + _mt3StrobeOffset;
var relAngle = ((baseAngle - _MT3_ARC_START) % _MT3_ARC_SPAN + _MT3_ARC_SPAN) % _MT3_ARC_SPAN;
var nearIdx = Math.max(0, Math.min(_TUNER_MT3_TICK_COUNT - 1,
Math.round(relAngle / _MT3_ARC_SPAN * (_TUNER_MT3_TICK_COUNT - 1))));
// 3 bright ticks centred at nearIdx
_setTickState(nearIdx - 1, 2);
_setTickState(nearIdx, 2);
_setTickState(nearIdx + 1, 2);
// Lightspill beyond the cluster
_setTickState(nearIdx - 2, 1);
_setTickState(nearIdx + 2, 1);
}
}
// Apply current tick states to the glowGroup DOM — only write changed ticks
function _applyTickStates() {
for (var ti = 0; ti < _TUNER_MT3_TICK_COUNT; ti++) {
var state = _mt3TickState[ti];
if (state === _mt3LastTickState[ti]) { continue; }
_mt3LastTickState[ti] = state;
var el = _mt3GlowTickEls[ti];
if (state === 2) {
el.setAttribute('stroke', _mt3TickColors[ti]);
el.setAttribute('stroke-width', '3');
} else if (state === 1) {
el.setAttribute('stroke', _mt3TickSpillColors[ti]);
el.setAttribute('stroke-width', '2');
} else {
el.setAttribute('stroke', 'none');
el.setAttribute('stroke-width', '1');
}
}
}
// ── RAF loop ──────────────────────────────────────────────────
function _animateStrobe(now) {
if (_mt3LastTime === null) { _mt3LastTime = now; }
var dt = Math.min((now - _mt3LastTime) / 1000, 0.1);
_mt3LastTime = now;
// Smooth cents for strobe drift
var lerpFactor = 1 - Math.exp(-10 * dt);
_mt3SmoothedCents += (_mt3CurrentCents - _mt3SmoothedCents) * lerpFactor;
// Animate glow opacity: fast fade-in (~120ms), slow fade-out (~400ms)
var opacityTarget = _mt3HasSignal ? 1.0 : 0.0;
var opacityRate = _mt3HasSignal ? 8.0 : 2.5;
_mt3GlowOpacity += (opacityTarget - _mt3GlowOpacity) * (1 - Math.exp(-opacityRate * dt));
var _scaledOpacity = Math.min(1, _mt3GlowOpacity * _MT3_BRIGHTNESS[_mt3BrightnessIdx].brightScale);
_glowGroup.setAttribute('opacity', _scaledOpacity.toFixed(3));
// Advance strobe offset
if (_mt3Mode === 'strobe' && Math.abs(_mt3SmoothedCents) > 0.1) {
var absCents = Math.min(_TUNER_MT3_GAUGE_CENTS, Math.abs(_mt3SmoothedCents));
var normalized = Math.max(0, absCents - _TUNER_MT3_IN_TUNE_THR) / (_TUNER_MT3_GAUGE_CENTS - _TUNER_MT3_IN_TUNE_THR);
var speed = _MT3_ARC_SPAN * Math.pow(normalized, 0.9);
if (_mt3SmoothedCents < 0) { speed = -speed; }
_mt3StrobeOffset = ((_mt3StrobeOffset + speed * dt) % _MT3_ARC_SPAN + _MT3_ARC_SPAN) % _MT3_ARC_SPAN;
}
// Recompute and apply strobe tick states each frame
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
_applyTickStates();
_mt3RafId = requestAnimationFrame(_animateStrobe);
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
// ── MODE button ───────────────────────────────────────────────
_mt3ModeBtn.addEventListener('click', function () {
_mt3ModeBtn.style.boxShadow = 'inset 0 2px 4px rgba(0,0,0,0.9)';
setTimeout(function () {
_mt3ModeBtn.style.boxShadow = 'inset 0 1px 2px rgba(255,255,255,0.08), 0 2px 3px rgba(0,0,0,0.7)';
}, 120);
if (_mt3Mode === 'standard') {
_mt3Mode = 'strobe';
_mt3StrobeOffset = 0;
_clearTickStates();
_mt3StrobeLed.style.background = 'radial-gradient(circle at 35% 35%, #ff4444, #cc0000)';
_mt3StrobeLed.style.boxShadow = '0 0 4px 2px #ff2200, 0 0 8px 3px #880000';
} else {
_mt3Mode = 'standard';
_computeStandardStates(_mt3CurrentCents, _mt3HasSignal);
_mt3StrobeLed.style.background = 'radial-gradient(circle at 35% 35%, #2a0000, #0a0000)';
_mt3StrobeLed.style.boxShadow = 'none';
}
});
// ── BRGHT button — cycle low/medium/high brightness ──────────
brightBtn.addEventListener('click', function () {
brightBtn.style.boxShadow = 'inset 0 2px 4px rgba(0,0,0,0.9)';
setTimeout(function () {
brightBtn.style.boxShadow = 'inset 0 1px 2px rgba(255,255,255,0.08), 0 2px 3px rgba(0,0,0,0.7)';
}, 120);
_mt3BrightnessIdx = (_mt3BrightnessIdx + 1) % _MT3_BRIGHTNESS.length;
// Update SVG tick glow filter intensity
_sfFlood.setAttribute('flood-opacity', _MT3_BRIGHTNESS[_mt3BrightnessIdx].floodOpacity);
// Re-render segment display with new brightness
_renderNote(_mt3LastLetter);
_applyAccidental();
});
// ── Public: update ────────────────────────────────────────────
function update(note, cents) {
var hasNote = (note !== null && note !== undefined);
_mt3HasSignal = hasNote;
_mt3CurrentCents = hasNote ? (cents || 0) : 0;
if (_mt3Mode === 'standard') { _computeStandardStates(_mt3CurrentCents, hasNote); }
if (hasNote) {
_mt3LastLetter = note.charAt(0);
_mt3LastSharp = note.charAt(1) === '#';
_mt3LastFlat = note.charAt(1) === 'b';
_renderNote(_mt3LastLetter);
_applyAccidental();
} else {
_mt3LastLetter = ' ';
_mt3LastSharp = false;
_mt3LastFlat = false;
_renderNote(' ');
_applyAccidental();
}
}
// ── Public: destroy ───────────────────────────────────────────
function destroy() {
if (_mt3RafId) { cancelAnimationFrame(_mt3RafId); _mt3RafId = null; }
panel.remove();
}
return { update: update, destroy: destroy };
};
})();
+81
View File
@@ -0,0 +1,81 @@
/**
* Default (gauge) tuner visualization for the Slopsmith tuner plugin.
*
* Contract: window._tunerViz_default(container) → { update(note, cents, freq), destroy() }
* - note: string | null (null = no signal)
* - cents: number (deviation from target, 50…+50)
* - freq: number (detected frequency in Hz)
*/
window._tunerViz_default = function (container) {
'use strict';
// ── DOM ───────────────────────────────────────────────────────────
const noteDisplay = document.createElement('div');
noteDisplay.className = 'my-2 h-16 flex items-center justify-center';
const noteText = document.createElement('div');
noteText.className = 'text-5xl font-black text-white';
noteText.textContent = '--';
noteDisplay.appendChild(noteText);
container.appendChild(noteDisplay);
const freqDisplay = document.createElement('div');
freqDisplay.className = 'text-xs text-gray-500 mb-3 font-mono text-center w-full';
freqDisplay.textContent = '0.0 Hz';
container.appendChild(freqDisplay);
const gaugeEl = document.createElement('div');
gaugeEl.className = 'w-full h-2.5 bg-dark-900 border border-gray-800 rounded-full relative overflow-hidden mb-1.5';
const centerMarker = document.createElement('div');
centerMarker.className = 'absolute left-1/2 top-0 bottom-0 w-0.5 bg-accent z-10';
gaugeEl.appendChild(centerMarker);
const gaugeNeedle = document.createElement('div');
gaugeNeedle.className = 'absolute left-1/2 top-0 bottom-0 w-1 bg-white transition-all duration-100 ease-out -translate-x-1/2 z-20 shadow-[0_0_8px_rgba(255,255,255,0.5)]';
gaugeEl.appendChild(gaugeNeedle);
container.appendChild(gaugeEl);
const centsDisplay = document.createElement('div');
centsDisplay.className = 'text-sm font-bold tracking-tight text-center w-full';
centsDisplay.textContent = '0 cents';
container.appendChild(centsDisplay);
// ── Public API ────────────────────────────────────────────────────
function update(note, cents, freq) {
if (note === null) {
noteText.textContent = '--';
noteText.className = 'text-5xl font-black text-white';
freqDisplay.textContent = '0.0 Hz';
centsDisplay.textContent = '0 cents';
gaugeNeedle.style.left = '50%';
gaugeNeedle.className = 'absolute left-1/2 top-0 bottom-0 w-1 bg-white transition-all duration-100 ease-out -translate-x-1/2 z-20 shadow-[0_0_8px_rgba(255,255,255,0.5)]';
return;
}
noteText.textContent = note;
noteText.className = 'text-5xl font-black ' + (Math.abs(cents) < 5 ? 'text-green-400' : 'text-white');
freqDisplay.textContent = freq.toFixed(1) + ' Hz';
centsDisplay.textContent = (cents > 0 ? '+' : '') + cents.toFixed(0) + ' cents';
const gaugeRange = 50;
const percent = Math.max(0, Math.min(100, 50 + (cents / gaugeRange) * 50));
gaugeNeedle.style.left = percent + '%';
if (Math.abs(cents) < 5) {
gaugeNeedle.className = 'absolute top-0 bottom-0 w-1 bg-green-400 transition-all duration-100 ease-out -translate-x-1/2 z-20 shadow-[0_0_8px_rgba(74,222,128,0.5)]';
} else {
gaugeNeedle.className = 'absolute top-0 bottom-0 w-1 bg-white transition-all duration-100 ease-out -translate-x-1/2 z-20 shadow-[0_0_8px_rgba(255,255,255,0.5)]';
}
}
function destroy() {
noteDisplay.remove();
freqDisplay.remove();
gaugeEl.remove();
centsDisplay.remove();
}
return { update, destroy };
};
+452
View File
@@ -0,0 +1,452 @@
/**
* Mace Fx III style tuner visualization for the Slopsmith tuner plugin.
*
* Inspired by hardware rack tuner displays:
* - Dark navy LCD background
* - Horizontal chromatic tick-mark gauge (top)
* - Inward-pointing directional arrows (▶ ◀) below gauge
* - Large note name (lower-left) and octave number (lower-right)
* - Orange dashed strobe circle (bottom centre)
* - Mode tabs Free / Auto / Manual (top-right)
*
* Contract: window['_tunerViz_mace-fx-iii'](container) → { update(note, cents, freq, mode), destroy() }
* - note: string | null (null = no signal)
* - cents: number (deviation from target, 50…+50)
* - freq: number (detected frequency in Hz)
* - mode: 'free' | 'auto' | 'manual' (tuning mode from screen.js)
*/
(function () {
'use strict';
// ── Constants ─────────────────────────────────────────────────────
var _TUNER_TICK_COUNT = 11;
var _TUNER_STROBE_N = 4; // segments fitting in 180° (plus one trailing gap)
var _TUNER_STROBE_R = 38; // radius in SVG units (full circle, fits in 120×120 viewBox)
var _TUNER_IN_TUNE_THR = 2; // cents threshold for in-tune state
var _TUNER_ARROW_THR = 3; // cents threshold for arrow direction
var _SVG_NS = 'http://www.w3.org/2000/svg';
// ── Colours (custom palette; no Tailwind token equivalents) ──────
var _COL_BG = '#0e0e0e'; // dark gray background
var _COL_TICK = '#7ad400'; // yellow-green gauge ticks
var _COL_MARKER = '#ffffff'; // white pitch-position marker
var _COL_NOTE = '#ffffff'; // white note/octave text
var _COL_ARROW_WH = '#e8e8e8'; // lit arrow colour
var _COL_ARROW_DIM = '#1e3030'; // dimmed arrow colour
var _COL_STROBE = '#e87020'; // orange strobe circle
var _COL_TAB_ACT_BG = '#505868'; // active tab background (slate-gray)
var _COL_TAB_ACT_FG = '#ffffff'; // active tab text
var _COL_TAB_DIM = '#506080'; // inactive tab text
window['_tunerViz_mace-fx-iii'] = function (container) {
'use strict';
// ── Root panel ────────────────────────────────────────────────
var panel = document.createElement('div');
panel.className = 'relative w-full overflow-hidden font-mono select-none';
panel.style.backgroundColor = _COL_BG;
panel.style.aspectRatio = '16 / 9';
panel.style.minHeight = '120px';
// ── Mode tabs (full-width bar, ~12.5% height) ────────────────
var tabsWrap = document.createElement('div');
tabsWrap.style.position = 'absolute';
tabsWrap.style.top = '0';
tabsWrap.style.left = '0';
tabsWrap.style.right = '0';
tabsWrap.style.height = '12.5%';
tabsWrap.style.display = 'flex';
tabsWrap.style.alignItems = 'flex-end';
tabsWrap.style.justifyContent = 'flex-end';
tabsWrap.style.borderBottom = '2px solid ' + _COL_TAB_ACT_BG;
tabsWrap.style.zIndex = '10';
var _tabNames = ['Free', 'Auto', 'Manual'];
var _tabEls = _tabNames.map(function (name) {
var tab = document.createElement('span');
tab.className = 'px-2 py-px text-xs leading-none';
tab.style.borderRadius = '2px 2px 0 0';
tab.style.cursor = 'default';
tab.textContent = name;
tabsWrap.appendChild(tab);
return tab;
});
panel.appendChild(tabsWrap);
// ── Gauge + arrows zone (25%→50% from top) ───────────────────
var gaugeZone = document.createElement('div');
gaugeZone.style.position = 'absolute';
gaugeZone.style.top = '25%';
gaugeZone.style.left = '0';
gaugeZone.style.right = '0';
gaugeZone.style.height = '25%';
gaugeZone.style.display = 'flex';
gaugeZone.style.flexDirection = 'column';
gaugeZone.style.justifyContent = 'center';
gaugeZone.style.zIndex = '5';
// Chromatic gauge — ends at 12.5% from each edge
var gaugeOuter = document.createElement('div');
gaugeOuter.style.position = 'relative';
gaugeOuter.style.marginLeft = '12.5%';
gaugeOuter.style.marginRight = '12.5%';
gaugeOuter.style.flexShrink = '0';
var gaugeBg = document.createElement('div');
gaugeBg.style.position = 'absolute';
gaugeBg.style.top = '22.5%'; // (100% - 55%) / 2, matches flex items-center
gaugeBg.style.left = '0';
gaugeBg.style.right = '0';
gaugeBg.style.height = '55%';
gaugeBg.style.backgroundColor = 'rgba(0,60,20,0.7)';
gaugeBg.style.borderRadius = '2px';
gaugeOuter.appendChild(gaugeBg);
var gaugeWrap = document.createElement('div');
gaugeWrap.className = 'relative flex items-center justify-between';
gaugeWrap.style.height = '1.4em';
var _tickEls = [];
for (var i = 0; i < _TUNER_TICK_COUNT; i++) {
var isCentre = (i === Math.floor(_TUNER_TICK_COUNT / 2));
var tick = document.createElement('div');
tick.style.width = '2px';
tick.style.height = isCentre ? '100%' : '55%';
tick.style.backgroundColor = _COL_TICK;
tick.style.borderRadius = '1px';
tick.style.flexShrink = '0';
tick.style.filter = 'drop-shadow(0 0 4px rgba(122,212,0,0.35))';
gaugeWrap.appendChild(tick);
_tickEls.push(tick);
}
var marker = document.createElement('div');
marker.style.position = 'absolute';
marker.style.top = '0';
marker.style.bottom = '0';
marker.style.width = '3px';
marker.style.backgroundColor = _COL_MARKER;
marker.style.left = '50%';
marker.style.transform = 'translateX(-50%)';
marker.style.display = 'none';
marker.style.zIndex = '6';
marker.style.boxShadow = '0 0 6px 1px rgba(255,255,255,0.6)';
gaugeWrap.appendChild(marker);
gaugeOuter.appendChild(gaugeWrap);
// Spacer: 1/3 of regular tick height (1/3 * 55% * 1.4em ≈ 0.257em)
var gaugeArrowGap = document.createElement('div');
gaugeArrowGap.style.height = '0.257em';
gaugeArrowGap.style.flexShrink = '0';
// Direction arrows SVG — outer edges at ±10¢, gap 15% (~3¢)
var arrowSvg = document.createElementNS(_SVG_NS, 'svg');
arrowSvg.setAttribute('viewBox', '0 0 100 10');
arrowSvg.setAttribute('preserveAspectRatio', 'none');
arrowSvg.style.alignSelf = 'center';
arrowSvg.style.width = '15%';
arrowSvg.style.height = '0.77rem';
arrowSvg.style.flexShrink = '0';
arrowSvg.style.overflow = 'visible';
// SVG glow filter — applied per-polygon so only lit arrows glow
var _arrowGlowId = 'arrow-glow-' + Math.random().toString(36).slice(2, 8);
var _arrowDefs = document.createElementNS(_SVG_NS, 'defs');
var _arrowFilter = document.createElementNS(_SVG_NS, 'filter');
_arrowFilter.setAttribute('id', _arrowGlowId);
_arrowFilter.setAttribute('x', '-80%'); _arrowFilter.setAttribute('y', '-80%');
_arrowFilter.setAttribute('width', '260%'); _arrowFilter.setAttribute('height', '260%');
var _fBlur = document.createElementNS(_SVG_NS, 'feGaussianBlur');
_fBlur.setAttribute('stdDeviation', '1.2'); _fBlur.setAttribute('result', 'blur');
var _fFlood = document.createElementNS(_SVG_NS, 'feFlood');
_fFlood.setAttribute('flood-color', 'white'); _fFlood.setAttribute('flood-opacity', '0.5'); _fFlood.setAttribute('result', 'col');
var _fComp = document.createElementNS(_SVG_NS, 'feComposite');
_fComp.setAttribute('in', 'col'); _fComp.setAttribute('in2', 'blur'); _fComp.setAttribute('operator', 'in'); _fComp.setAttribute('result', 'glow');
var _fMerge = document.createElementNS(_SVG_NS, 'feMerge');
[['glow'], ['SourceGraphic']].forEach(function (n) {
var mn = document.createElementNS(_SVG_NS, 'feMergeNode'); mn.setAttribute('in', n[0]); _fMerge.appendChild(mn);
});
_arrowFilter.appendChild(_fBlur); _arrowFilter.appendChild(_fFlood);
_arrowFilter.appendChild(_fComp); _arrowFilter.appendChild(_fMerge);
_arrowDefs.appendChild(_arrowFilter);
arrowSvg.appendChild(_arrowDefs);
var arrowLPoly = document.createElementNS(_SVG_NS, 'polygon');
arrowLPoly.setAttribute('points', '0,0 0,10 42.5,5');
arrowLPoly.setAttribute('fill', _COL_ARROW_DIM);
var arrowRPoly = document.createElementNS(_SVG_NS, 'polygon');
arrowRPoly.setAttribute('points', '100,0 100,10 57.5,5');
arrowRPoly.setAttribute('fill', _COL_ARROW_DIM);
arrowSvg.appendChild(arrowLPoly);
arrowSvg.appendChild(arrowRPoly);
// Order: arrows → spacer → gauge (arrows on top, gauge underneath)
gaugeZone.appendChild(arrowSvg);
gaugeZone.appendChild(gaugeArrowGap);
gaugeZone.appendChild(gaugeOuter);
panel.appendChild(gaugeZone);
var arrowL = arrowLPoly;
var arrowR = arrowRPoly;
var _arrowGlowUrl = 'url(#' + _arrowGlowId + ')';
// ── Note name display ─────────────────────────────────────────
// Horizontal: center of note letter at 12.5% from left.
// font-size set on wrapper so `ch` resolves to the note character width.
// noteLetter is width:1ch so the accidental never shifts the F position.
var noteWrap = document.createElement('div');
noteWrap.style.position = 'absolute';
noteWrap.style.left = 'calc(12.5% - 0.5ch)';
noteWrap.style.top = '67%';
noteWrap.style.transform = 'translateY(-50%)';
noteWrap.style.height = '25%';
noteWrap.style.display = 'flex';
noteWrap.style.alignItems = 'center';
noteWrap.style.fontSize = '3.2rem';
noteWrap.style.color = _COL_NOTE;
noteWrap.style.zIndex = '5';
noteWrap.style.overflow = 'visible';
noteWrap.style.textShadow = '0 0 5px rgba(255,255,255,0.4)';
var noteLetter = document.createElement('span');
noteLetter.style.display = 'inline-block';
noteLetter.style.width = '1ch';
noteLetter.style.flexShrink = '0';
noteLetter.style.fontWeight = '700';
noteLetter.style.lineHeight = '1';
noteLetter.textContent = '-';
var noteAccidental = document.createElement('span');
noteAccidental.style.fontSize = '1.7rem';
noteAccidental.style.fontWeight = '700';
noteAccidental.style.lineHeight = '1';
noteAccidental.style.alignSelf = 'flex-start';
noteAccidental.style.marginTop = '0.15em';
noteAccidental.textContent = '';
noteWrap.appendChild(noteLetter);
noteWrap.appendChild(noteAccidental);
panel.appendChild(noteWrap);
// ── Octave display ────────────────────────────────────────────
// Center of digit at 12.5% from right; width:1ch pins the element size.
var octaveEl = document.createElement('div');
octaveEl.style.position = 'absolute';
octaveEl.style.right = 'calc(12.5% - 0.5ch)';
octaveEl.style.top = '67%';
octaveEl.style.transform = 'translateY(-50%)';
octaveEl.style.height = '25%';
octaveEl.style.width = '1ch';
octaveEl.style.display = 'flex';
octaveEl.style.alignItems = 'center';
octaveEl.style.fontSize = '3.2rem';
octaveEl.style.fontWeight = '700';
octaveEl.style.lineHeight = '1';
octaveEl.style.color = _COL_NOTE;
octaveEl.style.zIndex = '5';
octaveEl.style.textShadow = '0 0 5px rgba(255,255,255,0.4)';
octaveEl.textContent = '-';
panel.appendChild(octaveEl);
// ── Strobe circle SVG (bottom-centre) ────────────────────────
// Full dashed circle, centered in the SVG viewBox so no part is clipped.
// gap = (2/3)*dash; using full circumference for dash calculation.
var _sVB_W = 120, _sVB_H = 120;
var _scx = 60, _scy = 60;
var _halfCirc = 2 * Math.PI * _TUNER_STROBE_R; // full circumference
var _dashLen = 3 * _halfCirc / 20;
var _gapLen = (2 / 3) * _dashLen;
var strobeSvg = document.createElementNS(_SVG_NS, 'svg');
strobeSvg.setAttribute('viewBox', '0 0 ' + _sVB_W + ' ' + _sVB_H);
strobeSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
strobeSvg.setAttribute('class', 'absolute');
strobeSvg.style.bottom = '4%';
strobeSvg.style.left = '50%';
strobeSvg.style.transform = 'translateX(-50%)';
strobeSvg.style.width = '22%';
strobeSvg.style.overflow = 'visible';
strobeSvg.style.zIndex = '4';
// SVG glow filter for arc — avoids CSS filter viewport clipping
var _strobeGlowId = 'strobe-glow-' + Math.random().toString(36).slice(2, 8);
var _strobeDefs = document.createElementNS(_SVG_NS, 'defs');
var _strobeFilter = document.createElementNS(_SVG_NS, 'filter');
_strobeFilter.setAttribute('id', _strobeGlowId);
_strobeFilter.setAttribute('x', '-30%'); _strobeFilter.setAttribute('y', '-30%');
_strobeFilter.setAttribute('width', '160%'); _strobeFilter.setAttribute('height', '160%');
var _sfBlur = document.createElementNS(_SVG_NS, 'feGaussianBlur');
_sfBlur.setAttribute('stdDeviation', '2'); _sfBlur.setAttribute('result', 'blur');
var _sfFlood = document.createElementNS(_SVG_NS, 'feFlood');
_sfFlood.setAttribute('flood-color', _COL_STROBE); _sfFlood.setAttribute('flood-opacity', '0.45'); _sfFlood.setAttribute('result', 'col');
var _sfComp = document.createElementNS(_SVG_NS, 'feComposite');
_sfComp.setAttribute('in', 'col'); _sfComp.setAttribute('in2', 'blur'); _sfComp.setAttribute('operator', 'in'); _sfComp.setAttribute('result', 'glow');
var _sfMerge = document.createElementNS(_SVG_NS, 'feMerge');
[['glow'], ['SourceGraphic']].forEach(function (n) {
var mn = document.createElementNS(_SVG_NS, 'feMergeNode'); mn.setAttribute('in', n[0]); _sfMerge.appendChild(mn);
});
_strobeFilter.appendChild(_sfBlur); _strobeFilter.appendChild(_sfFlood);
_strobeFilter.appendChild(_sfComp); _strobeFilter.appendChild(_sfMerge);
_strobeDefs.appendChild(_strobeFilter);
strobeSvg.appendChild(_strobeDefs);
// Full dashed circle — <circle> supports stroke-dashoffset identically to <path>
var arcPath = document.createElementNS(_SVG_NS, 'circle');
arcPath.setAttribute('cx', String(_scx));
arcPath.setAttribute('cy', String(_scy));
arcPath.setAttribute('r', String(_TUNER_STROBE_R));
arcPath.setAttribute('fill', 'none');
arcPath.setAttribute('stroke', _COL_STROBE);
arcPath.setAttribute('stroke-width', String(_dashLen));
arcPath.setAttribute('stroke-dasharray', _dashLen + ' ' + _gapLen);
arcPath.setAttribute('stroke-linecap', 'butt');
arcPath.setAttribute('filter', 'url(#' + _strobeGlowId + ')');
strobeSvg.appendChild(arcPath);
panel.appendChild(strobeSvg);
// ── LCD grid overlay ──────────────────────────────────────────
// Spans everything below the tab bar (top: 12.5%), 3px cell, bg colour lines.
var lcdGrid = document.createElement('div');
lcdGrid.style.position = 'absolute';
lcdGrid.style.top = '12.5%';
lcdGrid.style.left = '0';
lcdGrid.style.right = '0';
lcdGrid.style.bottom = '0';
lcdGrid.style.zIndex = '50';
lcdGrid.style.pointerEvents = 'none';
lcdGrid.style.backgroundImage = [
'repeating-linear-gradient(0deg, rgba(14,14,14,0.33) 0px, rgba(14,14,14,0.33) 1px, transparent 1px, transparent 2px)',
'repeating-linear-gradient(90deg, rgba(14,14,14,0.33) 0px, rgba(14,14,14,0.33) 1px, transparent 1px, transparent 2px)'
].join(',');
lcdGrid.style.backgroundPosition = '12.5% 0';
panel.appendChild(lcdGrid);
container.appendChild(panel);
// ── Internal state ────────────────────────────────────────────
var _rafId = null;
var _currentMode = 'free';
var _strobeOffset = 0; // stroke-dashoffset accumulator (SVG length units)
var _currentCents = 0; // target cents (0 when no signal)
var _smoothedCents = 0; // lerped cents — drives speed, decays to 0 on stop
var _lastTime = null;
var _totalDash = _dashLen + _gapLen; // one dash-cycle period
// ── Strobe RAF animation loop ─────────────────────────────────
// _smoothedCents lerps toward _currentCents every frame (mirrors strobe.js).
// When signal stops, _currentCents = 0 → _smoothedCents decays → speed → 0.
// The strobe always decelerates smoothly rather than snapping to a freeze.
function _animateStrobe(now) {
if (_lastTime === null) { _lastTime = now; }
var dt = Math.min((now - _lastTime) / 1000, 0.1);
_lastTime = now;
var lerpFactor = 1 - Math.exp(-10 * dt);
_smoothedCents += (_currentCents - _smoothedCents) * lerpFactor;
if (Math.abs(_smoothedCents) > 0.1) {
var absCents = Math.min(50, Math.abs(_smoothedCents));
var normalized = Math.max(0, absCents - _TUNER_IN_TUNE_THR) / (50 - _TUNER_IN_TUNE_THR);
var speed = _halfCirc * Math.pow(normalized, 0.9);
if (_smoothedCents > 0) { speed = -speed; }
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
}
_rafId = requestAnimationFrame(_animateStrobe);
}
_rafId = requestAnimationFrame(_animateStrobe);
// ── Helper: derive octave number from frequency ───────────────
function _freqToOctave(freq) {
if (!freq || freq <= 0) return '-';
var midi = Math.round(69 + 12 * Math.log2(freq / 440));
return String(Math.floor(midi / 12) - 1);
}
// ── Helper: update mode tab highlights ────────────────────────
function _updateTabs(mode) {
var map = { free: 0, auto: 1, manual: 2 };
var active = (map[mode] !== undefined) ? map[mode] : 0;
_tabEls.forEach(function (tab, i) {
if (i === active) {
tab.style.backgroundColor = _COL_TAB_ACT_BG;
tab.style.color = _COL_TAB_ACT_FG;
} else {
tab.style.backgroundColor = 'transparent';
tab.style.color = _COL_TAB_DIM;
}
});
}
// Initialise tabs
_updateTabs('free');
// ── Public: update ────────────────────────────────────────────
function update(note, cents, freq, mode, targetFreq) {
var hasNote = (note !== null && note !== undefined);
// Mode tabs
if (mode !== undefined) { _currentMode = mode; }
_updateTabs(_currentMode);
// Gauge marker — clamp cents to [-50,50] so marker stays within gauge bounds
if (hasNote) {
marker.style.left = Math.max(0, Math.min(100, cents + 50)) + '%';
marker.style.display = 'block';
} else {
marker.style.display = 'none';
}
// Direction arrows — use setAttribute('filter','none') not removeAttribute
// so the filter is explicitly cleared on every dim transition
if (!hasNote) {
arrowL.setAttribute('fill', _COL_ARROW_DIM); arrowL.setAttribute('filter', 'none');
arrowR.setAttribute('fill', _COL_ARROW_DIM); arrowR.setAttribute('filter', 'none');
} else if (cents <= -_TUNER_ARROW_THR) {
arrowL.setAttribute('fill', _COL_ARROW_WH); arrowL.setAttribute('filter', _arrowGlowUrl);
arrowR.setAttribute('fill', _COL_ARROW_DIM); arrowR.setAttribute('filter', 'none');
} else if (cents >= _TUNER_ARROW_THR) {
arrowL.setAttribute('fill', _COL_ARROW_DIM); arrowL.setAttribute('filter', 'none');
arrowR.setAttribute('fill', _COL_ARROW_WH); arrowR.setAttribute('filter', _arrowGlowUrl);
} else {
arrowL.setAttribute('fill', _COL_ARROW_WH); arrowL.setAttribute('filter', _arrowGlowUrl);
arrowR.setAttribute('fill', _COL_ARROW_WH); arrowR.setAttribute('filter', _arrowGlowUrl);
}
// Note display
if (hasNote) {
noteLetter.textContent = note.charAt(0);
noteAccidental.textContent = note.slice(1);
} else {
noteLetter.textContent = '-';
noteAccidental.textContent = '';
}
// Octave display — show target octave in auto/manual, detected octave in free
if (hasNote) {
if ((_currentMode === 'auto' || _currentMode === 'manual') && targetFreq) {
octaveEl.textContent = _freqToOctave(targetFreq);
} else {
octaveEl.textContent = _freqToOctave(freq);
}
} else {
octaveEl.textContent = '-';
}
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
_currentCents = hasNote ? cents : 0;
}
// ── Public: destroy ───────────────────────────────────────────
function destroy() {
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
panel.remove();
}
return { update: update, destroy: destroy };
};
})();
+496
View File
@@ -0,0 +1,496 @@
(function () {
'use strict';
// ── Constants ─────────────────────────────────────────────────────
var _TUNER_PT_IN_TUNE_THR = 2;
var _TUNER_PT_LED_COUNT = 11;
var _TUNER_PT_CENTS_RANGE = 50;
// Display colours
var _TUNER_PT_LIT = '#ff2200';
var _TUNER_PT_UNLIT = '#1a0000';
var _TUNER_PT_BG = '#0d0000';
// ── 8-segment map ─────────────────────────────────────────────────
// Segments indexed: [a, b, c, d, e, f, g1, g2]
var _TUNER_PT_SEGMENTS = {
// a b c d e f g1 g2
'A': [ true, true, true, false, true, true, true, true ],
'B': [ false, false, true, true, true, true, true, true ],
'C': [ true, false, false, true, true, true, false, false ],
'D': [ false, true, true, true, true, false, true, true ],
'E': [ true, false, false, true, true, true, true, false ],
'F': [ true, false, false, false, true, true, true, false ],
'G': [ true, false, true, true, true, true, false, true ],
' ': [ false, false, false, false, false, false, false, false ],
};
// Instance counter for unique SVG gradient IDs
var _ppTinyCount = 0;
window['_tunerViz_pp-tiny'] = function (container) {
'use strict';
// ── SVG frame ─────────────────────────────────────────────────
// Shape: semi-circle (r = W/2) + rectangle (h = W/4)
// Total height = W/2 + W/4 = 3W/4 → aspect-ratio 4:3
// viewBox "0 0 100 75" (75 = 3/4 × 100).
// Semi-circle arc: centre (50,50), r=50, from (0,50) to (100,50)
// Rectangle: y 50→75, full width, small rounded bottom corners
// Face inset 4 units on all sides:
// Semi-circle arc: centre (50,50), r=46, from (4,50) to (96,50)
// Rectangle: y 50→71 (754=71)
var _gradId = 'ppTinyFrameGrad' + (++_ppTinyCount);
var panel = document.createElement('div');
panel.style.cssText = 'position:relative;width:100%;aspect-ratio:4/3;user-select:none;';
// SVG draws the gray frame + dark face shape
var frameSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
frameSvg.setAttribute('viewBox', '0 0 100 75');
frameSvg.setAttribute('preserveAspectRatio', 'none');
frameSvg.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;display:block;';
var _brushId = 'ppTinyBrush' + _ppTinyCount;
var _bevelBrushId = 'ppTinyBevelBrush' + _ppTinyCount;
var defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
// Main face: metallic gradient with multiple highlight/shadow bands
var grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
grad.setAttribute('id', _gradId);
grad.setAttribute('x1', '0.2'); grad.setAttribute('y1', '0');
grad.setAttribute('x2', '0.8'); grad.setAttribute('y2', '1');
[['0%','#f2f2f2'],['14%','#d0d0d0'],['30%','#888888'],['46%','#bababa'],['62%','#7e7e7e'],['80%','#c2c2c2'],['100%','#6a6a6a']].forEach(function(s) {
var stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
stop.setAttribute('offset', s[0]); stop.setAttribute('stop-color', s[1]);
grad.appendChild(stop);
});
defs.appendChild(grad);
function _makeBrushFilter(id, freqX, freqY, seed, contrast, base) {
var c = contrast || 0.4, b = base !== undefined ? base : 0.25;
var v = c + ' 0 0 0 ' + b + ' ' + c + ' 0 0 0 ' + b + ' ' + c + ' 0 0 0 ' + b + ' 0 0 0 1 0';
var f = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
f.setAttribute('id', id);
f.setAttribute('color-interpolation-filters', 'sRGB');
var t = document.createElementNS('http://www.w3.org/2000/svg', 'feTurbulence');
t.setAttribute('type', 'fractalNoise'); t.setAttribute('baseFrequency', freqX + ' ' + freqY);
t.setAttribute('numOctaves', '2'); t.setAttribute('seed', seed); t.setAttribute('result', 'noise');
var cm = document.createElementNS('http://www.w3.org/2000/svg', 'feColorMatrix');
cm.setAttribute('type', 'matrix'); cm.setAttribute('in', 'noise');
cm.setAttribute('values', v);
cm.setAttribute('result', 'grayNoise');
var bl = document.createElementNS('http://www.w3.org/2000/svg', 'feBlend');
bl.setAttribute('in', 'SourceGraphic'); bl.setAttribute('in2', 'grayNoise');
bl.setAttribute('mode', 'soft-light'); bl.setAttribute('result', 'blended');
var cp = document.createElementNS('http://www.w3.org/2000/svg', 'feComposite');
cp.setAttribute('in', 'blended'); cp.setAttribute('in2', 'SourceGraphic'); cp.setAttribute('operator', 'in');
f.appendChild(t); f.appendChild(cm); f.appendChild(bl); f.appendChild(cp);
return f;
}
defs.appendChild(_makeBrushFilter(_brushId, '0.65', '0.015', '3', 0.4, 0.25)); // horizontal grain — main arc face
defs.appendChild(_makeBrushFilter(_bevelBrushId, '0.45', '0.015', '7', 0.65, 0.08)); // horizontal grain, lower freq, higher contrast — bevel face
frameSvg.appendChild(defs);
// Main frame — restored with original rounded corners, unchanged
var framePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
framePath.setAttribute('d', 'M 0,50 A 50,50 0 0 1 100,50 L 100,73 Q 100,75 98,75 L 2,75 Q 0,75 0,73 Z');
framePath.setAttribute('fill', 'url(#' + _gradId + ')');
framePath.setAttribute('filter', 'url(#' + _brushId + ')');
frameSvg.appendChild(framePath);
// Bevel trapezoid — sits on top of the main frame, covers only the bottom strip.
// Sides meet the corner curves at their t=0.5 midpoints (de Casteljau):
// Right midpoint: (99.5, 74.5); lower-half bezier: Q 99,75 98,75
// Left midpoint: (0.5, 74.5); lower-half bezier: Q 1,75 0.5,74.5 (path direction reversed)
// 45° sides: Δx=Δy=5.5 each ✓ — top edge y=69, x=6 to x=94
var bevelPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
bevelPath.setAttribute('d', 'M 6,69 L 94,69 L 99.5,74.5 Q 99,75 98,75 L 2,75 Q 1,75 0.5,74.5 Z');
bevelPath.setAttribute('fill', '#c0c0c0');
bevelPath.setAttribute('filter', 'url(#' + _bevelBrushId + ')');
frameSvg.appendChild(bevelPath);
var faceBgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
faceBgPath.setAttribute('d', 'M 4,50 A 46,46 0 0 1 96,50 L 96,70 Q 96,71 95,71 L 5,71 Q 4,71 4,70 Z');
faceBgPath.setAttribute('fill', '#080808');
frameSvg.appendChild(faceBgPath);
panel.appendChild(frameSvg);
// ── Black panel face (content host) ───────────────────────────
// Face occupies inset 4 units in viewBox coords:
// left: 4/100 = 4%, top: 4/75 = 5.333%
// width: 92/100 = 92%, height: 67/75 = 89.333%
var face = document.createElement('div');
face.style.cssText = 'position:absolute;left:4%;top:5.333%;width:92%;height:89.333%;overflow:hidden;';
panel.appendChild(face);
// ── Arc geometry ──────────────────────────────────────────────
// Face SVG inset: x 496, y 471 (width=92, height=67 in panel units).
// Arc centre in panel SVG = (50, 50) → in face-div %:
// cx = (504)/92×100 = 50 %
// cy = (504)/67×100 = 68.657 %
// Face aspect A = 92/67 ≈ 1.3731.
// For a physical circle of radius r (% of face-width):
// x = cx + r·cos(θ) (face-width %)
// y = cy r·A·sin(θ) (face-height %; A corrects non-square face)
// Separator SVG arc (viewBox 0 0 100 100, preserveAspectRatio=none):
// rx = r (x-units ≡ face-width %), ry = r·A (y-units ≡ face-height %)
// Radii (r in % of face_width, max=46):
// LEDs r=40 → top at (50%, 14%)
// line r=35 → top at (50%, 21%)
// labels r=30 → top at (50%, 27%)
var _ARC_CX = 50;
var _ARC_CY = 68.657; // % of face height
var _ARC_ASPECT = 92 / 67; // face width / face height
var _ARC_R_LEDS = 40;
var _ARC_R_LINE = 35;
var _ARC_R_LABELS = 30;
var _ARC_CENTRE_IDX = Math.floor(_TUNER_PT_LED_COUNT / 2); // 5
function _arcPoint(i, r) {
var angleDeg = 180 - i * (180 / (_TUNER_PT_LED_COUNT - 1));
var rad = angleDeg * Math.PI / 180;
return {
x: _ARC_CX + r * Math.cos(rad),
y: _ARC_CY - r * _ARC_ASPECT * Math.sin(rad),
};
}
// ── 1. LED arc ────────────────────────────────────────────────
var leds = [];
for (var i = 0; i < _TUNER_PT_LED_COUNT; i++) {
var pt = _arcPoint(i, _ARC_R_LEDS);
var led = document.createElement('div');
led.style.position = 'absolute';
led.style.left = pt.x.toFixed(2) + '%';
led.style.top = pt.y.toFixed(2) + '%';
led.style.transform = 'translate(-50%, -50%)';
led.style.width = '5%';
led.style.aspectRatio = '1 / 1';
led.style.borderRadius = '50%';
var isCentre = (i === _ARC_CENTRE_IDX);
led.style.background = isCentre
? 'radial-gradient(circle at 35% 35%, #3a0000, #1a0000)'
: 'radial-gradient(circle at 35% 35%, #2a2000, #141000)';
led.style.border = '1px solid ' + (isCentre ? '#400' : '#420');
led.style.boxShadow = 'none';
face.appendChild(led);
leds.push(led);
}
// ── 2. White separator arc (SVG) ──────────────────────────────
// Semicircle from (cxr, cy) to (cx+r, cy) through the top.
// viewBox "0 0 100 100" fills the square face exactly; rx=ry gives a
// true circle. sweep=1 (clockwise in SVG y-down space) draws upward.
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 100 100');
svg.setAttribute('preserveAspectRatio', 'none');
svg.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;overflow:visible';
// SVG viewBox 0 0 100 100, preserveAspectRatio=none:
// x-unit = 1% face-width, y-unit = 1% face-height.
// For a physical circle: rx=r, ry=r×A (corrects non-square face).
var x0Line = _ARC_CX - _ARC_R_LINE;
var x1Line = _ARC_CX + _ARC_R_LINE;
var ryLine = (_ARC_R_LINE * _ARC_ASPECT).toFixed(3);
var arcPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
arcPath.setAttribute('d',
'M ' + x0Line + ',' + _ARC_CY.toFixed(3) +
' A ' + _ARC_R_LINE + ',' + ryLine + ' 0 0 1 ' +
x1Line + ',' + _ARC_CY.toFixed(3));
arcPath.setAttribute('stroke', 'rgba(255,255,255,0.65)');
arcPath.setAttribute('stroke-width', '0.8');
arcPath.setAttribute('fill', 'none');
svg.appendChild(arcPath);
face.appendChild(svg);
// ── 3. Range labels ───────────────────────────────────────────
var labelDefs = [
{ text: '-50', i: 0 },
{ text: '0', i: _ARC_CENTRE_IDX },
{ text: '+50', i: _TUNER_PT_LED_COUNT - 1 },
];
labelDefs.forEach(function (d) {
var pt = _arcPoint(d.i, _ARC_R_LABELS);
var el = document.createElement('div');
el.style.position = 'absolute';
el.style.left = pt.x.toFixed(2) + '%';
el.style.top = pt.y.toFixed(2) + '%';
el.style.transform = 'translate(-50%, -50%)';
el.style.color = '#cccccc';
el.style.fontSize = '50%';
el.style.fontWeight = 'bold';
el.style.fontFamily = 'sans-serif';
el.style.lineHeight = '1';
el.textContent = d.text;
face.appendChild(el);
});
// ── 4. LCD display (letter + # inside one box) ────────────────
// top=50%, height=35% → bottom=85%, centre=67.5%.
var displayWrap = document.createElement('div');
displayWrap.style.cssText = [
'position:absolute',
'left:50%',
'top:50%',
'transform:translateX(-50%)',
'width:23%',
'height:35%',
'background:' + _TUNER_PT_BG,
'border-radius:3px',
'border:1px solid #2a0000',
'display:flex',
'flex-direction:row',
'align-items:center',
'justify-content:center',
'padding:4%',
'box-sizing:border-box',
'box-shadow:inset 0 0 8px #000'
].join(';');
face.appendChild(displayWrap);
// Letter digit (8-segment SVG, viewBox 100×200)
// T=16, G=5, CH=6, mid-gap=3 — thicker segs, uniform 5-unit gaps
// horiz: (xL+CH,y0),(xR-CH,y0),(xR,y0+T/2),(xR-CH,y1),(xL+CH,y1),(xL,y0+T/2)
// vert: (x+T/2,y0),(x+T,y0+CH),(x+T,y1-CH),(x+T/2,y1),(x,y1-CH),(x,y0+CH)
var segSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
segSvg.setAttribute('viewBox', '0 0 100 200');
segSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
segSvg.style.cssText = 'width:55%;aspect-ratio:1/2;flex-shrink:0;overflow:visible;';
displayWrap.appendChild(segSvg);
var segmentEls = {};
function _makeSeg(key, points) {
var el = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
el.setAttribute('points', points);
el.setAttribute('fill', _TUNER_PT_UNLIT);
segSvg.appendChild(el);
segmentEls[key] = el;
}
_makeSeg('a', '11,5 89,5 95,13 89,21 11,21 5,13');
_makeSeg('b', '87,26 95,32 95,81 87,87 79,81 79,32');
_makeSeg('c', '87,113 95,119 95,168 87,174 79,168 79,119');
_makeSeg('d', '11,179 89,179 95,187 89,195 11,195 5,187');
_makeSeg('e', '13,113 21,119 21,168 13,174 5,168 5,119');
_makeSeg('f', '13,26 21,32 21,81 13,87 5,81 5,32');
_makeSeg('g1', '11,92 42.5,92 48.5,100 42.5,108 11,108 5,100');
_makeSeg('g2', '57.5,92 89,92 95,100 89,108 57.5,108 51.5,100');
// "#" symbol — absolute-positioned bottom-right, viewBox 90×90 (symbol fills it)
// T=10, s=(90-20)/3=23.3 → bars and gaps evenly distributed
var sharpSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
sharpSvg.setAttribute('viewBox', '0 0 90 90');
sharpSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
sharpSvg.style.cssText = 'position:absolute;top:54%;right:6%;width:22%;aspect-ratio:1/1;overflow:visible;pointer-events:none;';
displayWrap.appendChild(sharpSvg);
var sharpParts = [];
function _makeSharpPoly(points) {
var el = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
el.setAttribute('points', points);
el.setAttribute('fill', _TUNER_PT_UNLIT);
sharpSvg.appendChild(el);
sharpParts.push(el);
}
// left vert, right vert, top horiz, bottom horiz — all 90 units, T=10, CH=4, s=23.3
_makeSharpPoly('28.3,0 33.3,4 33.3,86 28.3,90 23.3,86 23.3,4');
_makeSharpPoly('61.7,0 66.7,4 66.7,86 61.7,90 56.7,86 56.7,4');
_makeSharpPoly('4,23.3 86,23.3 90,28.3 86,33.3 4,33.3 0,28.3');
_makeSharpPoly('4,56.7 86,56.7 90,61.7 86,66.7 4,66.7 0,61.7');
// "♭" symbol — same position as sharpSvg, shown in place of it for flat notes
var flatSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
flatSvg.setAttribute('viewBox', '0 0 90 90');
flatSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
flatSvg.style.cssText = 'position:absolute;top:54%;right:6%;width:22%;aspect-ratio:1/1;overflow:visible;pointer-events:none;display:none;';
displayWrap.appendChild(flatSvg);
var flatText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
flatText.setAttribute('x', '45');
flatText.setAttribute('y', '82');
flatText.setAttribute('text-anchor', 'middle');
flatText.setAttribute('font-size', '85');
flatText.setAttribute('font-family', 'Georgia, serif');
flatText.setAttribute('fill', _TUNER_PT_UNLIT);
flatText.textContent = '♭';
flatSvg.appendChild(flatText);
// ── 5. AUTO LED (lit when mode is 'free' or 'auto') ──────────
// Anchored to the display's right edge (≈69%) at the display's
// vertical midpoint (54% + 20% = 74%).
var autoWrap = document.createElement('div');
autoWrap.style.cssText = 'position:absolute;left:85%;top:92%;transform:translateY(-50%);display:flex;flex-direction:column;align-items:center;gap:4%;pointer-events:none';
var autoLed = document.createElement('div');
autoLed.style.cssText = [
'width:6%',
'aspect-ratio:1/1',
'border-radius:50%',
'background:radial-gradient(circle at 35% 35%, #3a0000, #1a0000)',
'box-shadow:none',
'border:1px solid #400',
'flex-shrink:0'
].join(';');
var autoLabel = document.createElement('span');
autoLabel.style.cssText = 'color:#cccccc;font-size:52%;font-weight:bold;letter-spacing:0.05em;font-family:sans-serif;';
autoLabel.textContent = 'AUTO';
autoWrap.appendChild(autoLed);
autoWrap.appendChild(autoLabel);
face.appendChild(autoWrap);
// ── Brand label ───────────────────────────────────────────────
var brandLabel = document.createElement('div');
brandLabel.style.cssText = [
'position:absolute',
'bottom:4%',
'left:50%',
'transform:translateX(-50%)',
'color:#cccccc',
'font-size:60%',
'font-weight:bold',
'letter-spacing:0.1em',
'font-family:sans-serif',
'pointer-events:none'
].join(';');
brandLabel.textContent = 'PP-Tiny';
face.appendChild(brandLabel);
container.appendChild(panel);
// ── Helpers ───────────────────────────────────────────────────
function _setLed(index, lit) {
var led = leds[index];
var isCentre = (index === _ARC_CENTRE_IDX);
if (lit) {
if (isCentre) {
led.style.background = 'radial-gradient(circle at 35% 35%, #ff6644, #cc1100)';
led.style.boxShadow = '0 0 5px 2px #ff3300, 0 0 10px 4px #aa1100';
led.style.border = '1px solid #ff4400';
} else {
led.style.background = 'radial-gradient(circle at 35% 35%, #ffee66, #cc9900)';
led.style.boxShadow = '0 0 5px 2px #ffcc00, 0 0 10px 4px #aa8800';
led.style.border = '1px solid #ffbb00';
}
} else {
if (isCentre) {
led.style.background = 'radial-gradient(circle at 35% 35%, #3a0000, #1a0000)';
led.style.boxShadow = 'none';
led.style.border = '1px solid #400';
} else {
led.style.background = 'radial-gradient(circle at 35% 35%, #2a2000, #141000)';
led.style.boxShadow = 'none';
led.style.border = '1px solid #420';
}
}
}
function _updateLeds(cents, hasSignal) {
if (!hasSignal) {
for (var i = 0; i < _TUNER_PT_LED_COUNT; i++) _setLed(i, false);
return;
}
var c = Math.max(-_TUNER_PT_CENTS_RANGE, Math.min(_TUNER_PT_CENTS_RANGE, cents));
var targetIdx = _ARC_CENTRE_IDX + Math.round(c / 10);
targetIdx = Math.max(0, Math.min(_TUNER_PT_LED_COUNT - 1, targetIdx));
for (var j = 0; j < _TUNER_PT_LED_COUNT; j++) {
var lit;
if (c >= 0) {
lit = (j >= _ARC_CENTRE_IDX && j <= targetIdx);
} else {
lit = (j <= _ARC_CENTRE_IDX && j >= targetIdx);
}
_setLed(j, lit);
}
}
var _segKeys = ['a', 'b', 'c', 'd', 'e', 'f', 'g1', 'g2'];
function _setSegment(segEl, lit) {
segEl.setAttribute('fill', lit ? _TUNER_PT_LIT : _TUNER_PT_UNLIT);
segEl.style.filter = lit ? 'drop-shadow(0 0 2px #ff4400) drop-shadow(0 0 5px #cc1100)' : 'none';
}
function _renderNote(letter) {
var map = _TUNER_PT_SEGMENTS[letter ? letter.toUpperCase() : ' '] || _TUNER_PT_SEGMENTS[' '];
for (var k = 0; k < _segKeys.length; k++) {
_setSegment(segmentEls[_segKeys[k]], map[k]);
}
}
function _setSharp(lit) {
var fill = lit ? _TUNER_PT_LIT : _TUNER_PT_UNLIT;
var filter = lit ? 'drop-shadow(0 0 2px #ff4400) drop-shadow(0 0 5px #cc1100)' : 'none';
for (var si = 0; si < sharpParts.length; si++) {
sharpParts[si].setAttribute('fill', fill);
sharpParts[si].style.filter = filter;
}
}
function _setFlat(lit) {
flatSvg.style.display = lit ? '' : 'none';
if (lit) {
flatText.setAttribute('fill', _TUNER_PT_LIT);
flatText.style.filter = 'drop-shadow(0 0 2px #ff4400) drop-shadow(0 0 5px #cc1100)';
}
}
function _setAuto(mode) {
var lit = (mode === 'free' || mode === 'auto');
autoLed.style.background = lit
? 'radial-gradient(circle at 35% 35%, #ff6644, #cc1100)'
: 'radial-gradient(circle at 35% 35%, #3a0000, #1a0000)';
autoLed.style.boxShadow = lit ? '0 0 5px 1px #ff3300, 0 0 10px 2px #aa1100' : 'none';
autoLed.style.border = lit ? '1px solid #ff2200' : '1px solid #400';
}
// ── Public API ────────────────────────────────────────────────
function update(note, cents, freq, mode) {
_setAuto(mode);
if (note === null) {
_updateLeds(0, false);
_renderNote(' ');
sharpSvg.style.display = '';
_setSharp(false);
_setFlat(false);
return;
}
var letter = note[0];
var acc = note.length > 1 ? note[1] : '';
_updateLeds(cents, true);
_renderNote(letter);
if (acc === '#') {
sharpSvg.style.display = '';
_setSharp(true);
_setFlat(false);
} else if (acc === 'b') {
sharpSvg.style.display = 'none';
_setFlat(true);
} else {
sharpSvg.style.display = '';
_setSharp(false);
_setFlat(false);
}
}
function destroy() {
panel.remove();
}
return { update: update, destroy: destroy };
};
}());
+199
View File
@@ -0,0 +1,199 @@
/**
* Strobe tuner visualization for the Slopsmith tuner plugin.
*
* Contract: window._tunerViz_strobe(container) → { update(note, cents, freq), destroy() }
* - note: string | null (null = no signal)
* - cents: number (deviation from target, 50…+50)
* - freq: number (detected frequency in Hz)
*/
window._tunerViz_strobe = function (container) {
'use strict';
// ── LCD segment map ───────────────────────────────────────────────
const _SEGMENT_MAP = {
'A': [1,1,1,1,0,0,1,1,1,1,0,0,0,0,0,0],
'B': [1,1,1,1,1,1,0,0,0,1,1,1,0,0,0,0],
'C': [1,1,0,0,1,1,1,1,0,0,0,0,0,0,0,0],
'D': [1,1,1,1,1,1,0,0,0,0,1,1,0,0,0,0],
'E': [1,1,0,0,1,1,1,1,1,0,0,0,0,0,0,0],
'F': [1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0],
'G': [1,1,0,1,1,1,1,1,0,1,0,0,0,0,0,0],
'-': [0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0],
' ': [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
};
// Subtle glow for lit LCD elements; currentColor matches each element's own color
const _LIT_GLOW = 'drop-shadow(0 0 4px currentColor)';
function _createLCDDigit() {
const digit = document.createElement('div');
digit.className = 'segment-digit relative w-12 h-16 flex-shrink-0';
// Glow rides on the rendered segments; unlit ones (opacity 0.05) stay dark
digit.style.filter = _LIT_GLOW;
const seg = 'absolute bg-current transition-opacity duration-150 rounded-sm';
digit.innerHTML = `
<div class="${seg} top-0 left-0.5 w-[calc(50%-1px)] h-2 rounded-tl-md" data-seg="0"></div>
<div class="${seg} top-0 right-0.5 w-[calc(50%-1px)] h-2 rounded-tr-md" data-seg="1"></div>
<div class="${seg} bottom-0 right-0.5 w-[calc(50%-1px)] h-2 rounded-br-md" data-seg="4"></div>
<div class="${seg} bottom-0 left-0.5 w-[calc(50%-1px)] h-2 rounded-bl-md" data-seg="5"></div>
<div class="${seg} top-1 left-0 w-2 h-[calc(50%-1.5px)]" data-seg="7"></div>
<div class="${seg} bottom-1 left-0 w-2 h-[calc(50%-1.5px)]" data-seg="6"></div>
<div class="${seg} top-1 right-0 w-2 h-[calc(50%-1.5px)]" data-seg="2"></div>
<div class="${seg} bottom-1 right-0 w-2 h-[calc(50%-1.5px)]" data-seg="3"></div>
<div class="${seg} top-1/2 left-1.5 w-[calc(50%-2px)] h-2 -translate-y-1/2" data-seg="8"></div>
<div class="${seg} top-1/2 right-1.5 w-[calc(50%-2px)] h-2 -translate-y-1/2" data-seg="9"></div>
<div class="${seg} top-1.5 left-1/2 w-2 h-[calc(50%-2.5px)] -translate-x-1/2" data-seg="10"></div>
<div class="${seg} bottom-1.5 left-1/2 w-2 h-[calc(50%-2.5px)] -translate-x-1/2" data-seg="11"></div>
<svg class="absolute inset-0 w-full h-full pointer-events-none overflow-visible" viewBox="0 0 48 64">
<line x1="10" y1="10" x2="22" y2="30" stroke="currentColor" stroke-width="6" stroke-linecap="round" class="transition-opacity duration-150" style="opacity:0.05" data-seg="12"/>
<line x1="38" y1="10" x2="26" y2="30" stroke="currentColor" stroke-width="6" stroke-linecap="round" class="transition-opacity duration-150" style="opacity:0.05" data-seg="13"/>
<line x1="10" y1="54" x2="22" y2="34" stroke="currentColor" stroke-width="6" stroke-linecap="round" class="transition-opacity duration-150" style="opacity:0.05" data-seg="14"/>
<line x1="38" y1="54" x2="26" y2="34" stroke="currentColor" stroke-width="6" stroke-linecap="round" class="transition-opacity duration-150" style="opacity:0.05" data-seg="15"/>
</svg>
`;
return digit;
}
function _updateSegmentDigit(el, char) {
const active = _SEGMENT_MAP[char.toUpperCase()] || _SEGMENT_MAP[' '];
el.querySelectorAll('[data-seg]').forEach((s) => {
s.style.opacity = active[parseInt(s.dataset.seg)] ? '1' : '0.05';
});
}
// ── DOM ─────────────────────────────────────────────────────────
const wrap = document.createElement('div');
wrap.className = 'w-full h-32 bg-dark-900 border border-gray-800 rounded-lg relative overflow-hidden mb-3 flex flex-col items-center justify-end pb-4';
const noteSegmented = document.createElement('div');
noteSegmented.className = 'flex items-center justify-center gap-2 text-accent z-30';
const spacer = document.createElement('div');
spacer.className = 'w-8 flex-shrink-0';
noteSegmented.appendChild(spacer);
const digit = _createLCDDigit();
noteSegmented.appendChild(digit);
const sharp = document.createElement('div');
sharp.className = 'relative w-8 h-16 flex-shrink-0';
sharp.innerHTML = `
<div class="sharp-segments absolute inset-0 opacity-5 transition-opacity duration-150">
<div class="absolute top-[30%] left-0 right-0 h-2.5 bg-current -rotate-12 rounded-full"></div>
<div class="absolute bottom-[30%] left-0 right-0 h-2.5 bg-current -rotate-12 rounded-full"></div>
<div class="absolute top-0 bottom-0 left-[30%] w-2.5 bg-current rotate-12 rounded-full"></div>
<div class="absolute top-0 bottom-0 right-[30%] w-2.5 bg-current rotate-12 rounded-full"></div>
</div>
`;
const sharpSegsEl = sharp.querySelector('.sharp-segments');
const flatSegEl = document.createElement('div');
flatSegEl.style.cssText = 'position:absolute;inset:0;display:none;align-items:flex-end;justify-content:center;font-size:2.4rem;font-weight:900;line-height:1;color:currentColor;transition:opacity 0.15s;';
flatSegEl.textContent = '♭';
sharp.appendChild(flatSegEl);
noteSegmented.appendChild(sharp);
wrap.appendChild(noteSegmented);
const scanlines = document.createElement('div');
scanlines.className = 'absolute inset-0 z-40 pointer-events-none opacity-20';
scanlines.style.backgroundImage = 'repeating-linear-gradient(90deg,#000 0px,#000 1px,transparent 1px,transparent 3px),repeating-linear-gradient(0deg,#000 0px,#000 1px,transparent 1px,transparent 3px)';
wrap.appendChild(scanlines);
const stripeGrad = 'linear-gradient(90deg,currentColor 0%,currentColor 45%,transparent 45%,transparent 100%)';
const strobeEl = document.createElement('div');
strobeEl.className = 'absolute top-0 left-0 w-full h-full text-accent';
strobeEl.style.cssText = `transition:opacity 0.3s ease,filter 0.3s ease;background-image:${stripeGrad},${stripeGrad};background-size:40px 15%,80px 15%;background-position:0 5%,0 21%;background-repeat:repeat-x;opacity:0`;
// Strobe glow scales with its brightness state (set in update); none while hidden
const _STROBE_GLOW_IN_TUNE = 'drop-shadow(0 0 3px currentColor)';
const _STROBE_GLOW_OUT = 'drop-shadow(0 0 1px currentColor)';
wrap.appendChild(strobeEl);
container.appendChild(wrap);
// ── State ─────────────────────────────────────────────────────────
let strobePhase = 0;
let smoothedCents = 0;
let currentCents = 0;
let strobeActive = false;
let lastAnimateTime = performance.now();
let lastSignalTime = performance.now();
let rafId = null;
function _animate() {
const now = performance.now();
let dt = (now - lastAnimateTime) / 1000;
if (dt > 0.1) dt = 0.016;
lastAnimateTime = now;
const lerpFactor = 1 - Math.exp(-10 * dt);
smoothedCents = smoothedCents * (1 - lerpFactor) + currentCents * lerpFactor;
const signalTimeout = (now - lastSignalTime) > 1000;
if (strobeActive && !signalTimeout) {
const absCents = Math.min(100, Math.abs(smoothedCents));
const maxSpeed = 2500;
const base = 10;
let speed = maxSpeed * (Math.pow(base, absCents / 100) - 1) / (base - 1);
if (smoothedCents < 0) speed = -speed;
strobePhase = ((strobePhase + speed * dt) % 80 + 80) % 80;
strobeEl.style.backgroundPosition = `${strobePhase}px 5%,${strobePhase}px 21%`;
} else if (signalTimeout && strobeActive) {
strobeActive = false;
strobeEl.style.opacity = '0';
}
rafId = requestAnimationFrame(_animate);
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────────
function _setAccidental(acc) {
if (acc === '#') {
sharpSegsEl.style.opacity = '1';
sharpSegsEl.style.filter = _LIT_GLOW;
flatSegEl.style.display = 'none';
} else if (acc === 'b') {
sharpSegsEl.style.opacity = '0.05';
sharpSegsEl.style.filter = 'none';
flatSegEl.style.display = 'flex';
flatSegEl.style.filter = _LIT_GLOW;
} else {
sharpSegsEl.style.opacity = '0.05';
sharpSegsEl.style.filter = 'none';
flatSegEl.style.display = 'none';
}
}
function update(note, cents, freq) {
if (note === null) {
strobeActive = false;
strobeEl.style.opacity = '0';
_updateSegmentDigit(digit, '-');
_setAccidental('');
currentCents = 0;
return;
}
lastSignalTime = performance.now();
strobeActive = true;
currentCents = cents;
_updateSegmentDigit(digit, note[0]);
_setAccidental(note.length > 1 ? note[1] : '');
strobeEl.style.backgroundImage = `${stripeGrad},${stripeGrad}`;
strobeEl.style.backgroundSize = '40px 15%,80px 15%';
strobeEl.style.backgroundPosition = `${strobePhase}px 5%,${strobePhase}px 21%`;
const inTune = Math.abs(cents) < 5;
strobeEl.style.opacity = inTune ? '1' : '0.6';
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
}
function destroy() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
wrap.remove();
}
return { update, destroy };
};
+145
View File
@@ -0,0 +1,145 @@
/**
* Toilet Tuner visualization for the Slopsmith tuner plugin.
*
* Bathroom scene background; plunger slides left/right over the bowl based on
* cents deviation; dips into bowl when in tune (±2 cents); wall calendar shows
* the detected note name.
*
* Contract: window['_tunerViz_toilet-tuner'](container) → { update(note, cents, freq), destroy() }
* - note: string | null (null = no signal)
* - cents: number (deviation from target, 50…+50)
* - freq: number (detected frequency in Hz)
*/
(function () {
'use strict';
// ── Constants ─────────────────────────────────────────────────────
var _TUNER_TT_IN_TUNE_THR = 2;
var _TUNER_TT_ASSET_BASE = '/api/plugins/tuner/viz-assets/';
// Positions derived from Bathroom.svg 0-1024 coordinate space.
// Bowl ellipse centre: x=512 (50%), y=673 (65.7%), semi-major=99 (9.7%).
// Plunger SVG is 29.8mm wide x 69.5mm tall (ratio 1:2.33).
// At width=8%, rendered height = 8% x 2.33 = 18.6%.
// Raised: cup bottom at ~62% (above bowl top) → top = 62 - 18.6 = 43%.
// Dipped: cup inside bowl → top = 52%.
var _TUNER_TT_LEFT_PCT = 15; // x at cents=-50
var _TUNER_TT_RIGHT_PCT = 85; // x at cents=+50
var _TUNER_TT_CENTRE_PCT = 50; // x at cents=0 (bowl centre)
var _TUNER_TT_RAISED_TOP = 41; // plunger top % when hovering above bowl
var _TUNER_TT_DIPPED_TOP = 52; // plunger top % when cup inside bowl
window['_tunerViz_toilet-tuner'] = function (container) {
'use strict';
// ── Root panel — 1:1 square, full width ───────────────────────
// padding-bottom: 100% trick: reliable square even with all-absolute children.
// Background loaded as CSS background-image: bypasses browser intrinsic-size
// limits that cause SVGs with huge explicit width/height to fail as <img>.
var panel = document.createElement('div');
panel.className = 'relative w-full overflow-hidden select-none';
panel.style.height = '0';
panel.style.paddingBottom = '100%';
panel.style.backgroundImage = "url('" + _TUNER_TT_ASSET_BASE + "Bathroom.svg')";
panel.style.backgroundSize = 'cover';
panel.style.backgroundPosition = 'center';
// ── Note label (over calendar on wall) ────────────────────────
var noteEl = document.createElement('div');
noteEl.className = 'absolute font-bold pointer-events-none';
noteEl.style.right = '17.25%';
noteEl.style.top = '17%';
noteEl.style.fontSize = '1.6rem';
noteEl.style.color = '#303332';
noteEl.style.textAlign = 'center';
noteEl.style.transform = 'translateX(50%)';
noteEl.textContent = '';
panel.appendChild(noteEl);
// ── Plunger ───────────────────────────────────────────────────
var plungerEl = document.createElement('img');
plungerEl.src = _TUNER_TT_ASSET_BASE + 'Plunger.svg';
plungerEl.className = 'absolute pointer-events-none';
plungerEl.style.width = '10%';
plungerEl.style.left = _TUNER_TT_CENTRE_PCT + '%';
plungerEl.style.top = _TUNER_TT_RAISED_TOP + '%';
plungerEl.style.transform = 'translateX(-50%)';
panel.appendChild(plungerEl);
// ── Toilet bowl overlay (hides plunger cup when dipped) ───────
var bowlEl = document.createElement('img');
bowlEl.src = _TUNER_TT_ASSET_BASE + 'Toiletbowl.svg';
bowlEl.className = 'absolute pointer-events-none';
// Bowl overlay aligned via shared path124 (lower bowl body) registration:
// Bathroom path102 ellipse centre: x=50%, y=65.8%; width=19.4% of panel.
// Toiletbowl path102 same ellipse: centre at y=0 (top of viewBox), rx=35.4% of viewBox.
// → width = 19.4% / 70.7% = 27.4%; left = 50% - 27.4%/2 = 36.3%; top = 65.8%.
// Verified: Toiletbowl path124 at y=60.93% × height(25.2%) + 65.8% = 81.2% = Bathroom path124 ✓
bowlEl.style.left = '36.3%';
bowlEl.style.top = '65.8%';
bowlEl.style.width = '27.4%';
panel.appendChild(bowlEl);
container.appendChild(panel);
// ── State ─────────────────────────────────────────────────────
var _rafId = null;
var _currentNote = null;
var _currentCents = 0;
var _plungerDipped = false;
var _lastTime = null;
var _leftPct = _TUNER_TT_CENTRE_PCT;
var _topPct = _TUNER_TT_RAISED_TOP;
// ── Animation loop ────────────────────────────────────────────
function _animate(now) {
var dt = Math.min(((now - (_lastTime || now)) / 1000), 0.1);
_lastTime = now;
var inTune = _currentNote !== null && Math.abs(_currentCents) <= _TUNER_TT_IN_TUNE_THR;
var targetLeft = _currentNote === null
? _TUNER_TT_CENTRE_PCT
: Math.min(_TUNER_TT_RIGHT_PCT, Math.max(_TUNER_TT_LEFT_PCT,
_TUNER_TT_CENTRE_PCT + (_currentCents / 50) * (_TUNER_TT_RIGHT_PCT - _TUNER_TT_CENTRE_PCT)));
if (inTune && !_plungerDipped) {
_leftPct = _TUNER_TT_CENTRE_PCT;
_topPct = _TUNER_TT_DIPPED_TOP;
_plungerDipped = true;
noteEl.textContent = '💩';
} else if (!inTune && _plungerDipped) {
_topPct = _TUNER_TT_RAISED_TOP;
_plungerDipped = false;
noteEl.textContent = _currentNote || '';
}
if (!_plungerDipped) {
_leftPct += (targetLeft - _leftPct) * 8 * dt;
}
plungerEl.style.left = _leftPct.toFixed(2) + '%';
plungerEl.style.top = _topPct.toFixed(2) + '%';
_rafId = requestAnimationFrame(_animate);
}
// ── Public API ────────────────────────────────────────────────
function update(note, cents, freq) {
_currentNote = note;
_currentCents = note === null ? 0 : cents;
if (!_plungerDipped) { noteEl.textContent = note || ''; }
}
function destroy() {
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
panel.remove();
}
_rafId = requestAnimationFrame(_animate);
return { update: update, destroy: destroy };
};
})();
+89
View File
@@ -0,0 +1,89 @@
/**
* YIN pitch detection worker.
*
* Receives { samples: Float32Array, sampleRate: number }, posts back
* { freq, confidence, rms }. The samples ArrayBuffer should be passed
* as transferable so no copy occurs across the worker boundary.
*/
// Guard allows the file to be required in Node.js test environments where
// `self` is not defined; the worker message handler only runs in the browser.
if (typeof self !== 'undefined') {
self.onmessage = (e) => {
const { samples, sampleRate } = e.data;
self.postMessage(_yinDetect(samples, sampleRate));
};
}
function _yinDetect(buffer, sampleRate) {
const threshold = 0.15;
const halfLen = Math.floor(buffer.length / 2);
const yinBuffer = new Float32Array(halfLen);
let rms = 0;
for (let i = 0; i < buffer.length; i++) rms += buffer[i] * buffer[i];
rms = Math.sqrt(rms / buffer.length);
if (rms < 0.01) return { freq: 0, confidence: 0, rms };
let runningSum = 0;
yinBuffer[0] = 1;
for (let tau = 1; tau < halfLen; tau++) {
let sum = 0;
for (let i = 0; i < halfLen; i++) {
const delta = buffer[i] - buffer[i + tau];
sum += delta * delta;
}
yinBuffer[tau] = sum;
runningSum += sum;
yinBuffer[tau] = runningSum > 0 ? yinBuffer[tau] * tau / runningSum : 1;
}
// Canonical YIN absolute-threshold step: walk tau upward and take the FIRST
// local minimum that drops below the threshold — NOT the globally deepest
// dip. The fundamental period is the smallest tau that satisfies the
// difference function; its sub-octaves (2T, 3T, …) sit at LARGER tau and
// often dip just as deep or deeper (the waveform realigns over two full
// periods), so picking the deepest dip is what produced the octave-low
// errors — D1 reported for a D2 string, or the common sub-harmonic of a
// two-note pluck. Choosing the first qualifying dip rejects those at the
// source. We still track the global minimum as a fallback for signals where
// nothing crosses the threshold.
let tau = -1;
let minVal = 1, minTau = -1;
for (let t = 2; t < halfLen; t++) {
if (yinBuffer[t] < minVal) { minVal = yinBuffer[t]; minTau = t; }
if (yinBuffer[t] < threshold) {
// Descend to the bottom of this first qualifying dip, keeping the
// global-min tracker current for the indices we skip (otherwise the
// fallback below could interpolate around a non-minimum).
while (t + 1 < halfLen && yinBuffer[t + 1] < yinBuffer[t]) {
t++;
if (yinBuffer[t] < minVal) { minVal = yinBuffer[t]; minTau = t; }
}
tau = t;
break;
}
}
if (tau === -1) {
// Nothing crossed the threshold — fall back to the global minimum so a
// weak but periodic signal still yields an estimate (confidence will be
// correspondingly low and is filtered downstream).
if (minTau === -1) return { freq: 0, confidence: 0, rms };
tau = minTau;
}
const s0 = yinBuffer[tau - 1];
const s1 = yinBuffer[tau];
const s2 = tau + 1 < halfLen ? yinBuffer[tau + 1] : yinBuffer[tau];
const denom = s0 - 2 * s1 + s2;
let betterTau = denom === 0 ? tau : tau + (s0 - s2) / (2 * denom);
// A near-zero (but nonzero) denom can fling the parabolic estimate far
// outside the bracket; the true minimum is within ±1 sample of tau. The
// negated test also rejects NaN.
if (!(betterTau >= tau - 1 && betterTau <= tau + 1)) betterTau = tau;
return { freq: sampleRate / betterTau, confidence: 1 - yinBuffer[tau], rms };
}
// Allow direct import in Node.js test environments; harmless in browser workers
// where the `module` global is undefined.
if (typeof module !== 'undefined') module.exports = { _yinDetect };