Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,150 changes: 241 additions & 909 deletions BUILD.md

Large diffs are not rendered by default.

479 changes: 179 additions & 300 deletions README.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
"""Stemmy — local AI stem separation studio."""
from .server import create_app

from .server import create_app as _create_app
from .tools_ui import install_tools


def create_app():
"""Create Stemmy and attach isolated musician tools."""
return install_tools(_create_app())


__all__ = ["create_app"]
90 changes: 90 additions & 0 deletions app/static/stemmy-tools.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/* Stemmy musician tools: isolated tuner + chord creator UI. */
.st-tools-btn svg{width:15px;height:15px}
@media (max-width:1280px){.st-tools-btn span{display:none}.st-tools-btn{padding:7px 9px}}

.st-tool-stage{position:fixed;inset:0;z-index:520;display:none;flex-direction:column;background:
radial-gradient(900px 480px at 50% -10%,rgba(var(--green-rgb),.14),transparent 65%),#060a08;color:var(--text)}
.st-tool-stage.show{display:flex}
.st-tool-head{height:64px;display:flex;align-items:center;gap:14px;padding:0 22px;border-bottom:1px solid var(--line);background:rgba(10,16,12,.94);backdrop-filter:blur(10px)}
.st-tool-head .st-title{font-size:18px;font-weight:700;letter-spacing:-.25px}
.st-tool-head .st-sub{font-size:11px;color:var(--muted);margin-top:2px}
.st-tool-head .st-spacer{flex:1}
.st-tool-close{width:38px;height:38px;border:1px solid var(--line);border-radius:10px;background:rgba(0,0,0,.28);color:var(--muted);font-size:16px}
.st-tool-close:hover{color:var(--text);border-color:var(--green-dim)}
.st-tool-scroll{flex:1;min-height:0;overflow:auto;padding:24px}
.st-tool-grid{display:grid;grid-template-columns:minmax(260px,380px) minmax(420px,1fr);gap:20px;max-width:1400px;margin:0 auto}
.st-panel{border:1px solid var(--line);border-radius:16px;background:linear-gradient(180deg,var(--panel-2),var(--panel));box-shadow:var(--shadow);overflow:hidden}
.st-panel-h{padding:15px 17px;border-bottom:1px solid var(--line-soft);display:flex;align-items:center;gap:10px}
.st-panel-h h3{font-size:14px;margin:0}.st-panel-h small{color:var(--muted-2);font-size:10.5px}
.st-panel-b{padding:17px}
.st-field{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
.st-field>label,.st-field-label{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted-2)}
.st-row{display:flex;gap:9px;align-items:center;flex-wrap:wrap}
.st-select,.st-input{height:38px;border:1px solid var(--line);border-radius:9px;background:#090e0b;color:var(--text);padding:0 10px;font-family:inherit;outline:none}
.st-select:focus,.st-input:focus{border-color:var(--green-dim)}
.st-input[type=number]{width:84px}
.st-button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:38px;padding:0 14px;border:1px solid var(--line);border-radius:9px;background:rgba(0,0,0,.25);font-size:12.5px;font-weight:600;color:var(--text)}
.st-button:hover{border-color:var(--green-dim)}
.st-button.primary{background:var(--green);border-color:var(--green);color:#04140b}
.st-button.primary:hover{background:#46ee8c}
.st-button.danger:hover{border-color:var(--danger);color:var(--danger)}
.st-button[disabled]{opacity:.45;cursor:not-allowed}
.st-note{font-size:11px;line-height:1.55;color:var(--muted)}
.st-badge{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:3px 8px;font-size:10px;color:var(--muted)}
.st-badge.good{border-color:var(--green-dim);color:var(--green)}
.st-switch{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);cursor:pointer}
.st-switch input{accent-color:var(--green)}

/* Tuner */
.st-tuner-layout{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:minmax(260px,330px) minmax(440px,1fr);gap:20px}
.st-tuner-face{min-height:520px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:34px;text-align:center;position:relative}
.st-tuner-status{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted-2);min-height:16px}
.st-tuner-note{font-family:var(--mono);font-size:clamp(76px,11vw,150px);line-height:.92;font-weight:700;color:var(--text);letter-spacing:-.08em;text-shadow:0 0 35px rgba(var(--green-rgb),.16);margin:14px 0 8px}
.st-tuner-note.in-tune{color:var(--green);text-shadow:0 0 38px rgba(var(--green-rgb),.42)}
.st-tuner-cents{font-family:var(--mono);font-size:18px;color:var(--muted);min-height:24px}
.st-meter{width:min(620px,92%);height:88px;position:relative;margin:26px 0 14px}
.st-meter-line{position:absolute;left:0;right:0;top:50%;height:2px;background:linear-gradient(90deg,var(--danger),var(--warn) 35%,var(--green) 50%,var(--warn) 65%,var(--danger));border-radius:2px}
.st-meter-center{position:absolute;left:50%;top:12px;bottom:12px;width:2px;background:var(--green);opacity:.75}
.st-meter-needle{position:absolute;left:50%;top:5px;width:3px;height:66px;background:#fff;border-radius:3px;transform-origin:50% 100%;transform:translateX(-50%) rotate(0deg);transition:transform .11s ease-out;box-shadow:0 0 10px rgba(255,255,255,.55)}
.st-meter-labels{display:flex;justify-content:space-between;width:min(620px,92%);font:10px var(--mono);color:var(--muted-2)}
.st-tuner-meta{display:flex;gap:18px;flex-wrap:wrap;justify-content:center;margin-top:18px;font:11px var(--mono);color:var(--muted)}
.st-level{height:7px;border-radius:5px;background:var(--line);overflow:hidden;margin-top:8px}.st-level i{display:block;width:0;height:100%;background:linear-gradient(90deg,var(--teal),var(--green));transition:width .08s}
.st-string-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px}
.st-string{border:1px solid var(--line);border-radius:10px;padding:10px 8px;text-align:center;background:rgba(0,0,0,.18)}
.st-string b{display:block;font:18px var(--mono);color:var(--text)}.st-string small{font:9px var(--mono);color:var(--muted-2)}
.st-string.active{border-color:var(--green);background:rgba(var(--green-rgb),.10);box-shadow:0 0 18px -8px var(--green)}
.st-string.active b{color:var(--green)}

/* Chord creator */
.st-genre-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}
.st-genre{display:flex;align-items:center;gap:8px;padding:8px 9px;border:1px solid var(--line-soft);border-radius:9px;background:rgba(0,0,0,.13);font-size:11.5px;color:var(--muted);cursor:pointer}
.st-genre:has(input:checked){border-color:var(--green-dim);color:var(--text);background:rgba(var(--green-rgb),.07)}
.st-genre input{accent-color:var(--green)}
.st-range{width:100%;accent-color:var(--green)}
.st-results{display:flex;flex-direction:column;gap:12px}
.st-empty{min-height:330px;display:grid;place-items:center;text-align:center;color:var(--muted-2);padding:30px}
.st-prog{border:1px solid var(--line);border-radius:13px;background:rgba(0,0,0,.16);overflow:hidden}
.st-prog-top{display:flex;align-items:flex-start;gap:12px;padding:13px 14px;border-bottom:1px solid var(--line-soft)}
.st-prog-num{width:29px;height:29px;border-radius:8px;display:grid;place-items:center;background:var(--green-deep);color:var(--green);font:12px var(--mono);flex:0 0 auto}
.st-prog-info{min-width:0;flex:1}.st-prog-title{font-size:13.5px;font-weight:650}.st-prog-desc{font-size:10.5px;color:var(--muted);margin-top:3px;line-height:1.45}
.st-prog-tags{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
.st-prog-body{padding:13px 14px}
.st-chords{display:flex;gap:7px;flex-wrap:wrap;align-items:center}
.st-chord-chip{position:relative;min-width:58px;padding:10px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel-2);font:14px var(--mono);text-align:center;color:var(--text)}
.st-chord-chip:hover{border-color:var(--green-dim)}
.st-chord-chip .st-cycle{display:block;font:8px var(--sans);color:var(--muted-2);margin-top:3px;text-transform:uppercase;letter-spacing:.05em}
.st-arrow{color:var(--muted-2);font-size:11px}
.st-prog-details{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:12px}
.st-detail{border:1px solid var(--line-soft);border-radius:8px;padding:8px 9px;background:rgba(0,0,0,.11)}
.st-detail small{display:block;font-size:8.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin-bottom:3px}.st-detail b{font-size:10.5px;font-weight:550;color:var(--muted)}
.st-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}.st-actions .st-button{min-height:32px;padding:0 10px;font-size:10.5px}
.st-diagrams{display:none;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;margin-top:12px}.st-diagrams.show{display:grid}
.st-diagram{border:1px solid var(--line-soft);border-radius:9px;padding:9px;background:rgba(0,0,0,.14)}.st-diagram h5{margin:0 0 6px;font:12px var(--mono)}
.st-fret{font:10px/1.45 var(--mono);white-space:pre;color:var(--muted)}
.st-faves{margin-top:14px;border-top:1px solid var(--line-soft);padding-top:13px}.st-faves h4{font-size:11px;margin:0 0 8px;color:var(--muted)}
.st-fave-row{display:flex;gap:8px;align-items:center;padding:7px 8px;border:1px solid var(--line-soft);border-radius:8px;margin-bottom:6px;font:10.5px var(--mono);color:var(--muted)}.st-fave-row span{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.st-toast{position:fixed;left:50%;bottom:24px;z-index:600;transform:translate(-50%,20px);opacity:0;pointer-events:none;background:#111a14;border:1px solid var(--green-dim);color:var(--text);border-radius:9px;padding:10px 14px;font-size:12px;transition:.18s}.st-toast.show{opacity:1;transform:translate(-50%,0)}

@media(max-width:900px){
.st-tool-scroll{padding:12px}.st-tool-grid,.st-tuner-layout{grid-template-columns:1fr}.st-tuner-face{min-height:430px}.st-prog-details{grid-template-columns:1fr}.st-genre-grid{grid-template-columns:1fr}.st-tool-head{padding:0 12px}.st-tuner-note{font-size:84px}
}
116 changes: 116 additions & 0 deletions app/static/stemmy-tools/part-01.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
(() => {
'use strict';

const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const mod = (n, m) => ((n % m) + m) % m;
const SHARP = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B'];
const NOTE_RX = /^([A-G])([#b]?)(-?\d+)?$/;
let toastTimer = 0;

function toast(message) {
let el = $('#stToast');
if (!el) {
el = document.createElement('div');
el.id = 'stToast'; el.className = 'st-toast'; document.body.appendChild(el);
}
el.textContent = message;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 1700);
}

async function copyText(text) {
try {
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
} catch (_) {}
const ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0';
document.body.appendChild(ta); ta.select();
try { return document.execCommand('copy'); } finally { ta.remove(); }
}

function svg(path) {
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
}

function pauseStemmyAudio() {
try { if (typeof window.togglePlay === 'function') window.togglePlay(false); } catch (_) {}
$$('audio').forEach(a => { try { a.pause(); } catch (_) {} });
}

function insertTopButton(id, label, title, iconPath, afterId = 'karbtn') {
const anchor = $('#' + afterId) || $('#karbtn');
const host = anchor && anchor.parentElement;
if (!host || $('#' + id)) return null;
const btn = document.createElement('button');
btn.id = id;
btn.className = 'topact st-tools-btn';
btn.title = title;
btn.innerHTML = svg(iconPath) + `<span>${label}</span>`;
anchor.insertAdjacentElement('afterend', btn);
return btn;
}

function makeStage(id, title, subtitle, body) {
const stage = document.createElement('section');
stage.id = id;
stage.className = 'st-tool-stage';
stage.setAttribute('aria-hidden', 'true');
stage.innerHTML = `
<header class="st-tool-head">
<div>${svg('<path d="M4 12h2l2-6 3 15 3-12 2 6h4"/>')}</div>
<div><div class="st-title">${title}</div><div class="st-sub">${subtitle}</div></div>
<div class="st-spacer"></div>
<button class="st-tool-close" type="button" title="Close" aria-label="Close">✕</button>
</header>
<div class="st-tool-scroll">${body}</div>`;
document.body.appendChild(stage);
$('.st-tool-close', stage).addEventListener('click', () => hideStage(stage));
return stage;
}

function showStage(stage) {
pauseStemmyAudio();
stage.classList.add('show');
stage.setAttribute('aria-hidden', 'false');
}

function hideStage(stage) {
stage.classList.remove('show');
stage.setAttribute('aria-hidden', 'true');
stage.dispatchEvent(new CustomEvent('stemmy:hide'));
}

document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
const open = $('.st-tool-stage.show');
if (open) hideStage(open);
});

// ---------------------------------------------------------------------------
// Stable chromatic tuner
// ---------------------------------------------------------------------------
const TUNINGS = {
standard: {label:'Standard', notes:['E2','A2','D3','G3','B3','E4']},
half: {label:'Half-step down', notes:['Eb2','Ab2','Db3','Gb3','Bb3','Eb4']},
dropd: {label:'Drop D', notes:['D2','A2','D3','G3','B3','E4']},
dstd: {label:'D standard', notes:['D2','G2','C3','F3','A3','D4']},
dropcs: {label:'Drop C#', notes:['C#2','G#2','C#3','F#3','A#3','D#4']},
dropc: {label:'Drop C', notes:['C2','G2','C3','F3','A3','D4']},
openg: {label:'Open G', notes:['D2','G2','D3','G3','B3','D4']},
opend: {label:'Open D', notes:['D2','A2','D3','F#3','A3','D4']}
};

const tunerBody = `
<div class="st-tuner-layout">
<aside class="st-panel">
<div class="st-panel-h"><h3>Tuner settings</h3><small>all processing stays local</small></div>
<div class="st-panel-b">
<div class="st-field"><label>Input device</label><select class="st-select" id="stTunerDevice"><option value="">Default microphone</option></select></div>
<div class="st-field"><label>Tuning preset</label><select class="st-select" id="stTuning"></select></div>
<div class="st-string-grid" id="stStrings"></div>
<div class="st-field" style="margin-top:16px"><label>Note names</label>
<div class="st-row"><label class="st-switch"><input type="radio" name="stAcc" value="sharp"> Sharps</label><label class="st-switch"><input type="radio" name="stAcc" value="flat" checked> Flats</label></div>
</div>
117 changes: 117 additions & 0 deletions app/static/stemmy-tools/part-02.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<div class="st-field"><label>Reference pitch</label><div class="st-row"><input class="st-input" id="stA4" type="number" min="430" max="450" step="1" value="440"><span class="st-note">Hz for A4</span></div></div>
<div class="st-field"><label>Input level</label><div class="st-level"><i id="stLevel"></i></div></div>
<div class="st-row"><button class="st-button primary" id="stTunerStart">Start microphone</button><button class="st-button danger" id="stTunerStop" disabled>Stop</button></div>
<p class="st-note" style="margin:14px 0 0">Stable mode uses confidence filtering, a rolling median, gentle cents smoothing, and note-lock hysteresis so the display does not jump around on every noisy frame.</p>
</div>
</aside>
<main class="st-panel st-tuner-face">
<div class="st-tuner-status" id="stTunerStatus">Microphone is off</div>
<div class="st-tuner-note" id="stTunerNote">—</div>
<div class="st-tuner-cents" id="stTunerCents">Play one string</div>
<div class="st-meter"><div class="st-meter-line"></div><div class="st-meter-center"></div><div class="st-meter-needle" id="stNeedle"></div></div>
<div class="st-meter-labels"><span>−50 flat</span><span>in tune</span><span>+50 sharp</span></div>
<div class="st-tuner-meta"><span id="stFreq">— Hz</span><span id="stConfidence">confidence —</span><span id="stHold">stable lock —</span></div>
</main>
</div>`;

const tunerStage = makeStage('stTunerStage', 'Chromatic tuner', 'stable guitar tuning · alternate tunings · flats and sharps', tunerBody);
const tunerBtn = insertTopButton('tunerbtn', 'Tuner', 'Open stable chromatic tuner', '<path d="M4 12h2l2-7 4 14 3-9 2 5h3"/><circle cx="12" cy="12" r="9"/>');
if (tunerBtn) tunerBtn.addEventListener('click', () => showStage(tunerStage));

const tuner = {
ctx:null, stream:null, source:null, analyser:null, raf:0, timer:0, buffer:null,
midiHistory:[], lockedMidi:null, candidateMidi:null, candidateFrames:0,
smoothCents:0, lastGoodAt:0, running:false, devicesLoaded:false
};

function noteToMidi(note) {
const m = NOTE_RX.exec(note);
if (!m) return null;
const letter = m[1], accidental = m[2] || '', octave = Number(m[3]);
const base = {C:0,D:2,E:4,F:5,G:7,A:9,B:11}[letter];
const shift = accidental === '#' ? 1 : accidental === 'b' ? -1 : 0;
return (octave + 1) * 12 + mod(base + shift, 12);
}

function midiName(midi, flats) {
const rounded = Math.round(midi);
const names = flats ? FLAT : SHARP;
return names[mod(rounded,12)] + (Math.floor(rounded / 12) - 1);
}

function midiFrequency(midi, a4) {
return a4 * Math.pow(2, (midi - 69) / 12);
}

function median(values) {
if (!values.length) return 0;
const a = values.slice().sort((x,y)=>x-y);
const mid = Math.floor(a.length/2);
return a.length % 2 ? a[mid] : (a[mid-1]+a[mid])/2;
}

function yinPitch(input, sampleRate) {
const n = Math.min(input.length, 8192);
const minFreq = 55, maxFreq = 1200;
const minTau = Math.max(2, Math.floor(sampleRate / maxFreq));
const maxTau = Math.min(Math.floor(sampleRate / minFreq), Math.floor(n / 2) - 1);
const usable = Math.min(3072, n - maxTau - 1);
if (usable < 512) return null;

let rms = 0;
for (let i=0;i<n;i++) rms += input[i]*input[i];
rms = Math.sqrt(rms/n);
if (rms < 0.006) return {frequency:null, confidence:0, rms};

const diff = new Float32Array(maxTau + 1);
for (let tau=minTau; tau<=maxTau; tau++) {
let sum = 0;
for (let i=0; i<usable; i++) {
const d = input[i] - input[i+tau];
sum += d*d;
}
diff[tau] = sum;
}

let running = 0;
const cmnd = new Float32Array(maxTau + 1);
cmnd[0] = 1;
for (let tau=1; tau<=maxTau; tau++) {
running += diff[tau];
cmnd[tau] = running ? diff[tau] * tau / running : 1;
}

let tau = -1;
const threshold = 0.14;
for (let t=minTau; t<=maxTau; t++) {
if (cmnd[t] < threshold) {
while (t+1 <= maxTau && cmnd[t+1] < cmnd[t]) t++;
tau = t; break;
}
}
if (tau < 0) {
let best = minTau;
for (let t=minTau+1;t<=maxTau;t++) if (cmnd[t] < cmnd[best]) best=t;
if (cmnd[best] > 0.28) return {frequency:null, confidence:1-cmnd[best], rms};
tau = best;
}

let betterTau = tau;
if (tau > 1 && tau < maxTau) {
const s0=cmnd[tau-1], s1=cmnd[tau], s2=cmnd[tau+1];
const denom = 2*(2*s1-s2-s0);
if (Math.abs(denom) > 1e-9) betterTau = tau + (s2-s0)/denom;
}
return {frequency: sampleRate / betterTau, confidence: clamp(1-cmnd[tau],0,1), rms};
}

function tunerFlatMode() { return ($('input[name="stAcc"]:checked') || {}).value === 'flat'; }
function tunerA4() { return clamp(parseFloat($('#stA4').value) || 440, 430, 450); }

function fillTuningSelect() {
const sel = $('#stTuning');
Object.entries(TUNINGS).forEach(([key,t]) => {
const o=document.createElement('option'); o.value=key; o.textContent=t.label; sel.appendChild(o);
});
sel.value = 'half';
renderStrings();
Loading