Clean release snapshot

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

After

Width:  |  Height:  |  Size: 20 KiB

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

After

Width:  |  Height:  |  Size: 1.9 KiB

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

After

Width:  |  Height:  |  Size: 6.2 KiB

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