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:
+2384
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "app_tour_library",
|
||||
"name": "Library Tour",
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"script": "script.js",
|
||||
"tour": "tour.json"
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Override the engine's default `['player']` screens list for non-has_screen
|
||||
// plugins — this tour is for the Home/Library screen, not the player.
|
||||
var PLUGIN_ID = 'app_tour_library';
|
||||
var SCREENS = ['home'];
|
||||
|
||||
var PROVIDER_STEP = {
|
||||
id: 'library-provider',
|
||||
selector: '#lib-provider',
|
||||
title: 'Choose a library',
|
||||
content: 'Use this menu to switch between your local library and any connected remote libraries. Slopsmith remembers the last library you picked.',
|
||||
shape: 'spotlight',
|
||||
position: 'bottom',
|
||||
waitFor: '#lib-provider'
|
||||
};
|
||||
|
||||
async function _loadDefaultSteps() {
|
||||
try {
|
||||
var resp = await fetch('/api/plugins/' + encodeURIComponent(PLUGIN_ID) + '/tour.json');
|
||||
if (!resp.ok) return [];
|
||||
var data = await resp.json();
|
||||
return Array.isArray(data.tour) ? data.tour : [];
|
||||
} catch (e) {
|
||||
console.warn('[app_tour_library] failed to load tour steps', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function _isBrowsableProvider(provider) {
|
||||
return !!provider && Array.isArray(provider.capabilities) && provider.capabilities.indexOf('library.read') !== -1;
|
||||
}
|
||||
|
||||
async function _hasMultipleProviders() {
|
||||
try {
|
||||
var resp = await fetch('/api/library/providers');
|
||||
if (!resp.ok) return false;
|
||||
var data = await resp.json();
|
||||
var providers = Array.isArray(data.providers) ? data.providers.filter(_isBrowsableProvider) : [];
|
||||
return providers.length > 1;
|
||||
} catch (e) {
|
||||
console.warn('[app_tour_library] failed to load library providers', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _buildSteps() {
|
||||
var steps = await _loadDefaultSteps();
|
||||
if (!steps.length || !(await _hasMultipleProviders())) return steps;
|
||||
|
||||
var insertAt = steps.findIndex(function (step) { return step && step.id === 'search'; });
|
||||
var nextSteps = steps.slice();
|
||||
nextSteps.splice(insertAt === -1 ? 1 : insertAt + 1, 0, PROVIDER_STEP);
|
||||
return nextSteps;
|
||||
}
|
||||
|
||||
function _register() {
|
||||
try {
|
||||
window.slopsmithTour.register(PLUGIN_ID, { screens: SCREENS, buildSteps: _buildSteps });
|
||||
} catch (e) {
|
||||
console.warn('[app_tour_library] register failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.slopsmithTour && typeof window.slopsmithTour.register === 'function') {
|
||||
_register();
|
||||
} else {
|
||||
// Engine inits on DOMContentLoaded after fetching /api/plugins. Plugin
|
||||
// scripts can load before or after that handler runs, so poll briefly.
|
||||
var deadline = performance.now() + 5000;
|
||||
var pollId = setInterval(function () {
|
||||
if (window.slopsmithTour && typeof window.slopsmithTour.register === 'function') {
|
||||
clearInterval(pollId);
|
||||
_register();
|
||||
} else if (performance.now() > deadline) {
|
||||
clearInterval(pollId);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ── Layout nudge ──────────────────────────────────────────────────────
|
||||
// The Tuner plugin parks a FAB at `fixed bottom-5 right-5` (≈ bottom:20px,
|
||||
// right:20px, ~40px tall) on every screen. The tour engine's ? button sits
|
||||
// at bottom:12px right:12px — same corner. On the home screen they overlap
|
||||
// exactly. Scope a nudge to the home screen only so we don't move the
|
||||
// button on screens where the home tour isn't relevant anyway.
|
||||
var NUDGE_CLASS = 'app-tour-library-nudge';
|
||||
var STYLE_ID = 'app-tour-library-nudge-style';
|
||||
|
||||
function _ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
var s = document.createElement('style');
|
||||
s.id = STYLE_ID;
|
||||
s.textContent =
|
||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-menu-btn { bottom: 68px; }' +
|
||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-menu-popover { bottom: 112px; }' +
|
||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-prompt { bottom: 112px; }';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function _applyNudge(screenId) {
|
||||
if (!document.body) return;
|
||||
document.body.classList.toggle(NUDGE_CLASS, screenId === 'home');
|
||||
}
|
||||
|
||||
function _initNudge() {
|
||||
_ensureStyle();
|
||||
// Prime from whichever screen is already active.
|
||||
var active = document.querySelector('.screen.active');
|
||||
_applyNudge(active ? active.id : null);
|
||||
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
|
||||
window.slopsmith.on('screen:changed', function (ev) {
|
||||
_applyNudge(ev && ev.detail && ev.detail.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _initNudge, { once: true });
|
||||
} else {
|
||||
_initNudge();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tour": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"title": "Welcome to Slopsmith",
|
||||
"content": "This is your library — every song we found in your library folder. Let's take a quick spin through the controls.",
|
||||
"shape": "bubble",
|
||||
"position": "auto"
|
||||
},
|
||||
{
|
||||
"id": "search",
|
||||
"selector": "#lib-filter",
|
||||
"title": "Search",
|
||||
"content": "Filter the library by title, artist, or album. Matches as you type.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "sort",
|
||||
"selector": "#lib-sort",
|
||||
"title": "Sort",
|
||||
"content": "Sort by recent, artist, title, year, or tuning. \"Recent\" surfaces whatever you've played or added lately.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "format-filter",
|
||||
"selector": "#lib-format",
|
||||
"title": "Format filter",
|
||||
"content": "Narrow the list by source format — sloppak (open format) or folder imports.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "views",
|
||||
"selector": "#view-tree-btn",
|
||||
"title": "Grid or tree view",
|
||||
"content": "Toggle between grid (album-art cards) and tree (artist → album) views. Tree view shows Expand All / Collapse All buttons next to it.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "filters-drawer",
|
||||
"selector": "#btn-lib-filters",
|
||||
"title": "Advanced filters",
|
||||
"content": "Filter by arrangement (Lead/Rhythm/Bass), stems availability, lyrics, or tuning. Active filters appear as chips below the search box.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "song-card",
|
||||
"selector": ".song-card",
|
||||
"title": "Play a song",
|
||||
"content": "Click any card to start playing. The ★ marks it as a favorite — your favorites get their own screen in the navbar.",
|
||||
"shape": "spotlight",
|
||||
"position": "auto",
|
||||
"waitFor": ".song-card"
|
||||
},
|
||||
{
|
||||
"id": "outro",
|
||||
"title": "That's the tour",
|
||||
"content": "The floating ? button (bottom-right) brings this back any time, plus any plugin tours relevant to the current screen.",
|
||||
"shape": "bubble",
|
||||
"position": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "app_tour_settings",
|
||||
"name": "Settings Tour",
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"script": "script.js",
|
||||
"tour": "tour.json"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var PLUGIN_ID = 'app_tour_settings';
|
||||
var SCREENS = ['settings'];
|
||||
|
||||
function _register() {
|
||||
try {
|
||||
window.slopsmithTour.register(PLUGIN_ID, { screens: SCREENS });
|
||||
} catch (e) {
|
||||
console.warn('[app_tour_settings] register failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.slopsmithTour && typeof window.slopsmithTour.register === 'function') {
|
||||
_register();
|
||||
} else {
|
||||
var deadline = performance.now() + 5000;
|
||||
var pollId = setInterval(function () {
|
||||
if (window.slopsmithTour && typeof window.slopsmithTour.register === 'function') {
|
||||
clearInterval(pollId);
|
||||
_register();
|
||||
} else if (performance.now() > deadline) {
|
||||
clearInterval(pollId);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ── Layout nudge ──────────────────────────────────────────────────────
|
||||
// Tuner plugin parks a FAB at `fixed bottom-5 right-5` on every screen;
|
||||
// tour engine's ? button sits at bottom:12px right:12px — same corner.
|
||||
// Nudge the tour UI up when the settings screen is active.
|
||||
var NUDGE_CLASS = 'app-tour-settings-nudge';
|
||||
var STYLE_ID = 'app-tour-settings-nudge-style';
|
||||
|
||||
function _ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
var s = document.createElement('style');
|
||||
s.id = STYLE_ID;
|
||||
s.textContent =
|
||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-menu-btn { bottom: 68px; }' +
|
||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-menu-popover { bottom: 112px; }' +
|
||||
'body.' + NUDGE_CLASS + ' .slopsmith-tour-prompt { bottom: 112px; }';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function _applyNudge(screenId) {
|
||||
if (!document.body) return;
|
||||
document.body.classList.toggle(NUDGE_CLASS, screenId === 'settings');
|
||||
}
|
||||
|
||||
function _initNudge() {
|
||||
_ensureStyle();
|
||||
var active = document.querySelector('.screen.active');
|
||||
_applyNudge(active ? active.id : null);
|
||||
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
|
||||
window.slopsmith.on('screen:changed', function (ev) {
|
||||
_applyNudge(ev && ev.detail && ev.detail.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _initNudge, { once: true });
|
||||
} else {
|
||||
_initNudge();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tour": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"title": "Settings tour",
|
||||
"content": "Quick walk through the controls that matter most on first setup. You can re-run any time from the ? button.",
|
||||
"shape": "bubble",
|
||||
"position": "auto"
|
||||
},
|
||||
{
|
||||
"id": "dlc-path",
|
||||
"selector": "#dlc-path",
|
||||
"title": "Library folder",
|
||||
"content": "Point Slopsmith at your library folder. Songs here become your library. Hit Save after changing.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "default-arrangement",
|
||||
"selector": "#default-arrangement",
|
||||
"title": "Default arrangement",
|
||||
"content": "Which arrangement to land on when a song loads — Lead, Rhythm, Bass, or Auto (pick whatever the song has).",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "av-offset",
|
||||
"selector": "#setting-av-offset",
|
||||
"title": "A/V sync offset",
|
||||
"content": "Shift audio against the note highway in milliseconds. Use the note_detect plugin's auto-calibrate (or [ / ] keys in the player) to dial it in.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "rescan",
|
||||
"selector": "#btn-rescan",
|
||||
"title": "Rescan library",
|
||||
"content": "Picks up new songs you've dropped into the DLC folder. Full Rescan rebuilds metadata from scratch — slower, only needed after upgrades or corruption.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "backup",
|
||||
"selector": "#btn-export-settings",
|
||||
"title": "Backup and restore",
|
||||
"content": "Export bundles your core settings plus every plugin's opt-in state into a single file. Import on a fresh install to migrate. Plugin authors declare what gets included via settings.server_files.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "diagnostics",
|
||||
"selector": "#btn-diag-export",
|
||||
"title": "Export Diagnostics",
|
||||
"content": "Builds a troubleshooting bundle (system info, logs, plugin diagnostics) for bug reports. Use the checkboxes above to control what's included; Redact strips paths and identifiers.",
|
||||
"shape": "spotlight",
|
||||
"position": "top"
|
||||
},
|
||||
{
|
||||
"id": "plugins",
|
||||
"selector": "#plugin-settings-area",
|
||||
"title": "Per-plugin settings",
|
||||
"content": "Every installed plugin with its own settings shows up here as a collapsible section. Plugin updates are surfaced at the top of this block.",
|
||||
"shape": "spotlight",
|
||||
"position": "top",
|
||||
"waitFor": "#plugin-settings-area"
|
||||
},
|
||||
{
|
||||
"id": "about",
|
||||
"selector": "#app-version-about",
|
||||
"title": "About",
|
||||
"content": "Version, source code, and license. Slopsmith is AGPL-3.0 — if you fork it, the source has to stay open.",
|
||||
"shape": "spotlight",
|
||||
"position": "top"
|
||||
},
|
||||
{
|
||||
"id": "outro",
|
||||
"title": "Done",
|
||||
"content": "Plugins ship their own tours too — the ? button surfaces whichever ones apply to the current screen.",
|
||||
"shape": "bubble",
|
||||
"position": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "capability_inspector",
|
||||
"name": "Capability Inspector",
|
||||
"version": "0.1.0",
|
||||
"bundled": true,
|
||||
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"settings": { "html": "settings.html" },
|
||||
"capabilities": {
|
||||
"diagnostics": {
|
||||
"roles": ["requester", "observer"],
|
||||
"commands": ["snapshot"],
|
||||
"events": [],
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"ownership": "diagnostic-only",
|
||||
"safety": "diagnostic-only",
|
||||
"version": 1
|
||||
},
|
||||
"pipeline": {
|
||||
"roles": ["requester", "observer"],
|
||||
"commands": ["inspect", "validate", "participant.set-enabled"],
|
||||
"events": ["resolved", "runtime.validated", "participant.state-changed"],
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"ownership": "diagnostic-only",
|
||||
"safety": "diagnostic-only",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
<style>
|
||||
.capability-inspector [data-endpoint-icon] {
|
||||
display: inline-block !important;
|
||||
width: 0.75rem !important;
|
||||
height: 0.75rem !important;
|
||||
flex: 0 0 0.75rem !important;
|
||||
border-width: 1px !important;
|
||||
border-style: solid !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.capability-inspector [data-endpoint-icon="command"] {
|
||||
background: #fb923c !important;
|
||||
border-color: #fed7aa !important;
|
||||
box-shadow: 0 0 12px rgba(251, 146, 60, 0.65) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-endpoint-icon="operation"] {
|
||||
background: #c084fc !important;
|
||||
border-color: #e9d5ff !important;
|
||||
box-shadow: 0 0 12px rgba(192, 132, 252, 0.65) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-endpoint-icon="event"] {
|
||||
background: #60a5fa !important;
|
||||
border-color: #bfdbfe !important;
|
||||
box-shadow: 0 0 12px rgba(96, 165, 250, 0.65) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-endpoint-flow="provider-command"] {
|
||||
background: #c084fc !important;
|
||||
border-color: #e9d5ff !important;
|
||||
box-shadow: 0 0 12px rgba(192, 132, 252, 0.65) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-endpoint-flow="provider-operation"] {
|
||||
background: #d8b4fe !important;
|
||||
border-color: #f3e8ff !important;
|
||||
box-shadow: 0 0 12px rgba(216, 180, 254, 0.65) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-endpoint-flow="provider-event"] {
|
||||
background: #ddd6fe !important;
|
||||
border-color: #ede9fe !important;
|
||||
box-shadow: 0 0 12px rgba(221, 214, 254, 0.6) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-graph-provider-group],
|
||||
.capability-inspector [data-graph-participant-group] {
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-toggle-graph-group] {
|
||||
min-height: 1.75rem !important;
|
||||
gap: 0.625rem !important;
|
||||
padding-block: 0.125rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-graph-provider-group] [data-toggle-graph-group] {
|
||||
padding-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-graph-participant-group] [data-toggle-graph-group] {
|
||||
padding-left: 0.5rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-graph-provider-endpoint-row] {
|
||||
padding-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-graph-participant-endpoint-row] {
|
||||
padding-left: 0.5rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-role-icon],
|
||||
.capability-inspector [data-origin-icon],
|
||||
.capability-inspector [data-availability-icon] {
|
||||
width: 2.125rem !important;
|
||||
height: 2.125rem !important;
|
||||
flex: 0 0 2.125rem !important;
|
||||
border-radius: 0.375rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-role-icon] svg,
|
||||
.capability-inspector [data-origin-icon] svg,
|
||||
.capability-inspector [data-availability-icon] svg {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-role-icon] + [data-origin-icon],
|
||||
.capability-inspector [data-origin-icon] + [data-role-icon],
|
||||
.capability-inspector [data-origin-icon] + [data-availability-icon] {
|
||||
margin-left: 0.5rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-domain-owner-footer] {
|
||||
margin-top: auto !important;
|
||||
padding-top: 1rem !important;
|
||||
border-top: 1px solid rgba(31, 41, 55, 0.75) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-domain-owner-description] {
|
||||
display: block !important;
|
||||
margin-top: 0.125rem !important;
|
||||
line-height: 1.45 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-domain-graph-filter] {
|
||||
border: 1px solid #374151 !important;
|
||||
border-radius: 0.375rem !important;
|
||||
padding: 0.25rem 0.625rem !important;
|
||||
color: #d1d5db !important;
|
||||
background: rgba(31, 41, 55, 0.72) !important;
|
||||
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-domain-graph-filter]:hover {
|
||||
color: #ffffff !important;
|
||||
border-color: #6b7280 !important;
|
||||
background: rgba(55, 65, 81, 0.86) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-domain-graph-filter][aria-pressed="true"] {
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(192, 132, 252, 0.7) !important;
|
||||
background: rgba(126, 34, 206, 0.55) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(216, 180, 254, 0.18) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-icon] {
|
||||
display: inline-block !important;
|
||||
width: 0.75rem !important;
|
||||
height: 0.75rem !important;
|
||||
flex: 0 0 0.75rem !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-icon="command"] {
|
||||
background: #fb923c !important;
|
||||
box-shadow: 0 0 12px rgba(251, 146, 60, 0.55) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-icon="operation"] {
|
||||
background: #c084fc !important;
|
||||
box-shadow: 0 0 12px rgba(192, 132, 252, 0.55) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-icon="event"] {
|
||||
background: #60a5fa !important;
|
||||
box-shadow: 0 0 12px rgba(96, 165, 250, 0.55) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-line] {
|
||||
display: inline-flex !important;
|
||||
width: 2rem !important;
|
||||
height: 2px !important;
|
||||
flex: 0 0 2rem !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-line="command"] {
|
||||
background: #fb923c !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-line="operation"] {
|
||||
background: #c084fc !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-line="provider-operation"] {
|
||||
background: #d8b4fe !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-line="provider-command"] {
|
||||
background: #c084fc !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-line="event"] {
|
||||
background: #60a5fa !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-line="provider-event"] {
|
||||
background: #ddd6fe !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-legend-line="shimmed"] {
|
||||
height: 0 !important;
|
||||
border-top: 2px dashed #9ca3af !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-dashboard] {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
gap: 1rem !important;
|
||||
width: 100% !important;
|
||||
margin-top: 1.5rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-card] {
|
||||
min-height: 4.5rem !important;
|
||||
border: 1px solid #1f2937 !important;
|
||||
border-radius: 0.5rem !important;
|
||||
background: rgba(17, 24, 39, 0.72) !important;
|
||||
padding: 1rem 1.25rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-card][data-tone="clean"] {
|
||||
border-color: rgba(52, 211, 153, 0.55) !important;
|
||||
background: rgba(16, 185, 129, 0.08) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-card][data-tone="warning"] {
|
||||
border-color: rgba(251, 191, 36, 0.55) !important;
|
||||
background: rgba(245, 158, 11, 0.08) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-card][data-tone="conflict"] {
|
||||
border-color: rgba(248, 113, 113, 0.58) !important;
|
||||
background: rgba(239, 68, 68, 0.08) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-card][data-tone="used"] {
|
||||
border-color: rgba(232, 192, 64, 0.58) !important;
|
||||
background: rgba(232, 192, 64, 0.08) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-row] {
|
||||
display: flex !important;
|
||||
align-items: baseline !important;
|
||||
justify-content: space-between !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-label] {
|
||||
color: #8b93a7 !important;
|
||||
font-size: 0.72rem !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
text-transform: uppercase !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-value] {
|
||||
color: #ffffff !important;
|
||||
font-size: 1.5rem !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-status-value] {
|
||||
color: #34d399 !important;
|
||||
border: 1px solid rgba(52, 211, 153, 0.65) !important;
|
||||
border-radius: 0.375rem !important;
|
||||
background: rgba(16, 185, 129, 0.12) !important;
|
||||
padding: 0.25rem 0.625rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.1 !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-status-value][data-tone="warning"] {
|
||||
color: #fbbf24 !important;
|
||||
border-color: rgba(251, 191, 36, 0.65) !important;
|
||||
background: rgba(245, 158, 11, 0.12) !important;
|
||||
}
|
||||
|
||||
.capability-inspector [data-summary-status-value][data-tone="conflict"] {
|
||||
color: #f87171 !important;
|
||||
border-color: rgba(248, 113, 113, 0.7) !important;
|
||||
background: rgba(239, 68, 68, 0.12) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.capability-inspector [data-summary-dashboard] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.capability-inspector [data-summary-dashboard] {
|
||||
grid-template-columns: minmax(0, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.capability-inspector [data-domain-graph-fallback] {
|
||||
display: flex !important;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.capability-inspector [data-domain-provider-card],
|
||||
.capability-inspector [data-domain-participant-lane] {
|
||||
flex: 0 0 24rem;
|
||||
width: 24rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.capability-inspector [data-domain-graph-cy] {
|
||||
display: block !important;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.capability-inspector [data-graph-capability-port] {
|
||||
position: absolute;
|
||||
right: -1.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.capability-inspector [data-graph-participant-port] {
|
||||
position: absolute;
|
||||
left: -1.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.capability-inspector [data-domain-graph-fallback] {
|
||||
gap: 6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="max-w-7xl mx-auto px-6 pt-24 pb-16 capability-inspector">
|
||||
<div class="mb-6" data-inspector-header>
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<h2 class="text-3xl font-bold text-white">Capability Inspector</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="capability-inspector-filter" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-sm text-gray-300 outline-none">
|
||||
<option value="">All domains</option>
|
||||
</select>
|
||||
<button id="capability-inspector-refresh" class="bg-accent hover:bg-accent-light text-white px-4 py-2 rounded-lg text-sm transition">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="capability-inspector-summary"></div>
|
||||
</div>
|
||||
<div id="capability-inspector-empty" class="hidden text-gray-400 border border-gray-800 rounded-lg p-4 bg-dark-800/60"></div>
|
||||
<div id="capability-inspector-content" class="grid gap-4"></div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
<div role="group" aria-labelledby="capability-inspector-settings-heading">
|
||||
<h3 id="capability-inspector-settings-heading" class="text-sm font-medium text-gray-400 mb-2">Capability Inspector</h3>
|
||||
<div class="flex flex-col gap-3 rounded-lg border border-gray-800 bg-dark-800/40 p-3">
|
||||
<label class="flex items-start gap-3 text-sm text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" id="capability-inspector-show-nav" class="mt-1 accent-accent">
|
||||
<span>
|
||||
<span class="block text-gray-200">Show in Plugins menu</span>
|
||||
<span class="block text-xs text-gray-500 mt-1">Adds the inspector to the desktop and mobile plugin navigation.</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button type="button" id="capability-inspector-open" class="rounded bg-dark-700 border border-gray-800 px-3 py-2 text-xs text-gray-300 hover:text-white hover:border-gray-600 transition">Open inspector</button>
|
||||
<span id="capability-inspector-show-nav-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const key = 'capability_inspector.showInPluginsMenu';
|
||||
const checkbox = document.getElementById('capability-inspector-show-nav');
|
||||
const openButton = document.getElementById('capability-inspector-open');
|
||||
const status = document.getElementById('capability-inspector-show-nav-status');
|
||||
|
||||
function isEnabled() {
|
||||
try { return localStorage.getItem(key) === '1'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function render() {
|
||||
const enabled = isEnabled();
|
||||
if (checkbox) checkbox.checked = enabled;
|
||||
if (status) status.textContent = enabled ? 'Menu link enabled' : 'Menu link hidden';
|
||||
}
|
||||
|
||||
function refreshPluginsMenu() {
|
||||
if (typeof window.loadPlugins !== 'function') return;
|
||||
setTimeout(() => window.loadPlugins(), 0);
|
||||
}
|
||||
|
||||
if (checkbox) {
|
||||
checkbox.addEventListener('change', () => {
|
||||
try {
|
||||
if (checkbox.checked) localStorage.setItem(key, '1');
|
||||
else localStorage.removeItem(key);
|
||||
} catch (_) {}
|
||||
render();
|
||||
refreshPluginsMenu();
|
||||
});
|
||||
}
|
||||
if (openButton) {
|
||||
openButton.addEventListener('click', () => {
|
||||
if (typeof window.showScreen === 'function') window.showScreen('plugin-capability_inspector');
|
||||
});
|
||||
}
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,254 @@
|
||||
# 3D Highway Plugin — AI Maintainer Guide
|
||||
|
||||
This guide tells future AI assistants where each visual element lives in `screen.js`, what controls it, and the gotchas to watch for. The goal is for small polishes (color tweaks, sizing, animation timing, add/remove a label) to land in the right place on the first try without grep spelunking.
|
||||
|
||||
The whole renderer is **one file** — `screen.js`, wrapped in an IIFE, registered as `window.slopsmithViz_highway_3d` (a slopsmith#36 setRenderer factory). No imports beyond Three.js loaded from the vendored `/static/vendor/three/three.module.min.js` (pinned r170; swapped from CDN when bundled into core).
|
||||
|
||||
**Styling (slopsmith `styles` capability).** This plugin owns its Tailwind CSS: it ships `assets/plugin.css` and declares `"styles": "assets/plugin.css"` in `plugin.json`, so core's prebuilt `static/tailwind.min.css` no longer scans it (it's excluded from core's content globs). The frontend injects `assets/plugin.css` as a `<link>` when the renderer activates. This is the one maintainer-time build step: after you add/change a Tailwind class in `screen.js` or `settings.html`, run `bash build-tailwind.sh` (pinned `tailwindcss@3.4.19`, `corePlugins.preflight=false` — utilities only) and **bump the `version` in `plugin.json`** so the injected `<link>`'s `?v=` cache-buster fetches the fresh file. The generated `assets/plugin.css` is committed; end users never build. See [docs/plugin-styles.md](../../docs/plugin-styles.md).
|
||||
|
||||
> **Navigation note:** This guide references functions by name and uses the existing banner comments (`/* ── Scene initialisation ─ */`, etc.) as section anchors. Line numbers are deliberately avoided so this stays correct as the file evolves. Use `Grep` for the function name or banner text to jump to a section.
|
||||
|
||||
## File structure at a glance
|
||||
|
||||
The file is laid out top-to-bottom as:
|
||||
|
||||
1. **Constants block** — palette (`S_COL`), scale (`SCALE`, `K`), fret/string counts, geometry sizes, camera, fog
|
||||
2. **Pure helpers** — `fretX`, `fretMid`, `dZ`, `computeBPM`
|
||||
3. **Three.js loader** — `loadThree()` (loads vendored `/static/vendor/three/three.module.min.js`, memoized)
|
||||
4. **Splitscreen helpers** — `_ssActive`, `_ssIsCanvasFocused` (read `window.slopsmithSplitscreen`)
|
||||
5. **`createFactory()`** — the rest of the file is one big closure
|
||||
- Per-instance state (Three.js refs, pools, camera state, lifecycle flags)
|
||||
- `txtMat()` text-sprite cache, `pool()` factory
|
||||
- `drawChordDiagram()` — 2D canvas chord diagram (top-left overlay)
|
||||
- `drawLyrics()` — 2D canvas lyrics renderer (top centre)
|
||||
- `initScene()` — one-time WebGL setup: scene, camera, lights, materials, pools
|
||||
- `buildBoard()` — static fretboard geometry: strings, fret wires, fret dots, board plane
|
||||
- `updateStringHighlights()` — per-frame string emissive glow + opacity
|
||||
- `update(bundle)` — the big per-frame function: notes, chords, beats, lane, fret labels
|
||||
- `drawNote()` — single note: outline, body, sustain, drop line, technique labels, projection
|
||||
- `camUpdate()` — smooth camera lerp + self-correcting NDC look-at
|
||||
- `applySize()` — DPR + canvas size + aspect clamping
|
||||
- `teardown()` — dispose all GPU resources + reset state
|
||||
- `canvasSize()` — resilient canvas-dimension lookup
|
||||
- **Returned API** — `init / draw / resize / destroy` (setRenderer contract)
|
||||
|
||||
## Coordinate system
|
||||
|
||||
- **+X** runs along the fretboard (low frets → high frets, `fretX(f)` and `fretMid(f)`).
|
||||
- **+Y** is up (string Y is `sY(s)`, low strings have lower Y when not inverted).
|
||||
- **+Z** is toward the camera. Notes spawn at negative Z and approach Z=0 (the hit line). Past notes would be at positive Z, but `noteZ` is clamped via `Math.min(0, dZ(dt))` in `drawNote()` so they stop at the string plane.
|
||||
- **Camera** sits at roughly `(curX + 20*K, h*0.95, dist*0.75)` — positive Z, slightly above and behind the play line, looking toward `(curX, curLookY, -FOCUS_D * 0.35)`.
|
||||
|
||||
`dZ(dt) = -dt * TS` — the closer to "now," the closer to Z=0. `TS = 200*K` is the world-units-per-second scroll rate.
|
||||
|
||||
## The K scale and why everything is multiplied by it
|
||||
|
||||
`SCALE = 2.25`, `K = SCALE / 300 ≈ 0.0075`. **Almost every world-space dimension is expressed as `N * K`** so the whole scene scales as one unit. Tweaking `SCALE` alone resizes the entire highway. If you change a literal world dimension, write it as `N * K` to keep it consistent — naked numeric literals in Three.js geometry creation calls (e.g. inside `BoxGeometry`) are an obvious smell.
|
||||
|
||||
Concrete sizes (search the constants block for the names):
|
||||
|
||||
| Const | Value (world units) | Meaning |
|
||||
|---|---|---|
|
||||
| `STR_THICK` | `0.25 * K` | String thickness |
|
||||
| `S_BASE` / `S_GAP` | `3 * K` / `4 * K` | Lowest-string Y / inter-string gap |
|
||||
| `NW`, `NH`, `ND` | `5 * K`, `3 * K`, `0.5 * K` | Note width / height / depth |
|
||||
| `TS` | `200 * K` | Scroll speed (world units per second) |
|
||||
| `AHEAD` / `BEHIND` | `3.0` / `0.5` | Seconds visible ahead / behind hit line |
|
||||
| `CAM_DIST_BASE` / `CAM_H_BASE` | `240 * K` / `150 * K` | Reference camera distance / height |
|
||||
| `FOG_START` / `FOG_END` | `200 * K` / `670 * K` | Fog kicks in past hit line, swallows by the horizon |
|
||||
|
||||
## "I want to change X" — quick lookup
|
||||
|
||||
Each entry names the function or banner you should grep for, plus key sub-blocks (also marked with banner comments inside the function).
|
||||
|
||||
### Strings
|
||||
- **String colors** → `S_COL` array in the top-level constants block. Eight-element vibrant palette; index `s` is the string (0 = high E for guitar). `MAX_RENDER_STRINGS` keys off `S_COL.length`.
|
||||
- **String count for the active arrangement** → `resolveStringCount(bundle)` (top-level helper). Reads `bundle.stringCount` (slopsmith#93) with a `bass`-name fallback. Don't reintroduce `tuning.length` — see Pitfall #4.
|
||||
- **String thickness / gap / base Y** → `STR_THICK`, `S_BASE`, `S_GAP` constants.
|
||||
- **String-to-Y mapping (respects invert)** → the `sY(s)` arrow function inside `createFactory()`. Single source of truth for "where on Y is string s."
|
||||
- **Static string mesh creation** → `buildBoard()`, the `// Thin Line strings (glow layer)` and `// BoxGeometry strings — emissive glow ...` comment blocks. Two layers: low-opacity `Line` for soft glow, `BoxGeometry` mesh per string with its own material clone (kept in `stringLines[]` for live emissive updates).
|
||||
- **Live string glow / pulse** → `updateStringHighlights(noteState)`. Tunables: `BASE_GLOW`, `MAX_GLOW`, `IDLE_OP`. Driven by `noteState.stringSustain` and `noteState.stringAnticipation`.
|
||||
|
||||
### Fretboard
|
||||
- **Fret count** → `NFRETS` constant. Increasing requires nothing else.
|
||||
- **Fret X positioning** → `fretX(f)` and `fretMid(f)` (top-level helpers). Logarithmic guitar-fret spacing within `SCALE`.
|
||||
- **Fretboard plane / fret wires / fret dots** → `buildBoard()`, separate banner-style comment blocks (`// Fret wires`, `// Fret dots`). The dark background plane is the first thing built; main fret wires use `0xbbbbff` / opacity 0.8, minor wires `0x666688` / opacity 0.4. Single/double dots: `DOTS` array + `DDOTS` set in the constants block.
|
||||
- **Fret-row label colors / sizing** (the heat-coloured row of fret numbers below the board) → `update()`, `// ── Dynamic fret number row ──` block. Active = `#ffe84d`, inactive = `#9ab8cc`, opacity / scale driven by `noteState.fretHeat[f]`. Text rendering (font, outline, shadow) is governed by the `'fretRow'` preset in `TXT_STYLES` — see "Tweaking text-sprite styling".
|
||||
- **Active-fret cooldown** → `FRET_COOLDOWN` constant. How long after the last note in a fret it stays in the active set.
|
||||
|
||||
### Notes
|
||||
- **Single-note rendering** → `drawNote()`. Handles outline, core body, open-string variant, sustain trail, lane drop line, all technique labels, fret connector label, and the board projection. Each visual block has its own banner comment (`// ── Outline ──`, `// ── Core (filled note body) ──`, `// ── Sustain trail ──`, `// ── Lane drop line ──`, `// ── Technique labels ──`, `// ── Per-note fret connector label ──`, `// ── Board projection ──`).
|
||||
- **Note geometry / size** → `gNote = new T.BoxGeometry(NW, NH, ND)` in `initScene()`. Per-note scale tweaks happen inside `drawNote()`.
|
||||
- **Note approach rotation (vertical → horizontal)** → search `approachRot` inside `drawNote()`. Maps `dt / AHEAD` to `[0, π/2]`. Open strings skip the rotation.
|
||||
- **Note color** → `mStr[s]` (idle) / `mGlow[s]` (hit), built in `initScene()`. Hit material is white-with-emissive, idle is dim emissive of the string color.
|
||||
- **Sustain trail** → `// ── Sustain trail ──` block in `drawNote()`. Geometry: scaled `gSus` (`BoxGeometry(1,1,1)`). Width `NW * 0.85`, height `NH * 0.12`. Outline mesh + colored core mesh.
|
||||
- **Lane drop line** → `// ── Lane drop line ──` block in `drawNote()`. Vertical line from each upcoming note down to the fretboard plane in the string's color.
|
||||
- **Per-note fret connector label** → `// ── Per-note fret connector label ──` block in `drawNote()`. Number below the board with a thin line up to the note. Be careful with `replace_all` on the `0.5` and `0.4` floats in the alpha formula — they're separate constants. Uses the `'noteFret'` preset in `TXT_STYLES` (also applied to the on-body fret number when `showFretOnNote` is enabled).
|
||||
- **Technique markers** (bend, slide, hammer/pull/tap, accent, tremolo, palm-mute, pinch harmonic) → `// ── Technique labels ──` block in `drawNote()`. Most are small if-blocks using `txtMat(text, color, wide, style)` (cached sprite material; `'technique'` preset in `TXT_STYLES`). Exceptions: a **bend** draws a string-coloured chevron strength stack (`bendChevronMat`, one chevron per half-step), and **hammer-on / pull-off** draw a white ▲/▼ triangle with a string-coloured border (`triMat`) — both pinned to the gem; the bend ribbon's up→hold→down contour is driven by `bendSemisAtTime`.
|
||||
- **Open-string note** → special-cased throughout `drawNote()`: `n.f === 0`. Wider/flatter geometry, "0" label sprite, uses `openX` (the chord's open-string centroid) when supplied.
|
||||
- **Board projection ("ghost" preview)** → `// ── Board projection ──` block in `drawNote()`. Two meshes per string (`projMeshArr`, `projGlowArr`), one visible per frame for the next note. Linger window `PROJ_WIN`. Gated on the `projectionVisible` setting (BG_DEFAULTS / `h3dBgSetProjectionVisible` / the "Show note preview on the fretboard" checkbox in `settings.html`) — when off, the block is skipped and `update()`'s per-frame `m.visible = false` reset leaves the ghost hidden. **The glow has `renderOrder = -1`** which fights the strings — see Pitfall #6.
|
||||
- **Note-hit "sizzle" (slopsmith#254)** → `drawNotedetectSizzle()` (called from the `lyricsCtx` block in `draw()`, just before `drawNotedetectLabels()`). For each confirmed hit/active note (`_ndGood` in `drawNote()` pushes `{x, y, z, s, alpha, color}` onto the per-frame `_ndSizzle` array — `alpha` is the provider's clamped fade, `color` an optional palette override), it projects the note's world point through the up-to-date `cam`, sizes the burst from a fretboard-X-axis offset projection (reliable even when the note's rotated flat at the line), and twinkles a few short crackling ellipse-arc segments + tiny dots hugging the note's rectangle — re-randomised every frame, contained to ≲1.4× the note, half white / half the string colour (or the provider's `color` when given). Every dot/arc's `globalAlpha` and `shadowBlur` are scaled by the entry's `alpha`, and the per-element "off-this-frame" probability rises as `alpha` decays, so a struck-note glow visibly thins and fades. Also: `_ndGood` swaps the note's outline to `mGlow[s]` (bright string-tinted, not green). Knobs are inline: arc/dot count, base on-probability, line widths, `shadowBlur`, spread radii. Lives entirely on the 2D overlay layer — no Three.js geometry/disposal.
|
||||
|
||||
### Chords
|
||||
- **Chord rendering loop** → `update()`, `// ── Chords ──` block. Iterates `bundle.chords`, calls `drawNote()` per chord-note, then draws the frame box, name label, and barre indicator.
|
||||
- **Chord linger after hit** → the `0.55`-second value passed as the `linger` arg to `drawNote()` from inside the chord loop, and used in the chord-frame Z clamp + opacity formulas.
|
||||
- **Chord frame-box** (rectangle around frets in the chord) → inside the chord loop, search for the `drawEdge` helper. Four edges + a low-opacity fill. `isRepeat` halves the height + dims it.
|
||||
- **Chord name label (gold)** → in the same chord loop, search `chordName`. Cached via `txtMat(chordName, '#e8d080', true)`. Anchored above the chord box.
|
||||
- **Barre indicator** (white vertical line at the barre fret during linger) → in the chord loop, gated on `/barre/i.test(chordName) && chDt <= 0`. Position is `fretMid(bFret)` where `bFret` is the lowest fretted string.
|
||||
- **Repeat-chord detection** → `prevChordSig` / `prevChordTime` inside the chord loop. Same shape within 0.5 s → `isRepeat = true` (suppresses note bodies, dims frame).
|
||||
- **Chord diagram (top-left 2D overlay)** → `drawChordDiagram()`, called from the `lyricsCtx` block at the bottom of the returned `draw()`. The chord-to-display is selected in `update()` under `// ── Chord diagram: track most recently hit chord ──` and stashed in `_diagChord` (most recently hit named chord within the 0.55 s linger window).
|
||||
|
||||
### Camera
|
||||
- **Reference values** → `CAM_H_BASE`, `CAM_DIST_BASE`, `REF_ASPECT`, `FOCUS_D`, `CAM_LERP_BASE` in the constants block.
|
||||
- **Smooth lerp + look-at** → `camUpdate()`. BPM-scaled lerp speed (`CAM_LERP_BASE * bpm/120`).
|
||||
- **Self-correcting framing** → bottom half of `camUpdate()`. Projects the fretboard mid-Y to NDC, nudges `tgtLookY` until that point sits at NDC Y ≈ `DESIRED_NDC_Y` (lower third of frame). This is what lets the camera adapt automatically to ultra-wide split-screen panels.
|
||||
- **Aspect compensation** → `aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5))` in `applySize()`. Clamped to ≥ 1 so wide panels keep baseline depth (don't dolly in flat). Removing the `Math.max(1, …)` is the bug we already fixed; don't reintroduce it.
|
||||
|
||||
### Beats and sections
|
||||
- **Beat lines** (downbeats highlighted) → `update()`, `// ── Beat lines ──` block. `mBeatM` (full opacity 0.25) for measure starts, `mBeatQ` (0.07) for other beats.
|
||||
- **Section labels** → `update()`, `// ── Section labels ──` block. Cyan (`#00cccc`) sprite at fret 12, above the highest string.
|
||||
|
||||
### Highway lane (the highlighted strip under active frets)
|
||||
- **Lane drawing** → `update()`, `// ── Dynamic highway lane ──` block. `pLane` is a single quad on the fretboard plane; `pLaneDivider` is thin vertical lines at each fret inside the lane. Width keys off the active-fret range; min width ≈ 4 frets.
|
||||
- **Lane intensity** → `highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it). `_laneTargetColor = 0x4488ff` (set in `initScene()`) is the "lit" color, blended toward from `0x112233`.
|
||||
|
||||
### Lyrics & overlays
|
||||
- **Lyrics overlay** → `drawLyrics()`. 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim).
|
||||
- **Chord diagram overlay** → `drawChordDiagram()` (see "Chords" above). 2D canvas, top-left, fades over the 0.55 s linger window. Respects `inverted` (column 0 is high-e when inverted, low-E otherwise).
|
||||
- **The `lyricsCanvas`** is created in `initScene()` with `z-index:1`, appended to `wrap` **after** `ren.domElement` — this is the empirically-correct stacking order for all browsers/contexts (including splitscreen panels with `position:relative; overflow:hidden`). Don't reorder; see Pitfall #5.
|
||||
|
||||
### Splitscreen
|
||||
- **Focus dim** → `_isFocused` flag, manipulated by `_updateFocusState()`. Fades ambient + directional light intensity in non-focused panels.
|
||||
- **Per-panel resize fallback** → search `_lastHwW` in the returned `draw()`. The renderer self-detects when the highway canvas backing-store dimensions change and re-runs `applySize()`. Needed because the splitscreen plugin overrides `hw.resize` and never calls `renderer.resize()`.
|
||||
- **Reduced DPR in split** → `applySize()` clamps DPR to 1.25 when splitscreen is active vs 2 otherwise (search `baseDPR`). Keeps four-panel quad layout from melting GPUs.
|
||||
|
||||
### Splitscreen panel controls/settings
|
||||
- Per-panel background overrides use `localStorage` keys shaped as `h3d_bg_panel<N>_<key>`. When present, they override the global `h3d_bg_<key>` value for panel `N`; when absent, the global value still applies.
|
||||
- Keep per-panel keys to `BG_DEFAULTS` entries that `_bgLoadSettings()` reads. Do not add panel-only keys outside that load path.
|
||||
- `panelControls` is a static, host-readable, curated descriptor list for controls a host can expose per panel. It documents the supported per-panel surface; the renderer still loads values through `_bgLoadSettings()`.
|
||||
- Asset/background image keys remain global-only. Do not make uploaded or selected asset references panel-scoped unless that contract is explicitly widened.
|
||||
- Host refresh nudges that call toggle setters must pass real booleans, not strings such as `'false'`, so setters can distinguish `true` from `false`.
|
||||
|
||||
## The `bundle` object
|
||||
|
||||
Every per-frame renderer call receives a `bundle` from slopsmith core. Fields used by this plugin:
|
||||
|
||||
- `currentTime` — playback time in seconds (drives `dt` for everything)
|
||||
- `notes`, `chords`, `beats`, `sections` — chart arrays (already difficulty-filtered by core)
|
||||
- `chordTemplates` — array indexed by `ch.id`; each `{ name, frets: [N] }`
|
||||
- `lyrics` — syllable array `[{ w, t, d }, …]`
|
||||
- `inverted` — display flag honored via `sY(s)` (low-string-on-top vs the default low-string-on-bottom)
|
||||
- `lyricsVisible` — gate for lyrics overlay
|
||||
- `renderScale` — pixel-ratio multiplier from the user's quality setting
|
||||
- `songInfo.arrangement` — only field of `songInfo` this plugin reads, used as the bass-name fallback in `resolveStringCount()`
|
||||
- `stringCount` — slopsmith#93; always prefer this over deriving from tuning/arrangement
|
||||
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck.
|
||||
- `getNoteState(note, chartTime)` — slopsmith#254; per-note judgment from a scorer (note_detect). Captured each frame into `_ndGetNoteState` at the top of `update()` and consulted in `drawNote()` AFTER the event-driven `_ndHitMarks`/`_ndMissMarks` lookup AND over the proximity-based `hit` heuristic, both of which it overrides when it has a verdict: `'hit'`/`'active'` → `mGlow[s]` outline (bright string-tinted, *not* green) + `mGlow[s]` body + `mGlow[s]` sustain trail + a queue entry for `drawNotedetectSizzle` (so a held sustain keeps glowing/sparkling as long as the provider keeps returning `'active'`); `'miss'` → `mMissOutline` and `_showHit = false` (suppresses the bright body even if the note is near the line). Called with the note's chart time (`n.t`), which is how note_detect keys its `noteResults` map — *not* `now`. Returns null on cores without the API or songs with no scorer — then the event path / `hit` heuristic drive feedback for older note_detect builds. **notedetect ≥1.13 object verdicts additionally carry `{ points, mult, popKey }`** (game-scoring layer): `points` is the note's awarded score, `mult` the multiplier tier it landed at, and `popKey` a dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn in `drawNote()` (see Score FX below); all three are absent on older notedetect builds, so guard with `!== undefined`.
|
||||
|
||||
`tuning` and `capo` aren't consumed by this plugin.
|
||||
|
||||
### Score FX (notedetect game-scoring layer)
|
||||
|
||||
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
|
||||
- **Session FX** → `notedetect:fx` events (`{ fxType: 'multiplier'|'milestone'|'streakBreak', ... }`). notedetect dispatches each detail object twice in the same task: on `window` (unscoped, first) and as a bubbling CustomEvent from its per-panel instanceRoot (scoped, second). The listener (`_fxOnFx`, bound with the other notedetect listeners) treats element-targeted copies as authoritative — accepted only when their root lives in this panel's container — and **defers the window copy by a task** (`setTimeout 0`): if the element copy (same detail reference) arrived meanwhile it's dropped as a duplicate, otherwise it's the compat fallback for a detector whose root isn't in the DOM. This keeps splitscreen panels from rendering each other's FX even for the first event of a session. Effects: milestone → particle burst from a 4-slot Float32Array pool (`_fxBursts`), multiplier tier-up → expanding ring pulse at the strike-line centre, streak break → brief red wash.
|
||||
- **Skin palette** → `_fxResolvePalette()` reads `localStorage['slopsmith_notedetect_skin']` (`neon`/`esports`/`metal` → `_FX_PALETTES`) at listener-bind time and on the `notedetect:skin` bus event. The display fonts are document-loaded by notedetect's stylesheet, so the overlay canvas can reference the family names directly.
|
||||
- Everything lives on the 2D overlay layer — no Three.js geometry, no `txtMat()` cache traffic, nothing to dispose; `teardown()` deactivates the pools and removes both listeners.
|
||||
- **This block is the reference implementation for other renderer plugins** (drum highway, piano, custom highways) that want score pops / session FX: copy the `_fxOnFx` dedup+scoping listener, the `popKey`-keyed seen-map (cleared on backward seek), and the `_FX_PALETTES` skin mapping. The full consumer contract (events, payloads, provider verdict fields, theming variables) is documented in slopsmith-plugin-notedetect's `CLAUDE.md`.
|
||||
|
||||
If you need a bundle field that isn't here yet, check `_makeBundle()` in `static/highway.js` in the **slopsmith core repo** — this is the plugin repo, `static/highway.js` is not here. The full path in the parent slopsmith checkout is `slopsmith/static/highway.js`.
|
||||
|
||||
## Per-string state arrays
|
||||
|
||||
Several frame-local arrays are sized to `nStr`:
|
||||
|
||||
```js
|
||||
const noteState = {
|
||||
stringSustain: new Array(nStr).fill(false),
|
||||
stringAnticipation: new Array(nStr).fill(0),
|
||||
fretHeat: new Array(NFRETS + 1).fill(0),
|
||||
strGlow: new Array(nStr).fill(0.5),
|
||||
};
|
||||
```
|
||||
|
||||
Anything that indexes a per-string array MUST be guarded by `validString(s)`. The function checks that `s` is an integer in `[0, nStr)` (returning `false` otherwise so the caller can skip), warns once when an out-of-range index is seen, and keeps the `mStr / mGlow / mSus / projMeshArr` lookups safe. It does NOT clamp — out-of-range strings are dropped, not silently mapped to a valid one. `filterValidNotes(notes)` is the chord-note equivalent (allocates only when something would actually be dropped).
|
||||
|
||||
## Object pools
|
||||
|
||||
Pools live as closure refs (`pNote`, `pSus`, `pLbl`, `pBeat`, `pSec`, `pFretLbl`, `pLane`, `pLaneDivider`, `pChordBox`, `pChordLbl`, `pBarreLine`, `pNoteFretLabel`, `pConnectorLine`, `pDropLine`, `pSusOutline`).
|
||||
|
||||
The pool factory `pool(parent, mk)` returns `{ get(), reset() }`. **Every pool MUST be `.reset()`-ed at the top of `update()`** — otherwise objects from the previous frame stay visible. When you add a new pool, add the reset call too. Search for the existing block of `.reset()` calls at the top of `update()` to find where to add yours.
|
||||
|
||||
If a pool's mesh has per-instance state (its own material clone, its own texture map), set those fields each `get()` call so a recycled instance picks up the right values. The "first context wins" trap is real — recycled sprites that retain a stale `material.map` from a previous frame won't repaint. The chord-name label loops on this (search `lbl.material.map !== mat.map`) by checking before swapping.
|
||||
|
||||
## Key gotchas / pitfalls
|
||||
|
||||
1. **Adding a new pool? Reset it.** The reset block at the top of `update()` is easy to miss when adding a new pool elsewhere.
|
||||
2. **`txtMat()` is cache-keyed by `(style, text, color, wide)`.** Calling it with a numeric `text` works (it's coerced via `String(...)`), but new label content creates a new texture forever. Don't generate dynamic per-frame text (e.g. interpolated values) through `txtMat()` or you'll leak GPU memory. For static labels that change occasionally (chord names, fret numbers), the cache is fine. The `style` arg picks a preset from the `TXT_STYLES` table — see "Tweaking text-sprite styling" below.
|
||||
3. **Disposal in `teardown()` matters.** Three.js doesn't garbage-collect GPU resources. Every `material.dispose()`, `geometry.dispose()`, `map.dispose()`, and `ren.dispose()` call there is load-bearing. `teardown()` is called from `init()` (when re-initing), `destroy()` (setRenderer swap or `highway.stop()`), and on init failure.
|
||||
4. **Don't use `tuning.length` for string count.** `bundle.tuning` (and `arr.tuning` server-side) is always 6 elements even for bass — slopsmith pre-fills the array with zeros for unused strings. Use `bundle.stringCount` (slopsmith#93), with `/bass/i.test(arrangement)` as the only acceptable fallback. There's a comment in `resolveStringCount()` documenting this.
|
||||
5. **lyricsCanvas DOM order.** The 2D overlay canvas is appended to `wrap` AFTER `ren.domElement` and given `z-index:1`. This is the empirically-correct order — earlier versions had it before the WebGL canvas, which broke in splitscreen panels with `position:relative; overflow:hidden`. Don't reorder without testing both modes.
|
||||
6. **Projection glow `renderOrder = -1`** in `initScene()`. This is a known-suboptimal setting — it forces the glow to draw before the strings in the transparent queue, so the string visibly cuts through the preview. Removing the line lets natural Z-sort layer it correctly. Plus the projection's world-Y matches the string Y, which after perspective projection puts the preview slightly screen-lower than the string; bumping `projY = y + NH * 0.4` recenters it. (Both fixes live on the `fix/preview-stacking` branch.)
|
||||
7. **`renderOrder` on transparent objects is sticky.** Three.js sorts the transparent queue by `renderOrder` first, then back-to-front. A stray `m.renderOrder = -1` on something will pull it under everything regardless of Z. When in doubt, leave `renderOrder` at the default 0 and rely on Z position.
|
||||
- **Corollary: `depthTest: false` alone does NOT make a sprite "always on top."** It removes the sprite from depth-buffer comparison, but draw order in the transparent queue is still determined by `renderOrder` then Z. Anything rendered after a `depthTest: false` sprite will still overdraw it. For HUD-style overlays that must always be visible (fret-row labels — issue #35, technique callouts), set `renderOrder = 1000` AND keep `depthTest: false`. Both knobs together is the contract; either alone leaves the door open to occlusion.
|
||||
8. **`ch.id` may be missing.** Some chord events lack an `id` (or it doesn't index into `chordTemplates`). Always optional-chain: `bundle.chordTemplates?.[ch.id]?.name`. The chord diagram + name label both gate on a non-empty result.
|
||||
9. **The `aspectScale` clamp (`Math.max(1, …)`).** Without it, ultra-wide split-screen panels (top/bottom layout, ~5:1 aspect) yield aspectScale ≈ 0.33, which dollies the camera way in and kills highway depth. The clamp keeps wide panels at baseline depth and only allows narrow panels to dolly the camera back.
|
||||
10. **The `_oobStringWarned` flag is reset on `nStr` change** in the returned `draw()` — switching from guitar (6) to bass (4) re-arms the warning so a malformed bass chart still gets logged.
|
||||
11. **`renderOrder` values for the lane and dividers are explicit** in `update()` (`lane.renderOrder = 1`, `div.renderOrder = 2`). The lane plane needs to draw above the static fretboard plane (which has no renderOrder), and dividers need to draw above the lane.
|
||||
|
||||
## Tweaking colors safely
|
||||
|
||||
The eight-color palette `S_COL` is the single source of truth for per-string color. **Don't hardcode hex values inside `drawNote()` or `update()`** — every per-string color reference is either an entry in `S_COL` or one of the per-string material arrays (`mStr`, `mGlow`, `mSus`, `mProj`, `mProjGlow`) built from it.
|
||||
|
||||
If a planned color-palette feature lands (issue #10), expect it to swap the palette source array but keep this single-array indirection. Anything that hardcodes color today will break that swap; flag it during review.
|
||||
|
||||
Non-string colors (lane target `0x4488ff`, fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
|
||||
|
||||
## Tweaking text-sprite styling
|
||||
|
||||
Every text label in the 3D scene is rasterised by `txtMat(text, color, wide, style)` and the look (font, outline, drop-shadow, source-canvas resolution) is driven by a preset in the `TXT_STYLES` table at the top of `createFactory()`. **Do not edit the body of `txtMat()` to change a single label class** — change the relevant preset entry instead, so the rest stay unaffected.
|
||||
|
||||
Current presets and their callers:
|
||||
|
||||
| Preset | Used by | Default look |
|
||||
|---|---|---|
|
||||
| `fretRow` | Fret-number row under the board (`update()`, fret-row block) | Arial Black 900, 256px source canvas, 18px dark outline + soft drop-shadow — designed to pop against any background |
|
||||
| `noteFret` | Per-note connector numbers + on-body fret label (`drawNote()`) | Same heavy treatment as `fretRow` |
|
||||
| `chord` | 3D chord-name labels above chord boxes | bold sans, 128px source, 6px outline (lighter so the gold reads) |
|
||||
| `section` | Section banners ("Verse", "Chorus") at fret 12 | bold sans, 128px source, 6px outline |
|
||||
| `technique` | Bend / slide / H / P / T / PH / PM / accent / tremolo / open-string overlay | bold sans, 128px source, 6px outline |
|
||||
| `open` | The "0" label on open-string note bodies | bold sans, 128px source, 6px outline |
|
||||
|
||||
Style fields:
|
||||
|
||||
- `font` / `wideFont` — full CSS font shorthand (weight + size + family); `wideFont` is used when `wide=true` (long-aspect labels: chord names, section names, "↑1/2", "~~~"). Keep both in sync if you change weight or family.
|
||||
- `srcH` — source-canvas height in px. Wide labels use `srcH * 4` for width. Larger `srcH` keeps glyph strokes crisp after bilinear downsampling onto small sprites — bumping it from 128 → 256 was the difference between thin-and-blurry and crisp on the fret-number presets. **Keep `srcH` power-of-two** (128, 256, 512, …): WebGL1 and Three.js silently disable mipmap generation on NPOT textures and fall back to a non-mipmap min-filter, which causes shimmer/aliasing on labels far down the highway. The 4× width derivation preserves POT-ness too (e.g. 256 → 1024 wide).
|
||||
- `stroke` / `strokeW` — outline color and line-width in source-canvas px. Set `stroke: null` or `strokeW: 0` to skip the outline (faster cache rasterisation, no contrast halo).
|
||||
- `shadow` — `{ color, blur, dx, dy }` or `null`. Drawn via canvas 2D `shadowColor` / `shadowBlur` / `shadowOffsetX/Y` *before* the stroke and fill, so it haloes the whole glyph.
|
||||
|
||||
**Cache key includes the preset name** (`style|wide|text|color`), so two presets with otherwise-identical text produce two distinct cached materials. Adding a new preset is safe — just add the entry to `TXT_STYLES` and pass its name as the 4th arg at the call site. Forgetting to pass `style` falls back to `'technique'` (the broadest, most generic preset) and is the right default for a brand-new label class.
|
||||
|
||||
**Don't generate per-frame distinct text through `txtMat()`** (e.g. interpolated values, tick counters). The cache is unbounded and will leak GPU memory across the session — see Pitfall #2.
|
||||
|
||||
## Lifecycle (setRenderer contract)
|
||||
|
||||
Per slopsmith#36, the factory returns `{ init, draw, resize, destroy }`:
|
||||
|
||||
- **`init(canvas, bundle)`** tears down any prior state, sets `highwayCanvas`, lazily loads Three.js, runs `initScene()`, calls `applySize()` (with a `retrySize` rAF loop fallback if the canvas isn't laid out yet).
|
||||
- **`draw(bundle)`** is gated on `_isReady`. Re-resolves `nStr` / inverted / renderScale, then `update(bundle) → camUpdate(bundle) → ren.render → 2D overlays`. The `_lastHwW/_lastHwH` check at the top auto-resizes when the splitscreen plugin bypasses `resize()`.
|
||||
- **`resize(w, h)`** is gated on `_isReady`. Just calls `applySize()`.
|
||||
- **`destroy()`** is idempotent. Sets flags, runs `teardown()`, drops `highwayCanvas`. Tolerates being called on an instance that's been destroyed and re-init'd already (resets `_lastHwW/H`, `_diagChord`, etc.).
|
||||
|
||||
The factory **returns a fresh instance per call**, so splitscreen's per-panel `setRenderer(slopsmithViz_highway_3d())` gets independent state per panel — important because the chord diagram, projection meshes, etc. are all per-instance.
|
||||
|
||||
## Branching / PR conventions
|
||||
|
||||
- Feature branches off `main`, descriptive name (e.g. `fix/preview-stacking`, `feat/palette-picker`).
|
||||
- PR target: target the contributor's own fork by default unless they ask otherwise; confirm before opening a PR upstream. Run `git remote -v` in this directory to see the remotes that are configured locally.
|
||||
- Commit messages: short imperative subject, optional body explaining *why*. Don't summarize the diff — the diff already does that.
|
||||
- This plugin is bundled **in-tree** at `plugins/highway_3d/` inside the `byrongamatos/slopsmith` repository (not a gitlink/submodule). It ships with the default container image. Changes go through the normal slopsmith PR process — no separate upstream repo to sync.
|
||||
|
||||
## When in doubt
|
||||
|
||||
- `screen.js` is one file — `Grep` for the function name or banner text before guessing.
|
||||
- The constants block at the top is intentionally exhaustive; scan it before introducing a new magic number.
|
||||
- If a "polish" feels like it should be one or two lines but stretches into restructuring, double-check whether a per-frame state field, pool reset, or `validString()` guard already covers your case.
|
||||
@@ -0,0 +1,86 @@
|
||||
# 3D Highway — Free-Camera Bridge
|
||||
|
||||
> 🇬🇧 English · 🇪🇸 Español más abajo
|
||||
|
||||
## What this modification does (EN)
|
||||
|
||||
This change adds a small, **opt-in** hook inside `camUpdate()` in
|
||||
[`screen.js`](./screen.js) that lets an external plugin drive the 3D Highway
|
||||
camera (orbit, height, zoom, tilt, pan) **without forking the renderer**.
|
||||
|
||||
The renderer reads a single shared object once per frame:
|
||||
|
||||
```js
|
||||
window.__h3dCamCtl = {
|
||||
enabled, // master switch — when false the renderer auto-frames as usual
|
||||
heightMul, // camera height multiplier
|
||||
distMul, // dolly / zoom multiplier
|
||||
yaw, // orbit around the look target (radians)
|
||||
pitch, // tilt offset (highway K-units)
|
||||
panX, panY // look-target pan (highway K-units)
|
||||
};
|
||||
```
|
||||
|
||||
**Safety / backward compatibility**
|
||||
- The bridge object is read **once** (`_freeCam`) and reused for both the
|
||||
position and the look-at transforms.
|
||||
- Every field is coerced with `Number.isFinite` to a safe default
|
||||
(`heightMul`/`distMul → 1`, everything else → `0`) before use, so a malformed
|
||||
bridge object can **never** feed `NaN` into `cam.position.set` / `cam.lookAt`.
|
||||
- When `window.__h3dCamCtl` is absent or `enabled === false`, behaviour is
|
||||
**byte-for-byte identical** to before: the `if` is skipped and `lookAt` uses
|
||||
the existing `else` path.
|
||||
|
||||
The shared `-FOCUS_D * 0.35` look-at Z is computed once (`_lookAtZ`) and reused.
|
||||
|
||||
## The plugin that uses this bridge
|
||||
|
||||
**Camera Director** — a floating, bilingual (EN/ES) control panel to author,
|
||||
save and share highway camera views:
|
||||
|
||||
➡️ **https://github.com/nimuart/cameradirector_feedback**
|
||||
|
||||
Camera Director creates and writes `window.__h3dCamCtl`; this renderer only
|
||||
reads it. That one-object contract is the entire integration surface — no other
|
||||
globals, no patching of the renderer's internals.
|
||||
|
||||
---
|
||||
|
||||
## Qué hace esta modificación (ES)
|
||||
|
||||
Este cambio agrega un hook pequeño y **opcional** dentro de `camUpdate()` en
|
||||
[`screen.js`](./screen.js) que permite que un plugin externo maneje la cámara del
|
||||
3D Highway (órbita, altura, zoom, inclinación, paneo) **sin tener que forkear el
|
||||
renderer**.
|
||||
|
||||
El renderer lee un único objeto compartido una vez por frame:
|
||||
|
||||
```js
|
||||
window.__h3dCamCtl = {
|
||||
enabled, // interruptor maestro — si es false, el renderer encuadra solo
|
||||
heightMul, // multiplicador de altura
|
||||
distMul, // multiplicador de dolly / zoom
|
||||
yaw, // órbita alrededor del objetivo (radianes)
|
||||
pitch, // inclinación (unidades K del highway)
|
||||
panX, panY // paneo del objetivo (unidades K del highway)
|
||||
};
|
||||
```
|
||||
|
||||
**Seguridad / compatibilidad**
|
||||
- El objeto se lee **una sola vez** (`_freeCam`) y se reutiliza para la posición
|
||||
y para el look-at.
|
||||
- Cada campo se valida con `Number.isFinite` y cae a un default seguro
|
||||
(`heightMul`/`distMul → 1`, el resto → `0`), así un objeto mal formado **nunca**
|
||||
mete `NaN` en `cam.position.set` / `cam.lookAt`.
|
||||
- Si `window.__h3dCamCtl` no existe o `enabled === false`, el comportamiento es
|
||||
**idéntico** al de antes.
|
||||
|
||||
## El plugin que usa este puente
|
||||
|
||||
**Camera Director** — panel flotante y bilingüe (EN/ES) para crear, guardar y
|
||||
compartir vistas de cámara del highway:
|
||||
|
||||
➡️ **https://github.com/nimuart/cameradirector_feedback**
|
||||
|
||||
Camera Director crea y escribe `window.__h3dCamCtl`; este renderer solo lo lee.
|
||||
Ese contrato de un solo objeto es toda la superficie de integración.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 3D Highway
|
||||
|
||||
A 3D note highway visualization for [Slopsmith](https://github.com/byrongamatos/slopsmith) — an alternative to the default 2D highway, with a sense of depth and perspective inspired by stage views in modern rhythm games.
|
||||
|
||||
## What you get
|
||||
|
||||
- A camera-perspective highway with notes flying down toward a virtual fretboard at the bottom of the screen
|
||||
- Glowing strings that pulse and brighten on each hit
|
||||
- Note Detection feedback, including hit/miss outlines and diagnostic
|
||||
early/late/sharp/flat labels when the note detection plugin emits enriched
|
||||
judgments
|
||||
- Chord frame-boxes, named-chord labels, and a chord diagram overlay (configurable corner position) so you can read shapes at a glance
|
||||
- Two complementary barre indicators fire together when a barre chord shape is detected (2+ consecutive strings fretted at the lowest fret, e.g. F `[1,1,2,3,3,1]`, or an outer-edge full-span barre with every intermediate string fretted, e.g. B major `x24442`): a translucent vertical line across the strings on the 3D highway, and a straight bracket drawn inside the first fret space of the chord diagram overlay
|
||||
- A heat-colored fret number row that lights up around your active playing region
|
||||
- Selectable color palettes for the strings — pick the look you want
|
||||
- Audio-reactive ambient background animations (particles, silhouettes, stage lights, geometric — pick one or turn it off)
|
||||
- Lyrics overlay synced to the song
|
||||
- Works as the main player view *or* per-panel inside the splitscreen plugin
|
||||
|
||||
## Install
|
||||
|
||||
3D Highway ships **bundled** with Slopsmith — no separate installation needed. Pick **3D Highway** from the visualization picker in the player.
|
||||
|
||||
> **Note:** The bundled version is preferred over any user-installed copy with the same plugin ID. If you have an old `slopsmith-plugin-3dhighway` clone on disk (from before 3D Highway was promoted to core), it will be ignored at startup — a warning in the server log names the path of the discarded copy. You can safely delete the stale clone.
|
||||
>
|
||||
> **Fallback:** In the unlikely event that the bundled copy fails to load its routes (e.g., a broken bundled release), Slopsmith will automatically fall back to your user-installed copy and show a yellow "Fallback" badge in the Settings panel. Check the server startup log for the root cause in that case.
|
||||
|
||||
## Settings
|
||||
|
||||
Most of the visual controls (background style, intensity, audio reactivity, color palette) live on Slopsmith's **Settings** screen under the *3D Highway* section.
|
||||
|
||||
## Contributing / development
|
||||
|
||||
For maintainers and AI assistants working on the codebase, see [`CLAUDE.md`](CLAUDE.md) — it's a navigation guide that maps every visual element to where it lives in `screen.js`, plus the gotchas worth knowing before tweaking.
|
||||
|
||||
### Perf bench (`?h3dbench=1`)
|
||||
|
||||
Append `?h3dbench=1` to the player URL to enable opt-in `console.log` reporting of `update()` self-time, broken into six segments — `frame` (everything between `pbBeg(0)` at the top of `update()` and `pbEnd(0)` at the bottom; excludes the trailing `pbReportTick()` logging that fires after `pbEnd(0)`), `state` (per-frame state-derivation loop), `next` (next-note-by-string lookahead), `mat` (per-string material writes), `noteDraw` (single-note draw loop), `chordDraw` (chord draw loop). Reported every 5 seconds with p50 / p95 / max per segment and frame count, so before/after numbers on a target chart are reproducible (slopsmith#226). Off-by-default; the bench helpers (`pbBeg` / `pbEnd` / `pbReportTick`) are bound to a shared empty-function literal when the renderer instance is created (each `createHighway()` panel re-checks the flag), so the hot-path call sites are no-ops with negligible overhead (typically JIT-inlined).
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Tailwind input for the 3D Highway plugin's own stylesheet.
|
||||
Utilities only — core ships the single base reset (preflight), so this
|
||||
plugin builds with corePlugins.preflight=false and must NOT re-include
|
||||
`@tailwind base`. Generated artifact: assets/plugin.css (committed).
|
||||
Regenerate with: bash build-tailwind.sh */
|
||||
@tailwind utilities;
|
||||
File diff suppressed because one or more lines are too long
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate this plugin's own stylesheet (assets/plugin.css) from its content
|
||||
# globs. Maintainer task — the generated CSS is committed, so end users / Docker
|
||||
# / desktop builds never run this. Run it whenever you add Tailwind classes to
|
||||
# screen.js / settings.html, and bump the plugin.json `version` so the injected
|
||||
# <link>'s ?v= cache-buster fetches the fresh file.
|
||||
#
|
||||
# Pin the same Tailwind 3.x core uses so output stays diff-stable across
|
||||
# rebuilds. Utilities only (corePlugins.preflight=false in tailwind.config.js) —
|
||||
# core ships the one base reset.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
exec npx -y tailwindcss@3.4.19 \
|
||||
-c tailwind.config.js \
|
||||
-i _plugin.src.css \
|
||||
-o assets/plugin.css \
|
||||
--minify
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.26.0",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
"styles": "assets/plugin.css",
|
||||
"settings": { "html": "settings.html", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
|
||||
"routes": "routes.py",
|
||||
"tour": "tour.json"
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Plugin-registered FastAPI routes for the 3dhighway visualization plugin.
|
||||
|
||||
Registered by slopsmith core via plugin.json's "routes" field — the
|
||||
loader at plugins/__init__.py:589–604 imports this module and calls
|
||||
setup(app, context). context["config_dir"] points at the slopsmith
|
||||
data directory; we namespace user uploads under
|
||||
{config_dir}/plugin_uploads/highway_3d/.
|
||||
|
||||
This module owns the upload/serve/delete endpoints for the `video` bg
|
||||
style (issue #19 follow-up). Single deterministic slot — each upload
|
||||
replaces the previous file, no orphan accumulation. localStorage on
|
||||
the renderer side stores only the filename, never the bytes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
PLUGIN_ID = "highway_3d"
|
||||
ALLOWED_VIDEO_EXTS = {"mp4", "webm"}
|
||||
ALLOWED_VIDEO_MIMES = {"video/mp4", "video/webm"}
|
||||
MAX_VIDEO_BYTES = 50 * 1024 * 1024 # 50 MB raw
|
||||
|
||||
# Filenames the GET endpoint accepts. Tightened to the exact slot
|
||||
# pattern this plugin produces — anything else (leftover upload-*.part
|
||||
# temp files from a crashed upload, future schema additions, manual
|
||||
# disk edits) gets a 404 rather than being served. The previous
|
||||
# permissive regex would have happily streamed a `.part` file to a
|
||||
# client that knew the name.
|
||||
SLOT_FILENAME_RE = re.compile(r"^current\.(mp4|webm)$")
|
||||
|
||||
|
||||
def setup(app: FastAPI, context: dict) -> None:
|
||||
config_dir = Path(context["config_dir"])
|
||||
upload_dir = config_dir / "plugin_uploads" / PLUGIN_ID
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Serialises the atomic replace + other-ext cleanup so two concurrent
|
||||
# uploads of different extensions (e.g. mp4 and webm) can't both finish
|
||||
# streaming before either cleans up, leaving both files on disk. Streaming
|
||||
# itself (the slow part) happens outside the lock; only the final
|
||||
# replace + cleanup is held under it — so concurrent uploads of the *same*
|
||||
# extension still overlap for all but the last microsecond.
|
||||
_slot_lock = asyncio.Lock()
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/files")
|
||||
async def upload_file(request: Request):
|
||||
# Pre-parse Content-Length guard — fires before ANY body reading.
|
||||
#
|
||||
# FastAPI only reads request.form() when the handler/dependency
|
||||
# explicitly asks for it. By accepting `Request` directly (rather
|
||||
# than `file: UploadFile = File(...)`), we get headers without
|
||||
# consuming the body. If Content-Length already indicates the
|
||||
# upload is too large, we return 413 immediately — python-multipart
|
||||
# never buffers a byte to disk.
|
||||
#
|
||||
# Clients that omit or forge Content-Length fall through to the
|
||||
# streaming chunk-count cap in _do_upload, which remains as a
|
||||
# defence-in-depth fallback.
|
||||
cl = request.headers.get("content-length")
|
||||
if cl is not None:
|
||||
try:
|
||||
cl_int = int(cl)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid Content-Length header.")
|
||||
if cl_int < 0:
|
||||
raise HTTPException(400, "Invalid Content-Length header.")
|
||||
if cl_int > MAX_VIDEO_BYTES:
|
||||
raise HTTPException(
|
||||
413,
|
||||
f"Upload exceeds {MAX_VIDEO_BYTES // (1024 * 1024)} MB limit.",
|
||||
)
|
||||
|
||||
# Body is only consumed here, after the Content-Length pre-check.
|
||||
form = await request.form()
|
||||
try:
|
||||
file = form.get("file")
|
||||
if not isinstance(file, UploadFile):
|
||||
raise HTTPException(400, "Expected a file upload in field 'file'.")
|
||||
try:
|
||||
return await _do_upload(file)
|
||||
finally:
|
||||
try:
|
||||
await file.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# Release the form object (closes any remaining SpooledTemporaryFile
|
||||
# references that weren't already closed by file.close() above).
|
||||
try:
|
||||
await form.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _do_upload(file: UploadFile):
|
||||
# Extension whitelist is the primary guard — file.filename
|
||||
# (and thus the extension) is always present on a real upload,
|
||||
# whereas content_type is unreliable: some OS / browser combos
|
||||
# report it as empty or as the generic application/octet-stream
|
||||
# for valid .mp4 / .webm files. This mirrors settings.html's
|
||||
# client-side fallback so a working browser doesn't 400 here
|
||||
# after passing the client check. Server-side raw decoding
|
||||
# is left to the browser's <video> element on play.
|
||||
ext = (Path(file.filename or "").suffix.lstrip(".") or "").lower()
|
||||
if ext not in ALLOWED_VIDEO_EXTS:
|
||||
raise HTTPException(400, "Filename must end in .mp4 or .webm.")
|
||||
# MIME check applies only when the client supplied something
|
||||
# specific. Empty / octet-stream / None mean "browser couldn't
|
||||
# tell" — fall through to the extension whitelist that already
|
||||
# passed above.
|
||||
if (
|
||||
file.content_type
|
||||
and file.content_type != "application/octet-stream"
|
||||
and file.content_type not in ALLOWED_VIDEO_MIMES
|
||||
):
|
||||
raise HTTPException(400, "Only MP4 and WebM are allowed.")
|
||||
|
||||
# Stream the body to a temp file so we never hold the full 50 MB
|
||||
# in memory (and never doubled — the previous version buffered
|
||||
# chunks AND a joined bytes object). Writes go through
|
||||
# run_in_threadpool so the event loop isn't blocked by a
|
||||
# multi-second disk write. Atomic os.replace at the end means
|
||||
# a server crash mid-upload leaves the previous slot file
|
||||
# intact rather than a half-written current.<ext>.
|
||||
out_name = f"current.{ext}"
|
||||
out_path = upload_dir / out_name
|
||||
# mkstemp on the same filesystem as out_path is required for
|
||||
# os.replace to be atomic. Suffix marks the partial so a stray
|
||||
# leftover from a crashed upload is obvious on inspection.
|
||||
fd, tmp_name = await run_in_threadpool(
|
||||
tempfile.mkstemp, dir=str(upload_dir), prefix="upload-", suffix=".part"
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
bytes_read = 0
|
||||
try:
|
||||
# Wrap the raw fd in a Python file object so writes are
|
||||
# guaranteed-complete: os.write can return a short write on
|
||||
# some platforms / fd states and would silently truncate the
|
||||
# upload. The buffered file object loops internally and
|
||||
# raises on real errors.
|
||||
#
|
||||
# If fdopen itself fails, the fd hasn't been wrapped yet, so
|
||||
# the outer try's tmpf.close path can't reach it — close
|
||||
# manually here. The outer except still unlinks tmp_path.
|
||||
try:
|
||||
tmpf = await run_in_threadpool(os.fdopen, fd, "wb")
|
||||
except BaseException:
|
||||
try:
|
||||
await run_in_threadpool(os.close, fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
bytes_read += len(chunk)
|
||||
if bytes_read > MAX_VIDEO_BYTES:
|
||||
raise HTTPException(
|
||||
413,
|
||||
f"Video exceeds {MAX_VIDEO_BYTES // (1024 * 1024)} MB cap.",
|
||||
)
|
||||
await run_in_threadpool(tmpf.write, chunk)
|
||||
finally:
|
||||
# Close before any rename / unlink to avoid Windows
|
||||
# file-locking surprises. Close also flushes the
|
||||
# buffer so the bytes are on disk before os.replace.
|
||||
await run_in_threadpool(tmpf.close)
|
||||
|
||||
# Reject empty uploads before the atomic rename. Without
|
||||
# this guard, a misbehaving client (or a multipart body
|
||||
# whose file part is empty) would replace the slot with a
|
||||
# 0-byte file that the renderer then tries — and fails —
|
||||
# to play. Existing slot stays untouched; the temp file
|
||||
# is cleaned up by the outer except.
|
||||
if bytes_read == 0:
|
||||
raise HTTPException(400, "Empty upload — file is 0 bytes.")
|
||||
|
||||
# Hold the slot lock for the atomic replace + other-ext
|
||||
# cleanup. Streaming (above) happens outside the lock so
|
||||
# concurrent uploads of different extensions can overlap
|
||||
# for most of their duration. Only the final commit is
|
||||
# serialised. Under the lock there are no concurrent
|
||||
# writers, so we can safely delete the opposite slot
|
||||
# without a snapshot — whichever upload acquires the lock
|
||||
# second simply supersedes the first, and the first
|
||||
# upload's cleanup (which already ran) may have removed
|
||||
# the second's now-absent file, or the second's cleanup
|
||||
# removes the first's file now. Either way at most one
|
||||
# slot file survives after the lock is released.
|
||||
async with _slot_lock:
|
||||
await run_in_threadpool(os.replace, str(tmp_path), str(out_path))
|
||||
for e in ALLOWED_VIDEO_EXTS - {ext}:
|
||||
try:
|
||||
await run_in_threadpool((upload_dir / f"current.{e}").unlink)
|
||||
except OSError:
|
||||
# Another process holding the file (antivirus,
|
||||
# in-flight GET) shouldn't 500 the upload. The
|
||||
# new file is already in place; the stale one
|
||||
# will get retried on the next upload or Clear.
|
||||
pass
|
||||
except BaseException:
|
||||
# Any failure (size cap, write error, even cancellation):
|
||||
# remove the temp file so we don't leak partial uploads on
|
||||
# disk. unlink_missing_ok would be cleaner but isn't on
|
||||
# older Python versions.
|
||||
try:
|
||||
await run_in_threadpool(tmp_path.unlink)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
return {
|
||||
"url": f"/api/plugins/{PLUGIN_ID}/files/{out_name}",
|
||||
"name": out_name,
|
||||
"size": bytes_read,
|
||||
}
|
||||
|
||||
@app.get(f"/api/plugins/{PLUGIN_ID}/files/{{filename}}")
|
||||
async def get_file(filename: str):
|
||||
if not SLOT_FILENAME_RE.match(filename):
|
||||
raise HTTPException(404, "Not found.")
|
||||
path = upload_dir / filename
|
||||
# Defense-in-depth: even with the regex above, resolve and
|
||||
# confirm the resolved path stays inside upload_dir. Catches
|
||||
# any future regex regression or symlink trickery.
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(upload_dir.resolve())
|
||||
except (OSError, ValueError):
|
||||
raise HTTPException(404, "Not found.")
|
||||
if not resolved.is_file():
|
||||
raise HTTPException(404, "Not found.")
|
||||
ext = resolved.suffix.lstrip(".").lower()
|
||||
media = {"mp4": "video/mp4", "webm": "video/webm"}.get(
|
||||
ext, "application/octet-stream"
|
||||
)
|
||||
# The slot URL is stable across re-uploads (we always overwrite
|
||||
# current.<ext> in place), so without explicit cache headers a
|
||||
# browser or upstream proxy will happily serve the previous
|
||||
# video after a Replace operation. `no-cache` lets the cache
|
||||
# store a copy but forces revalidation on every load — paired
|
||||
# with the Last-Modified / ETag headers FileResponse adds, the
|
||||
# browser sends If-Modified-Since and gets a 304 when unchanged.
|
||||
return FileResponse(
|
||||
resolved,
|
||||
media_type=media,
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
# nosniff prevents the browser from second-guessing the
|
||||
# MIME we declared. The MIME is fixed to video/mp4 or
|
||||
# video/webm by the slot pattern, but a malicious
|
||||
# upload could try to sneak past via an allowed
|
||||
# extension carrying e.g. HTML; nosniff keeps the
|
||||
# browser from rendering it as anything other than a
|
||||
# video stream.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@app.delete(f"/api/plugins/{PLUGIN_ID}/files")
|
||||
async def delete_files():
|
||||
# Slot-level clear: removes every current.* in the upload dir
|
||||
# regardless of extension. This is the only delete operation
|
||||
# the client needs — the previous per-filename DELETE could
|
||||
# leak the other extension's file (e.g. clearing current.mp4
|
||||
# while current.webm survives) when client and server got out
|
||||
# of sync about which slot was active.
|
||||
#
|
||||
# Best-effort + always 200: file-locking on Windows (an
|
||||
# in-flight GET, antivirus, an OS file scanner) can transiently
|
||||
# block unlink, and a 500 response would silently leave the
|
||||
# client's localStorage in a "still has video" state because
|
||||
# the UI doesn't update on a failed clear. The user's actual
|
||||
# intent — "stop using this video" — is best served by
|
||||
# returning success so the client clears its pointer and the
|
||||
# next render uses the fallback style. Any leftover file
|
||||
# comes back in `leftover` for visibility; operators or the
|
||||
# next upload's pre-cleanup loop will handle it.
|
||||
# Hold the slot lock so a concurrent upload's os.replace() can't
|
||||
# sneak a new current.* into the slot between our glob and our
|
||||
# unlink calls. Without the lock, a DELETE that interleaves with
|
||||
# an upload could return "cleared" while the upload's replace
|
||||
# commits a fresh file immediately after the unlink.
|
||||
async with _slot_lock:
|
||||
slot_paths = await run_in_threadpool(
|
||||
lambda: list(upload_dir.glob("current.*"))
|
||||
)
|
||||
deleted = []
|
||||
leftover = []
|
||||
for path in slot_paths:
|
||||
try:
|
||||
await run_in_threadpool(path.unlink)
|
||||
deleted.append(path.name)
|
||||
except OSError:
|
||||
leftover.append({"name": path.name, "error": "unlink failed"})
|
||||
return JSONResponse({"ok": True, "deleted": deleted, "leftover": leftover})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Tailwind build config for the 3D Highway plugin's OWN stylesheet.
|
||||
*
|
||||
* Slopsmith serves Tailwind as a prebuilt stylesheet and core only scans core
|
||||
* source at build time (constitution Principle II — no Play CDN / runtime JIT).
|
||||
* This plugin owns its utilities so it styles correctly even when core's build
|
||||
* didn't scan it (it's excluded from core's content globs). It uses arbitrary
|
||||
* values (`text-[10px]`, `max-w-[12rem]`) that no "complete" Tailwind set
|
||||
* contains, so a self-built, content-scanned sheet is mandatory.
|
||||
*
|
||||
* Regenerate assets/plugin.css with: bash build-tailwind.sh
|
||||
*/
|
||||
module.exports = {
|
||||
// Core ships the single base reset; this plugin emits utilities only so it
|
||||
// doesn't double the preflight and fight core's styles.
|
||||
corePlugins: { preflight: false },
|
||||
content: [
|
||||
// List only the files that carry Tailwind classes — screen.js (renderer
|
||||
// + HUD markup) and settings.html. A broad ./*.{js,html} would also scan
|
||||
// THIS config (its comments mention class-like strings such as
|
||||
// text-[10px]) and emit them spuriously; tour.json is plain text.
|
||||
'./screen.js',
|
||||
'./settings.html',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
// Mirror core's theme tokens so classes like `bg-dark-700` compile
|
||||
// inside this standalone build.
|
||||
colors: {
|
||||
dark: { 900: '#050508', 800: '#0a0a12', 700: '#10101e', 600: '#181830', 500: '#1e1e3a' },
|
||||
accent: { DEFAULT: '#4080e0', light: '#60a0ff', dark: '#2060b0' },
|
||||
gold: '#e8c040',
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['"Inter"', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Belt-and-suspenders for any dark/accent class built indirectly (none are
|
||||
// today — all usage is literal — but this keeps the sheet self-sufficient).
|
||||
safelist: [
|
||||
{ pattern: /^(bg|text|border)-(dark|accent)(-.+)?$/ },
|
||||
],
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tour": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"title": "Welcome to the 3D Highway",
|
||||
"content": "This plugin renders your song's note chart as a live 3D highway. Let's take a quick look at what you can do.",
|
||||
"shape": "bubble",
|
||||
"position": "auto"
|
||||
},
|
||||
{
|
||||
"id": "canvas",
|
||||
"selector": ".h3d-wrap[data-h3d-primary]",
|
||||
"waitFor": ".h3d-wrap[data-h3d-primary]",
|
||||
"title": "The Note Highway",
|
||||
"content": "Notes flow toward you in 3D. The glowing horizontal line marks the strum point — hit notes as they cross it.",
|
||||
"shape": "spotlight",
|
||||
"position": "bottom"
|
||||
},
|
||||
{
|
||||
"id": "camera",
|
||||
"selector": "#player",
|
||||
"title": "Auto-Tracking Camera",
|
||||
"content": "The camera automatically follows the action, smoothly panning to keep the active fret range in frame. Adjust camera height, distance, and smoothing in Settings → 3D Highway.",
|
||||
"shape": "bubble",
|
||||
"position": "top"
|
||||
},
|
||||
{
|
||||
"id": "customise",
|
||||
"selector": "#player",
|
||||
"title": "Customise the Look",
|
||||
"content": "Open Settings → 3D Highway to change colour palettes, background effects, glow intensity, and camera smoothing.",
|
||||
"shape": "bubble",
|
||||
"position": "left"
|
||||
},
|
||||
{
|
||||
"id": "outro",
|
||||
"title": "You're ready to shred",
|
||||
"content": "That's the 3D Highway. Load a song and hit Play to see it in action. Restart this tour any time with the ? button.",
|
||||
"shape": "bubble",
|
||||
"position": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# slopsmith-plugin-minigames
|
||||
|
||||
The minigame framework for [Slopsmith](https://github.com/byrongamatos/slopsmith).
|
||||
|
||||
This plugin provides:
|
||||
|
||||
- A **Minigames hub** screen that discovers every installed minigame plugin and lists them as tiles with leaderboards.
|
||||
- A **shared profile** (XP, level, unlocks, totals) that aggregates runs across every minigame.
|
||||
- A JS **SDK** exposed at `window.slopsmithMinigames` that minigame plugins use to access scoring, HUD primitives, run persistence, and a scheduler — so individual minigames do not need their own DSP or backend.
|
||||
|
||||
## Writing a minigame
|
||||
|
||||
A minigame is a standard Slopsmith plugin that:
|
||||
|
||||
1. Adds a `minigame` block to its `plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_game",
|
||||
"name": "My Game",
|
||||
"version": "0.1.0",
|
||||
"script": "game.js",
|
||||
"minigame": {
|
||||
"title": "My Game",
|
||||
"tagline": "Short pitch",
|
||||
"type": "chart-free",
|
||||
"scoring": "pitch-continuous",
|
||||
"thumbnail": "thumb.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. On script load, registers itself with the SDK using the safe late-binding
|
||||
pattern (minigame plugins may load before the SDK; the pending queue
|
||||
handles both orderings — the SDK drains it on init, and the
|
||||
`slopsmith-minigames-ready` event is an alternative for plugins that prefer
|
||||
event-driven registration):
|
||||
|
||||
> **Important:** `spec.id` must exactly match the `id` field in `plugin.json`.
|
||||
> The backend registry uses `plugin_id` (sourced from `plugin.json`) and the
|
||||
> hub UI merges manifest metadata (title, tagline, thumbnail, modifiers) by
|
||||
> that key. If `spec.id` and `plugin.json` `id` diverge, runs and leaderboard
|
||||
> data will be attributed to an unrecognised game and display metadata (title,
|
||||
> thumbnail) will fall back to JS-spec values rather than the richer manifest
|
||||
> values.
|
||||
|
||||
```js
|
||||
const spec = {
|
||||
id: 'my_game',
|
||||
start: ({ container, modifiers, sdk }) => { /* mount game into container */ },
|
||||
stop: () => { /* tear down */ },
|
||||
};
|
||||
|
||||
if (window.slopsmithMinigames) {
|
||||
window.slopsmithMinigames.register(spec);
|
||||
} else {
|
||||
(window.__slopsmithMinigamesPending = window.__slopsmithMinigamesPending || []).push(spec);
|
||||
}
|
||||
```
|
||||
|
||||
3. Calls `window.slopsmithMinigames.end({ score, durationMs, modifiers, meta })` when the run ends.
|
||||
|
||||
See [`slopsmith-plugin-flappy-bend`](https://github.com/byrongamatos/slopsmith-plugin-flappy-bend) for a working example.
|
||||
|
||||
## SDK reference
|
||||
|
||||
`window.slopsmithMinigames` exposes:
|
||||
|
||||
- `register(spec)` — declare a minigame
|
||||
- `start(gameId, opts)` / `end(result)` — lifecycle
|
||||
- `scoring.createContinuous(opts)` — emits per-frame pitch (YIN, ~60 Hz)
|
||||
- `scoring.createDiscrete(opts)` — wraps `createNoteDetector`, emits hit/miss
|
||||
- `scoring.createChord(opts)` — full chord scorer (delegates to note_detect)
|
||||
- `ui.mountHUD(html)` / `ui.runSummary(result)` / `ui.modifierPicker(defs)`
|
||||
- `submitRun(...)` / `getLeaderboard(...)` / `getProfile()`
|
||||
- `scheduler.every(ms, cb)` / `in(ms, cb)` / `cancel(id)`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `slopsmith-plugin-notedetect` >= 1.10.0 — required for discrete/chord scoring modes (continuous mode is self-contained).
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "minigames",
|
||||
"name": "Minigames",
|
||||
"version": "0.1.0",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"server_files": [
|
||||
"minigames/"
|
||||
]
|
||||
},
|
||||
"diagnostics": {
|
||||
"server_files": [
|
||||
"minigames/profile.json"
|
||||
]
|
||||
},
|
||||
"routes": "routes.py"
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
"""Minigames framework — leaderboard + profile backend.
|
||||
|
||||
State lives under `<config_dir>/minigames/`:
|
||||
- `runs.db` SQLite, one row per run, indexed by game_id + created_at.
|
||||
- `profile.json` Cross-minigame profile (xp, level, unlocks, totals).
|
||||
|
||||
Endpoints (all under /api/plugins/minigames/):
|
||||
POST /runs submit a finished run; awards XP, evaluates unlocks
|
||||
GET /runs list runs (filter by game_id, scope, limit)
|
||||
GET /profile current XP/level/unlocks/totals
|
||||
POST /profile/reset wipe profile + runs
|
||||
GET /registry list of installed minigame plugins (server-side mirror
|
||||
of what the frontend can also see)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
# Separate lock for the registry cache so _list_minigame_plugins() can be
|
||||
# called safely from within a _lock-held section (e.g. submit_run) without
|
||||
# deadlocking.
|
||||
_registry_lock = threading.Lock()
|
||||
_state = {
|
||||
"db_path": None,
|
||||
"profile_path": None,
|
||||
"plugins_dir_resolver": None,
|
||||
"log": logging.getLogger("slopsmith.plugin.minigames"),
|
||||
# fee[dB]ack v0.3.0 unified XP: when running inside core these point at the
|
||||
# single core XP store (server.py plugin_context). XP then flows to ONE
|
||||
# store the profile badge reads. Absent when the plugin runs standalone,
|
||||
# in which case the legacy profile.json xp accounting is used.
|
||||
"award_xp": None, # (amount, source) -> progress dict {xp, level, ...}
|
||||
"get_xp_progress": None, # () -> progress dict
|
||||
"seed_xp": None, # (amount, marker) -> bool (one-time migration)
|
||||
"reset_xp": None, # (source) -> progress dict (reset this source's core XP)
|
||||
}
|
||||
|
||||
# TTL cache for the minigame plugin scan. Walking the filesystem on every
|
||||
# /registry call and run submission is cheap for small plugin counts but adds
|
||||
# up on repeated calls. Cache results for _REGISTRY_TTL_S seconds; callers
|
||||
# that need a fresh scan (e.g. after a plugin hot-reload) can use
|
||||
# _list_minigame_plugins(force_refresh=True).
|
||||
_REGISTRY_TTL_S = 10
|
||||
_registry_cache: dict = {"ts": 0.0, "data": []}
|
||||
|
||||
# Maximum byte-length of the serialised `modifiers` and `meta` JSON fields on a
|
||||
# run submission. Prevents a single call from bloating runs.db with arbitrary
|
||||
# payload (32 KB is generous for game-side metadata, while still being a
|
||||
# concrete limit).
|
||||
_MAX_RUN_JSON_BYTES = 32 * 1024
|
||||
|
||||
|
||||
# ── XP / level math ───────────────────────────────────────────────────────────
|
||||
|
||||
def xp_for_run(score: int) -> int:
|
||||
"""Default XP formula: floor(sqrt(score) * 10). Override per-game in
|
||||
the minigame manifest via `xp_formula` (not implemented in v1)."""
|
||||
if score <= 0:
|
||||
return 0
|
||||
return int(math.floor(math.sqrt(score) * 10))
|
||||
|
||||
|
||||
def level_for_xp(xp: int) -> int:
|
||||
"""Level grows with sqrt(xp): L1 at 0, L2 at 100, L3 at 400, L4 at 900..."""
|
||||
if xp <= 0:
|
||||
return 1
|
||||
return int(math.floor(math.sqrt(xp / 100))) + 1
|
||||
|
||||
|
||||
def xp_to_next_level(xp: int) -> int:
|
||||
next_lvl = level_for_xp(xp) + 1
|
||||
threshold = (next_lvl - 1) ** 2 * 100
|
||||
return max(0, threshold - xp)
|
||||
|
||||
|
||||
# ── Persistence helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _get_conn():
|
||||
db_path = _state["db_path"]
|
||||
if not db_path:
|
||||
raise RuntimeError("minigames plugin not initialised")
|
||||
conn = sqlite3.connect(db_path, timeout=5)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# WAL mode: reduces read/write contention under concurrent access (same
|
||||
# practice as MetadataDB in server.py). busy_timeout is set via the
|
||||
# connect() timeout= arg above (5 s), which maps to PRAGMA busy_timeout
|
||||
# in Python's sqlite3 module when the connection opens.
|
||||
# PRAGMA returns the *active* journal mode; WAL can silently fall back
|
||||
# (e.g. on a read-only filesystem) so log a warning when that happens.
|
||||
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
||||
actual_mode = row[0] if row else "unknown"
|
||||
if actual_mode.lower() != "wal":
|
||||
_state["log"].warning(
|
||||
"WAL mode not available for runs.db (active mode: %s); "
|
||||
"concurrent access may see increased lock contention",
|
||||
actual_mode,
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def _init_db():
|
||||
conn = _get_conn()
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
modifiers TEXT NOT NULL DEFAULT '{}',
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
xp_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS runs_game_idx ON runs(game_id, created_at DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS runs_score_idx ON runs(game_id, score DESC)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_profile() -> dict:
|
||||
path = _state["profile_path"]
|
||||
if path and path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
_state["log"].warning(
|
||||
"profile.json contained %s instead of an object; recreating",
|
||||
type(data).__name__,
|
||||
)
|
||||
except Exception as e:
|
||||
_state["log"].warning("profile.json unreadable, recreating: %s", e)
|
||||
return {
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
"unlocks": [],
|
||||
"totals": {"runs": 0, "score": 0, "per_game": {}},
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
def _save_profile(profile: dict) -> None:
|
||||
path = _state["profile_path"]
|
||||
if not path:
|
||||
raise RuntimeError("minigames plugin not initialised")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic write: temp file + rename. Per Principle VII (versioned settings),
|
||||
# safety-critical writes go through temp+rename so a crash mid-write does
|
||||
# not leave a partial profile.
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".profile-", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(profile, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_name, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _evaluate_unlocks(profile: dict, manifest_unlocks_by_game: dict) -> list:
|
||||
"""Given current XP and per-game unlock definitions, return the new list
|
||||
of unlocked IDs (game-scoped to avoid collision: 'game_id:unlock_id')."""
|
||||
raw_unlocks = profile.get("unlocks")
|
||||
# Coerce to list and filter to strings only — non-string items (e.g. ints
|
||||
# from a manual edit) would cause sorted() to fail on mixed-type comparison
|
||||
# in Python 3, and are invalid unlock IDs anyway.
|
||||
earned = set(
|
||||
v for v in (raw_unlocks if isinstance(raw_unlocks, list) else [])
|
||||
if isinstance(v, str)
|
||||
)
|
||||
# Coerce xp to numeric; non-numeric values (e.g. "100" from a manual edit)
|
||||
# would raise TypeError in the comparison `if xp >= unlock_xp` below.
|
||||
try:
|
||||
xp = float(profile.get("xp", 0))
|
||||
except (TypeError, ValueError):
|
||||
xp = 0
|
||||
for game_id, unlocks in manifest_unlocks_by_game.items():
|
||||
for u in unlocks or []:
|
||||
if not isinstance(u, dict):
|
||||
_state["log"].warning(
|
||||
"minigame %s has a non-object unlock entry (%r); skipping",
|
||||
game_id, type(u).__name__,
|
||||
)
|
||||
continue
|
||||
unlock_id = u.get("id")
|
||||
if not unlock_id:
|
||||
_state["log"].warning("minigame %s has an unlock entry missing 'id'; skipping", game_id)
|
||||
continue
|
||||
key = f"{game_id}:{unlock_id}"
|
||||
try:
|
||||
unlock_xp = float(u.get("xp", 0))
|
||||
except (TypeError, ValueError):
|
||||
_state["log"].warning(
|
||||
"minigame %s has an unlock entry with non-numeric xp threshold; skipping",
|
||||
game_id,
|
||||
)
|
||||
continue
|
||||
if xp >= unlock_xp:
|
||||
earned.add(key)
|
||||
return sorted(earned)
|
||||
|
||||
|
||||
# ── Minigame discovery (server-side) ──────────────────────────────────────────
|
||||
|
||||
def _list_minigame_plugins(force_refresh: bool = False) -> list:
|
||||
"""Walk the plugin directories, return the `minigame` block of every
|
||||
plugin whose plugin.json declares one. Tolerates missing/invalid JSON.
|
||||
|
||||
Results are cached for _REGISTRY_TTL_S seconds to avoid rescanning the
|
||||
filesystem on every run submission and /registry request. Pass
|
||||
force_refresh=True to bypass the cache (e.g. after a hot-reload).
|
||||
|
||||
Thread-safety: cache reads/writes are serialised under _registry_lock.
|
||||
The filesystem walk itself runs outside the lock (I/O can be slow) and
|
||||
the result is committed atomically at the end. Using a dedicated lock
|
||||
(not _lock) means this can safely be called from within a _lock-held
|
||||
section such as submit_run without deadlocking.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
# Fast path: read under lock, return cached data if still fresh.
|
||||
with _registry_lock:
|
||||
if not force_refresh and (now - _registry_cache["ts"]) < _REGISTRY_TTL_S:
|
||||
return list(_registry_cache["data"])
|
||||
|
||||
# Slow path: scan the filesystem outside the lock (I/O can be slow).
|
||||
# Concurrent callers that also miss the cache will each do their own scan;
|
||||
# for a small plugin count this is harmless, and it avoids holding the lock
|
||||
# during disk I/O. The winner is whoever commits last — always consistent.
|
||||
resolver = _state["plugins_dir_resolver"]
|
||||
if not resolver:
|
||||
return []
|
||||
out = []
|
||||
seen_ids: set = set()
|
||||
for pdir in resolver():
|
||||
manifest_path = pdir / "plugin.json"
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as e:
|
||||
_state["log"].warning(
|
||||
"failed to parse minigame manifest at %s: %s", manifest_path, e
|
||||
)
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
spec = data.get("minigame")
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
plugin_id = data.get("id")
|
||||
if not isinstance(plugin_id, str) or not plugin_id:
|
||||
_state["log"].warning(
|
||||
"minigame plugin at %s has no valid string 'id' in plugin.json; skipping",
|
||||
pdir,
|
||||
)
|
||||
continue
|
||||
# Spread spec first so authoritative top-level fields (plugin_id,
|
||||
# version) win if the minigame block contains conflicting keys.
|
||||
entry = {
|
||||
**spec,
|
||||
"plugin_id": plugin_id,
|
||||
"version": data.get("version"),
|
||||
}
|
||||
# Deduplicate by plugin_id: first entry wins (resolver returns
|
||||
# SLOPSMITH_PLUGINS_DIR before the bundled siblings, so an explicit
|
||||
# override takes precedence over the in-tree snapshot — same winner
|
||||
# selection as the core plugin loader).
|
||||
if plugin_id not in seen_ids:
|
||||
seen_ids.add(plugin_id)
|
||||
out.append(entry)
|
||||
|
||||
# Commit: write the fresh data under lock so ts and data are always updated
|
||||
# atomically and no reader sees a new ts with old data.
|
||||
with _registry_lock:
|
||||
_registry_cache["ts"] = time.monotonic()
|
||||
_registry_cache["data"] = out
|
||||
return list(out)
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
class RunSubmission(BaseModel):
|
||||
game_id: str
|
||||
score: int = Field(ge=0)
|
||||
duration_ms: int = Field(ge=0, default=0)
|
||||
modifiers: dict = Field(default_factory=dict)
|
||||
meta: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── FastAPI wiring ────────────────────────────────────────────────────────────
|
||||
|
||||
def setup(app, context):
|
||||
config_dir = context["config_dir"]
|
||||
base = Path(config_dir) / "minigames"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_state["db_path"] = str(base / "runs.db")
|
||||
_state["profile_path"] = base / "profile.json"
|
||||
_state["log"] = context.get("log") or _state["log"]
|
||||
# fee[dB]ack v0.3.0: adopt the unified core XP store when available.
|
||||
_state["award_xp"] = context.get("award_xp")
|
||||
_state["get_xp_progress"] = context.get("get_xp_progress")
|
||||
_state["seed_xp"] = context.get("seed_xp")
|
||||
_state["reset_xp"] = context.get("reset_xp")
|
||||
# Progression engine (spec 010): report runs so minigame challenges/quests
|
||||
# advance. Optional — standalone mode keeps working without it.
|
||||
_state["record_progression_event"] = context.get("record_progression_event")
|
||||
|
||||
# The plugin loader doesn't currently expose a list-other-plugins helper,
|
||||
# so derive the plugin directories from environment + conventions:
|
||||
# 1. SLOPSMITH_PLUGINS_DIR env var (explicit override)
|
||||
# 2. The directory that contains this plugin (plugin_self.parent) —
|
||||
# covers the common case where all plugins live in one flat dir.
|
||||
# 3. plugin_self.parent.parent / "plugins" — covers the layout where
|
||||
# the repo root is one level above the plugins directory.
|
||||
# Duplicates are removed via a seen-set keyed on resolved paths.
|
||||
def _resolve_plugin_dirs():
|
||||
roots = []
|
||||
env_dir = os.environ.get("SLOPSMITH_PLUGINS_DIR")
|
||||
if env_dir:
|
||||
roots.append(Path(env_dir))
|
||||
# Built-in plugins/ next to server.py (one level above this file's
|
||||
# parent when installed as a sibling).
|
||||
plugin_self = Path(__file__).resolve().parent
|
||||
for cand in (plugin_self.parent, plugin_self.parent.parent / "plugins"):
|
||||
if cand.exists() and cand.is_dir():
|
||||
roots.append(cand)
|
||||
seen = set()
|
||||
out = []
|
||||
for root in roots:
|
||||
try:
|
||||
children = sorted(root.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for child in children:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
key = child.resolve()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(child)
|
||||
return out
|
||||
|
||||
_state["plugins_dir_resolver"] = _resolve_plugin_dirs
|
||||
# Invalidate the registry cache so that if setup() is called again (e.g.
|
||||
# on a plugin hot-reload with a different resolver) the next /registry or
|
||||
# run-submission call triggers a fresh scan rather than serving stale data.
|
||||
with _registry_lock:
|
||||
_registry_cache["ts"] = 0.0
|
||||
_registry_cache["data"] = []
|
||||
_init_db()
|
||||
|
||||
# One-time migration: carry any XP already earned in the legacy
|
||||
# profile.json into the unified core store, so promoting XP to core
|
||||
# doesn't reset the player's level. Core no-ops if it's already seeded or
|
||||
# already has XP. (Core never reads plugin files — minigames pushes.)
|
||||
if _state.get("seed_xp"):
|
||||
try:
|
||||
existing = _load_profile()
|
||||
local_xp = max(0, int(existing.get("xp", 0) or 0))
|
||||
if local_xp > 0:
|
||||
seeded = _state["seed_xp"](local_xp, "minigames")
|
||||
if seeded:
|
||||
_state["log"].info("seeded unified XP store with %d minigames XP", local_xp)
|
||||
except Exception as e:
|
||||
_state["log"].warning("minigames XP seed skipped: %s", e)
|
||||
|
||||
log = _state["log"]
|
||||
log.info("minigames backend ready: db=%s profile=%s",
|
||||
_state["db_path"], _state["profile_path"])
|
||||
|
||||
@app.post("/api/plugins/minigames/runs")
|
||||
def submit_run(submission: RunSubmission):
|
||||
# Whitelist game_id against installed minigames. This is a soft
|
||||
# check — an uninstalled minigame can still submit if its plugin
|
||||
# was loaded earlier in the session — but it catches typos.
|
||||
installed = {p["plugin_id"]: p for p in _list_minigame_plugins()}
|
||||
if submission.game_id not in installed:
|
||||
log.warning("run submitted for unknown game_id=%s; accepting anyway",
|
||||
submission.game_id)
|
||||
|
||||
# Serialise modifiers + meta up-front so we can enforce the byte-size cap
|
||||
# before touching the database.
|
||||
try:
|
||||
modifiers_json = json.dumps(submission.modifiers, separators=(",", ":"))
|
||||
meta_json = json.dumps(submission.meta, separators=(",", ":"))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="modifiers/meta must be JSON-serialisable objects") from e
|
||||
if (len(modifiers_json.encode("utf-8")) > _MAX_RUN_JSON_BYTES
|
||||
or len(meta_json.encode("utf-8")) > _MAX_RUN_JSON_BYTES):
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"modifiers/meta too large (max {_MAX_RUN_JSON_BYTES} bytes each)")
|
||||
|
||||
xp_gained = xp_for_run(submission.score)
|
||||
created_at = int(time.time())
|
||||
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO runs (game_id, score, duration_ms,
|
||||
modifiers, meta, xp_awarded, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
submission.game_id,
|
||||
submission.score,
|
||||
submission.duration_ms,
|
||||
modifiers_json,
|
||||
meta_json,
|
||||
xp_gained,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
run_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
profile = _load_profile()
|
||||
# Coerce types with fallbacks so manual edits or import/export with
|
||||
# wrong types (e.g. "xp": "100" or "xp": null) don't raise here.
|
||||
def _int(val, default=0):
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
# Unified XP: when running inside core, the single core store is the
|
||||
# source of truth. Award there and mirror its total into profile.json
|
||||
# so unlock thresholds + legacy readers stay consistent with the
|
||||
# badge. Standalone (no core service) falls back to local accounting.
|
||||
core_awarded = False # so a later profile-save failure can reverse it
|
||||
if _state.get("award_xp"):
|
||||
try:
|
||||
core_prog = _state["award_xp"](xp_gained, "minigames")
|
||||
core_awarded = True
|
||||
profile["xp"] = max(0, _int(core_prog.get("xp"), 0))
|
||||
profile["level"] = _int(core_prog.get("level"), 1)
|
||||
except Exception as e:
|
||||
_state["log"].warning("core XP award failed, using local: %s", e)
|
||||
profile["xp"] = max(0, _int(profile.get("xp"), 0) + xp_gained)
|
||||
profile["level"] = level_for_xp(profile["xp"])
|
||||
else:
|
||||
profile["xp"] = max(0, _int(profile.get("xp"), 0) + xp_gained)
|
||||
profile["level"] = level_for_xp(profile["xp"])
|
||||
|
||||
# Validate that totals / per_game / per-game entry are dicts; reset
|
||||
# to defaults when a manual edit or import left wrong types.
|
||||
existing_totals = profile.get("totals")
|
||||
if not isinstance(existing_totals, dict):
|
||||
profile["totals"] = {"runs": 0, "score": 0, "per_game": {}}
|
||||
totals = profile["totals"]
|
||||
totals["runs"] = _int(totals.get("runs"), 0) + 1
|
||||
totals["score"] = _int(totals.get("score"), 0) + submission.score
|
||||
existing_per_game = totals.get("per_game")
|
||||
if not isinstance(existing_per_game, dict):
|
||||
totals["per_game"] = {}
|
||||
per_game = totals["per_game"]
|
||||
existing_g = per_game.get(submission.game_id)
|
||||
if not isinstance(existing_g, dict):
|
||||
per_game[submission.game_id] = {"runs": 0, "best_score": 0, "total_score": 0}
|
||||
g = per_game[submission.game_id]
|
||||
g["runs"] = _int(g.get("runs"), 0) + 1
|
||||
g["total_score"] = _int(g.get("total_score"), 0) + submission.score
|
||||
g["best_score"] = max(_int(g.get("best_score"), 0), submission.score)
|
||||
|
||||
manifest_unlocks = {
|
||||
p["plugin_id"]: p.get("unlocks", []) for p in installed.values()
|
||||
}
|
||||
profile["unlocks"] = _evaluate_unlocks(profile, manifest_unlocks)
|
||||
try:
|
||||
_save_profile(profile)
|
||||
except Exception:
|
||||
# Profile save failed (e.g. disk full). Roll back the run insert
|
||||
# AND reverse the core XP award — otherwise the core total stays
|
||||
# incremented while the run/profile didn't persist, so a client
|
||||
# retry would double-count this run's XP.
|
||||
try:
|
||||
conn2 = _get_conn()
|
||||
try:
|
||||
conn2.execute("DELETE FROM runs WHERE id = ?", (run_id,))
|
||||
conn2.commit()
|
||||
finally:
|
||||
conn2.close()
|
||||
except Exception as del_err:
|
||||
_state["log"].error(
|
||||
"failed to roll back run %s after profile-save failure: %s",
|
||||
run_id, del_err,
|
||||
)
|
||||
if core_awarded and _state.get("award_xp"):
|
||||
try:
|
||||
_state["award_xp"](-xp_gained, "minigames") # reverse the award
|
||||
except Exception as rev_err:
|
||||
_state["log"].error(
|
||||
"failed to reverse core XP award after profile-save failure: %s", rev_err)
|
||||
raise
|
||||
|
||||
# Progression (spec 010): a persisted run is a `minigame_run` event so
|
||||
# challenges/quests advance. Never let progression break the submission
|
||||
# (the run + XP are already committed).
|
||||
progression_summary = None
|
||||
if _state.get("record_progression_event"):
|
||||
try:
|
||||
progression_summary = _state["record_progression_event"](
|
||||
"minigame_run",
|
||||
{"game_id": submission.game_id, "score": submission.score},
|
||||
)
|
||||
except Exception as e:
|
||||
_state["log"].warning("progression event failed for run %s: %s", run_id, e)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"run_id": run_id,
|
||||
"xp_gained": xp_gained,
|
||||
"profile": {
|
||||
"xp": profile["xp"],
|
||||
"level": profile["level"],
|
||||
"xp_to_next_level": xp_to_next_level(profile["xp"]),
|
||||
"unlocks": profile["unlocks"],
|
||||
},
|
||||
"progression": progression_summary,
|
||||
}
|
||||
|
||||
@app.get("/api/plugins/minigames/runs")
|
||||
def list_runs(game_id: str = "", scope: str = "self", limit: int = 50):
|
||||
if limit < 1 or limit > 500:
|
||||
raise HTTPException(status_code=400, detail="limit out of range")
|
||||
# `scope` is reserved for future cross-user comparisons. v1 is
|
||||
# single-user so 'self' and 'global' both return this install's runs.
|
||||
if scope not in ("self", "global"):
|
||||
raise HTTPException(status_code=400, detail="scope must be 'self' or 'global'")
|
||||
q = "SELECT id, game_id, score, duration_ms, modifiers, meta, xp_awarded, created_at FROM runs"
|
||||
params: list = []
|
||||
if game_id:
|
||||
q += " WHERE game_id = ?"
|
||||
params.append(game_id)
|
||||
q += " ORDER BY score DESC, created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
conn = _get_conn()
|
||||
try:
|
||||
rows = conn.execute(q, params).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _safe_loads(raw, default=None):
|
||||
try:
|
||||
return json.loads(raw or "{}")
|
||||
except (ValueError, TypeError):
|
||||
return default if default is not None else {}
|
||||
|
||||
return {
|
||||
"runs": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"game_id": r["game_id"],
|
||||
"score": r["score"],
|
||||
"duration_ms": r["duration_ms"],
|
||||
"modifiers": _safe_loads(r["modifiers"]),
|
||||
"meta": _safe_loads(r["meta"]),
|
||||
"xp_awarded": r["xp_awarded"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/api/plugins/minigames/profile")
|
||||
def get_profile():
|
||||
profile = _load_profile()
|
||||
# Coerce xp and level to ints so xp_to_next_level / level_for_xp don't
|
||||
# crash on non-integer values that can appear after a manual edit or
|
||||
# import. Recompute level from coerced xp so the two are always in sync
|
||||
# (stale or wrong-type level in the file is ignored).
|
||||
try:
|
||||
xp = max(0, int(profile.get("xp", 0)))
|
||||
except (TypeError, ValueError):
|
||||
xp = 0
|
||||
# fee[dB]ack v0.3.0 unified XP: when core is the source of truth, report
|
||||
# its total (the profile.json xp is mirrored on write, but core wins on
|
||||
# read so song-play XP earned outside minigames is reflected here too).
|
||||
if _state.get("get_xp_progress"):
|
||||
try:
|
||||
xp = max(0, int(_state["get_xp_progress"]().get("xp", xp)))
|
||||
except Exception:
|
||||
pass
|
||||
level = level_for_xp(xp)
|
||||
return {
|
||||
**profile,
|
||||
"xp": xp,
|
||||
"level": level,
|
||||
"xp_to_next_level": xp_to_next_level(xp),
|
||||
}
|
||||
|
||||
@app.post("/api/plugins/minigames/profile/reset")
|
||||
def reset_profile():
|
||||
with _lock:
|
||||
fresh = {
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
"unlocks": [],
|
||||
"totals": {"runs": 0, "score": 0, "per_game": {}},
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
path = _state["profile_path"]
|
||||
if not path:
|
||||
raise RuntimeError("minigames plugin not initialised")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Two-phase commit: stage the fresh profile to a temp file first,
|
||||
# then wipe the DB, then rename the temp into place.
|
||||
# Phase 1 failure → nothing changed (consistent).
|
||||
# Phase 2 (DB delete) failure → temp file cleaned up, nothing
|
||||
# changed (consistent).
|
||||
# Phase 3 (rename) failure → DB wiped but old profile survives;
|
||||
# runs are gone but profile still holds stale totals. This is
|
||||
# acceptable because os.replace is virtually atomic on POSIX and
|
||||
# the rename failure case requires a full filesystem error.
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".profile-reset-", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(fresh, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Phase 2: wipe run history.
|
||||
conn = _get_conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM runs")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
# Phase 3: atomically install the fresh profile.
|
||||
os.replace(tmp_name, path)
|
||||
tmp_name = None # consumed
|
||||
finally:
|
||||
if tmp_name is not None:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
# Reset this source's contribution to the UNIFIED core XP store too —
|
||||
# otherwise get_profile() (which mirrors core when integrated) would keep
|
||||
# reporting the old XP/level after a minigames reset. Subtracts only the
|
||||
# minigames share, leaving song-play / tutorials XP intact.
|
||||
if _state.get("reset_xp"):
|
||||
try:
|
||||
_state["reset_xp"]("minigames")
|
||||
except Exception as e:
|
||||
_state["log"].warning("core XP reset failed during minigames reset: %s", e)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/plugins/minigames/registry")
|
||||
def registry():
|
||||
return {"minigames": _list_minigame_plugins()}
|
||||
@@ -0,0 +1,120 @@
|
||||
<!-- Inline critical styles: the plugin loader does not inject style.css
|
||||
automatically, so rules that can't be expressed as Tailwind classes
|
||||
are inlined here to ensure they're applied when screen.html is
|
||||
injected via innerHTML. -->
|
||||
<style>
|
||||
#mg-stage-body > .mg-game-root { position: absolute; inset: 0; }
|
||||
.mg-pitch-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: rgb(107 114 128); margin-right: 6px; vertical-align: middle; }
|
||||
.mg-pitch-dot.live { background: rgb(74 222 128); box-shadow: 0 0 8px rgba(74,222,128,0.6); }
|
||||
</style>
|
||||
|
||||
<div id="mg-root" class="max-w-5xl mx-auto px-6 pt-24 pb-16">
|
||||
|
||||
<!-- Header + profile strip -->
|
||||
<div class="flex items-end justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-white">Minigames</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">Standalone games that share your guitar input.</p>
|
||||
</div>
|
||||
<!-- Progression (spec 010): runs earn Decibels (dB), the spendable
|
||||
currency — the old XP level meter is gone. -->
|
||||
<div id="mg-profile-strip" class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<div class="text-xs uppercase tracking-widest text-gray-500">dB earned</div>
|
||||
<div id="mg-profile-xp" class="text-2xl font-bold text-white">0 dB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tile grid — mirrors the library card layout for visual consistency -->
|
||||
<div id="mg-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5"></div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="mg-empty" class="hidden text-center py-24">
|
||||
<div class="text-5xl mb-3">🎮</div>
|
||||
<p class="text-gray-400">No minigames installed.</p>
|
||||
<p class="text-gray-500 text-sm mt-1">
|
||||
Install a plugin whose <code>plugin.json</code> declares a
|
||||
<code>"minigame"</code> block to see it here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- In-game container — only visible while a minigame is running.
|
||||
z-[60] sits above the Slopsmith navbar (z-50) so the game owns the
|
||||
viewport during a run; the stage's own Quit button is the exit. -->
|
||||
<div id="mg-stage" class="hidden fixed inset-0 z-[60] bg-fb-bg/95 flex flex-col"
|
||||
role="region" aria-labelledby="mg-stage-title">
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-fb-border/50">
|
||||
<div>
|
||||
<div id="mg-stage-title" class="text-lg font-semibold text-fb-text"></div>
|
||||
<div id="mg-stage-instrument" class="text-xs text-fb-textDim mt-0.5"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div id="mg-stage-hud" class="text-sm text-fb-textDim"></div>
|
||||
<button id="mg-stage-quit"
|
||||
class="px-3 py-1.5 text-sm rounded bg-fb-card hover:bg-fb-border text-fb-text">
|
||||
Quit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mg-stage-body" class="flex-1 relative overflow-hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Run summary modal -->
|
||||
<div id="mg-summary" class="hidden fixed inset-0 z-[60] bg-black/70 flex items-center justify-center"
|
||||
role="dialog" aria-modal="true" aria-labelledby="mg-summary-heading">
|
||||
<div class="bg-fb-card rounded-lg shadow-2xl p-8 w-full max-w-md mx-4 border border-fb-border/50">
|
||||
<h2 id="mg-summary-heading" class="text-2xl font-bold text-fb-text mb-1">Run complete</h2>
|
||||
<p id="mg-summary-game" class="text-sm text-fb-textDim mb-6"></p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 mb-6 text-center">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-widest text-fb-textDim">Score</div>
|
||||
<div id="mg-summary-score" class="text-3xl font-bold text-fb-text mt-1">0</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-widest text-fb-textDim">dB earned</div>
|
||||
<div id="mg-summary-xp" class="text-3xl font-bold text-fb-good mt-1">0</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-widest text-fb-textDim">Best</div>
|
||||
<div id="mg-summary-best" class="text-3xl font-bold text-fb-text mt-1">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mg-summary-extra" class="text-sm text-fb-textDim mb-6"></div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button id="mg-summary-close"
|
||||
class="px-4 py-2 rounded bg-fb-card hover:bg-fb-border text-fb-text border border-fb-border/50">
|
||||
Close
|
||||
</button>
|
||||
<button id="mg-summary-again"
|
||||
class="px-4 py-2 rounded bg-fb-primary hover:bg-fb-primaryHi text-white">
|
||||
Play again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modifier picker (shown before a run starts) -->
|
||||
<div id="mg-picker" class="hidden fixed inset-0 z-[60] bg-black/70 flex items-center justify-center"
|
||||
role="dialog" aria-modal="true" aria-labelledby="mg-picker-title">
|
||||
<div class="bg-fb-card rounded-lg shadow-2xl p-8 w-full max-w-md mx-4 border border-fb-border/50">
|
||||
<h2 id="mg-picker-title" class="text-2xl font-bold text-fb-text mb-1"></h2>
|
||||
<p id="mg-picker-tagline" class="text-sm text-fb-textDim mb-6"></p>
|
||||
<div id="mg-picker-body" class="space-y-4 mb-6"></div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button id="mg-picker-cancel"
|
||||
class="px-4 py-2 rounded bg-fb-card hover:bg-fb-border text-fb-text border border-fb-border/50">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="mg-picker-start"
|
||||
class="px-4 py-2 rounded bg-fb-primary hover:bg-fb-primaryHi text-white">
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Profile</h3>
|
||||
<p class="text-sm text-gray-400 mb-3">
|
||||
Your XP, unlocks, and run history are stored locally under
|
||||
<code><config_dir>/minigames/</code> and round-trip through
|
||||
the global settings export.
|
||||
</p>
|
||||
<button id="mg-settings-reset" type="button"
|
||||
class="px-3 py-2 rounded bg-red-600/80 hover:bg-red-500 text-white text-sm">
|
||||
Reset profile + history
|
||||
</button>
|
||||
<span id="mg-settings-reset-status" class="ml-3 text-xs text-gray-400"
|
||||
role="status" aria-live="polite" aria-atomic="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const btn = document.getElementById('mg-settings-reset');
|
||||
const status = document.getElementById('mg-settings-reset-status');
|
||||
if (!btn || !status) return;
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Wipe all minigame XP, unlocks, and run history? This cannot be undone.')) return;
|
||||
if (!window.slopsmithMinigames?.resetProfile) {
|
||||
status.textContent = 'Minigames SDK not loaded — reload the page and try again.';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Wiping…';
|
||||
try {
|
||||
await window.slopsmithMinigames.resetProfile();
|
||||
status.textContent = 'Profile reset.';
|
||||
} catch (e) {
|
||||
status.textContent = 'Failed: ' + String(e?.message || e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -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