mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Clean release snapshot
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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.');
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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; },
|
||||
};
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 & 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 & 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(/&/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 |
@@ -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 R−18 (below the tube's inner edge with a gap)
|
||||
// Tube inner edge at R−8; labels at R−18 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 };
|
||||
};
|
||||
})();
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
})();
|
||||
@@ -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 (75−4=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 4–96, y 4–71 (width=92, height=67 in panel units).
|
||||
// Arc centre in panel SVG = (50, 50) → in face-div %:
|
||||
// cx = (50−4)/92×100 = 50 %
|
||||
// cy = (50−4)/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 (cx−r, 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 };
|
||||
};
|
||||
|
||||
}());
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user