basic-pitch-wasm / index.html
SegerEnd's picture
fix: open links in a new tab
1fae232 verified
Raw History Blame Contribute Delete
48.2 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Basic Pitch without TensorFlow</title>
<link rel="icon" href="data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3e%3ctext y='.9em' font-size='90'%3e🎹%3c/text%3e%3c/svg%3e">
<meta name="description" content="Audio to MIDI in the browser with Spotify's Basic Pitch model, run by a small C/WebAssembly runtime instead of TensorFlow.">
<script>
// Gradio's dark theme is .dark, set from the system setting
const dark = matchMedia("(prefers-color-scheme: dark)");
const setDark = () => document.documentElement.classList.toggle("dark", dark.matches);
setDark(); dark.addEventListener("change", setDark);
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;600;700&display=swap">
<link rel="stylesheet" href="gradio-theme.css">
<style>
:root { --font: 'Source Sans 3', 'Source Sans Pro', ui-sans-serif, system-ui, sans-serif; --roll-alt: var(--neutral-100); --roll-line: var(--neutral-200); }
:root.dark { --roll-alt: var(--neutral-900); --roll-line: var(--neutral-700); }
/* Submit: an orange gradient */
:root, :root.dark {
--button-primary-background-fill: linear-gradient(to bottom right, var(--primary-400), var(--primary-600));
--button-primary-background-fill-hover: linear-gradient(to bottom right, var(--primary-500), var(--primary-700));
--button-primary-border-color: var(--primary-500);
--button-primary-text-color: white;
}
/* grey buttons: huggingface.co's .btn */
:root {
--button-secondary-background-fill: linear-gradient(to bottom, #fff, oklch(96.7% .003 264.542));
--button-secondary-background-fill-hover: var(--button-secondary-background-fill);
--button-secondary-border-color: oklch(92.8% .006 264.531);
--button-secondary-text-color: oklch(27.8% .033 256.848);
}
:root.dark {
--button-secondary-background-fill: linear-gradient(to bottom, oklch(27.8% .033 256.848), #0b0f19);
--button-secondary-background-fill-hover: linear-gradient(to bottom, oklch(37.3% .034 259.733), #0b0f19);
--button-secondary-border-color: #141c2e;
--button-secondary-text-color: oklch(92.8% .006 264.531);
}
/* from Gradio's global.css */
:root { --line-sm: 1.4; --line-md: 1.5; --toast-error: #b91c1c; --toast-warning: #a16207; }
:root.dark { --toast-error: #ef4444; --toast-warning: #eab308; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { background: var(--body-background-fill); }
body { margin: 0; background: var(--body-background-fill); color: var(--body-text-color); font-family: var(--font); font-size: var(--text-md); line-height: var(--line-md); }
.app { max-width: 1280px; margin: 0 auto; padding: 16px 32px 24px; }
@media (max-width: 640px) { .app { padding: 16px 16px 24px; } }
.prose h1 { font-size: var(--text-xxl); font-weight: 600; margin: 0 0 16px; text-align: center; }
.prose p { margin: 0; font-size: var(--text-md); line-height: 1.625; color: var(--body-text-color); }
.prose a { color: var(--link-text-color); }
.row { display: flex; flex-wrap: wrap; gap: var(--layout-gap); margin-top: var(--layout-gap); }
.col { flex: 1 1 400px; min-width: min(100%, 320px); display: flex; flex-direction: column; gap: var(--layout-gap); }
/* block and label */
.block { position: relative; width: 100%; background: var(--block-background-fill); border: var(--block-border-width) solid var(--block-border-color);
border-radius: var(--block-radius); box-shadow: var(--block-shadow); padding: var(--block-padding); line-height: var(--line-sm); }
.block.labelled { padding-top: 36px; }
.label { position: absolute; top: var(--block-label-margin); left: var(--block-label-margin); display: inline-flex; align-items: center; z-index: 2;
box-shadow: var(--block-label-shadow); background: var(--block-label-background-fill);
border: var(--block-label-border-width) solid var(--block-label-border-color); border-top: none; border-left: none; border-radius: var(--block-label-radius);
padding: var(--block-label-padding); color: var(--block-label-text-color); font-size: var(--block-label-text-size);
font-weight: var(--block-label-text-weight); line-height: var(--line-sm); pointer-events: none; }
.label svg { width: calc(var(--block-label-text-size) - 1px); height: calc(var(--block-label-text-size) - 1px); margin-right: 8px; opacity: .8; }
/* icon button */
.icon-buttons { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; z-index: 2; }
.icon-button { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 4px; cursor: pointer;
background: var(--block-background-fill); border: 1px solid var(--border-color-primary); border-radius: var(--radius-sm); color: var(--block-label-text-color); }
.icon-button:hover { color: var(--color-accent); }
.icon-button svg { width: 100%; height: 100%; }
/* upload */
#drop { display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 240px; padding-top: 12px; margin: auto 8px;
color: var(--block-label-text-color); line-height: var(--line-md); text-align: center; cursor: pointer; }
@media (min-width: 768px) { #drop { font-size: var(--text-lg); } }
#drop .icon-wrap { width: 30px; margin-bottom: var(--spacing-lg); }
#drop.over .icon-wrap { color: var(--color-accent); }
#drop .or { color: var(--body-text-color-subdued); display: flex; }
#file { display: none; }
#loaded { display: none; flex-direction: column; gap: 8px; padding-top: 4px; }
#loaded audio { width: 100%; }
#loaded .name { color: var(--body-text-color); word-break: break-all; }
.has-file #drop { display: none; } .has-file #loaded { display: flex; }
#input:not(.has-file) .icon-buttons { display: none; }
/* accordion */
.accordion > .label-wrap { display: flex; justify-content: space-between; width: 100%; cursor: pointer; background: none; border: none; padding: 0;
font: inherit; color: var(--accordion-text-color); font-weight: var(--section-header-text-weight); font-size: var(--section-header-text-size); }
.accordion > .label-wrap .icon { transition: 150ms; transform: rotate(90deg); }
.accordion.open > .label-wrap { margin-bottom: 8px; }
.accordion.open:not(.closing) > .label-wrap .icon { transform: rotate(0); }
.accordion > .content { display: none; flex-direction: column; gap: var(--layout-gap); }
.accordion.open > .content { display: flex; }
/* slider */
.slider .head { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; width: 100%; }
.slider .head > label { flex: 1; }
.slider .title { display: inline-block; margin-bottom: var(--spacing-xs); color: var(--block-title-text-color); font-weight: var(--block-title-text-weight);
font-size: var(--block-title-text-size); line-height: var(--line-sm); }
.slider .info { font-weight: var(--block-info-text-weight); font-size: var(--block-info-text-size); line-height: var(--line-sm);
color: var(--block-info-text-color); margin-bottom: var(--spacing-md); }
.tab-like-container { display: flex; align-items: stretch; margin-left: auto; border: 1px solid var(--input-border-color); border-radius: var(--radius-sm); overflow: hidden; height: 24px; }
.tab-like-container input[type=number] { display: block; outline: none; border: none; border-radius: 0; background: var(--input-background-fill); padding: 4px 8px;
height: 100%; min-width: 56px; width: 64px; color: var(--body-text-color); font: inherit; font-size: var(--text-sm); line-height: var(--line-sm); text-align: center; }
.tab-like-container input[type=number]:focus { box-shadow: inset 0 0 0 1px var(--color-accent); border-radius: 3px 0 0 3px; }
.reset-button { display: flex; align-items: center; justify-content: center; background: none; border: none; border-left: 1px solid var(--input-border-color);
cursor: pointer; font-size: var(--text-sm); color: var(--body-text-color); padding: 0 8px; min-width: 24px; }
.reset-button:hover { background-color: var(--background-fill-secondary); }
.slider_input_container { display: flex; align-items: center; gap: 8px; }
.min_value, .max_value { font-size: var(--text-sm); color: var(--body-text-color-subdued); }
.boost-info { font-size: var(--block-info-text-size); line-height: var(--line-sm); color: var(--block-info-text-color); margin-bottom: 10px; }
.reset-all { display: flex; justify-content: flex-end; }
.ends { display: flex; justify-content: space-between; margin-top: 6px; font-size: var(--block-info-text-size); color: var(--block-info-text-color); }
@media (max-width: 480px) { .min_value, .max_value { display: none; } }
input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; min-width: 112px; cursor: pointer; outline: none; border-radius: var(--radius-xl); background: transparent; margin: 0; }
input[type=range]::-webkit-slider-runnable-track { height: 8px; border-radius: var(--radius-xl);
background: linear-gradient(to right, var(--slider-color) var(--range_progress, 50%), var(--neutral-200) var(--range_progress, 50%)); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 16px; width: 16px; background-color: white; border-radius: 50%; margin-top: -4px;
box-shadow: 0 0 0 1px rgba(247, 246, 246, 0.739), 1px 1px 4px rgba(0, 0, 0, 0.1); }
input[type=range]::-moz-range-track { height: 8px; background: var(--neutral-200); border-radius: var(--radius-xl); }
input[type=range]::-moz-range-progress { height: 8px; background-color: var(--slider-color); border-radius: var(--radius-xl); }
input[type=range]::-moz-range-thumb { height: 16px; width: 16px; background-color: white; border-radius: 50%; border: none;
box-shadow: 0 0 0 1px rgba(247, 246, 246, 0.739), 1px 1px 4px rgba(0, 0, 0, 0.1); }
/* checkbox */
.checkbox-container { display: inline-flex; align-items: center; gap: var(--spacing-lg); cursor: pointer; }
.checkbox-container .label-text { color: var(--body-text-color); font-size: var(--checkbox-label-text-size); line-height: var(--line-sm); }
.checkbox-container input { -webkit-appearance: none; appearance: none; margin: 0; width: 16px; height: 16px; flex-shrink: 0; cursor: pointer;
box-shadow: var(--checkbox-shadow); border: 1px solid var(--checkbox-border-color); border-radius: var(--checkbox-border-radius);
background-color: var(--checkbox-background-color); background-size: 100% 100%; }
.checkbox-container input:hover { border-color: var(--checkbox-border-color-hover); background-color: var(--checkbox-background-color-hover); }
.checkbox-container input:checked { background-image: var(--checkbox-check); background-color: var(--checkbox-background-color-selected); border-color: var(--checkbox-border-color-focus); }
/* dropdown */
.dropdown select { -webkit-appearance: none; appearance: none; font: inherit; font-size: var(--text-sm); color: var(--body-text-color); cursor: pointer;
padding: 4px 28px 4px 10px; border: 1px solid var(--input-border-color); border-radius: var(--input-radius); background-color: var(--input-background-fill);
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2371717a'%3e%3cpath d='M5.3 7.3a1 1 0 0 1 1.4 0L10 10.6l3.3-3.3a1 1 0 1 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 0-1.4z'/%3e%3c/svg%3e");
background-repeat: no-repeat; background-position: right 6px center; background-size: 16px; }
.dropdown select:focus { outline: none; border-color: var(--color-accent); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
/* buttons */
.buttons { display: flex; gap: var(--layout-gap); }
.btn { display: inline-flex; justify-content: center; align-items: center; gap: 6px; flex: 1; font: inherit; cursor: pointer;
font-size: var(--button-large-text-size); font-weight: var(--button-large-text-weight); padding: var(--button-large-padding);
border-radius: var(--button-large-radius); border: var(--button-border-width, 1px) solid transparent; box-shadow: var(--button-primary-shadow, none); }
.btn.primary { background: var(--button-primary-background-fill); border-color: var(--button-primary-border-color); color: var(--button-primary-text-color); }
.btn.primary:hover { background: var(--button-primary-background-fill-hover); }
.btn.secondary { background: var(--button-secondary-background-fill); border-color: var(--button-secondary-border-color); color: var(--button-secondary-text-color); }
.btn.secondary:hover:not(:disabled) { background: var(--button-secondary-background-fill-hover); box-shadow: inset 0 2px 4px 0 #0000000d; }
.btn.secondary { border-width: 1px; padding: 6px 16px; }
.btn.secondary.sm { padding: 4px 12px; }
.btn, .btn.sm { border-radius: 8px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* press animation from segerend.nl */
.btn, .icon-button, .reset-button, .toast-close { transition: border-color .15s, color .15s, opacity .15s, scale .4s cubic-bezier(0.34, 1.4, 0.64, 1); }
.btn:is(:active, .pressed):not(:disabled) { scale: .96; transition-duration: .15s, .15s, .15s, .08s; }
:is(.icon-button, .reset-button, .toast-close):active { scale: .88; transition-duration: .15s, .15s, .15s, .08s; }
@media (prefers-reduced-motion: reduce) { .btn, .icon-button, .reset-button, .toast-close { scale: none !important; } }
.btn.sm { flex: none; font-size: var(--button-small-text-size); font-weight: var(--button-small-text-weight); padding: var(--button-small-padding); border-radius: var(--button-small-radius); }
.btn svg { width: 1.1em; height: 1.1em; }
/* progress */
.status { position: absolute; inset: 0; z-index: 3; display: none; flex-direction: column; justify-content: center; align-items: center;
border-radius: var(--block-radius); background: var(--block-background-fill); padding: 0 24px; overflow: hidden; }
.pending .status { display: flex; }
.eta-bar { position: absolute; inset: 0; transform-origin: left; opacity: .8; z-index: 1; transition: 10ms; background: var(--background-fill-secondary); transform: translateX(-100%); }
.meta-text { position: absolute; bottom: 0; right: 0; z-index: 2; padding: 4px 8px; color: var(--body-text-color); font-size: var(--text-sm); font-family: var(--font-mono); }
.progress-level { display: flex; flex-direction: column; align-items: center; width: 100%; z-index: 2; }
.progress-level-inner { margin: 8px auto; color: var(--body-text-color); font-size: var(--text-sm); font-family: var(--font-mono); }
.progress-bar-wrap { border: 1px solid var(--border-color-primary); background: var(--background-fill-primary); width: 55.5%; height: 16px; }
.progress-bar { background-color: var(--loader-color); width: 0; height: 100%; transition: width 150ms; }
.empty { display: flex; justify-content: center; align-items: center; min-height: 236px; }
.empty .icon { opacity: .5; height: 20px; width: 20px; color: var(--body-text-color); }
.empty .icon svg { width: 100%; height: 100%; }
.player { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 10px; }
.player .grow { flex: 1; }
#time { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--body-text-color-subdued); }
#scroll { overflow-x: auto; overscroll-behavior-x: contain; border: 1px solid var(--border-color-primary); border-radius: var(--radius-sm); background: var(--background-fill-primary); }
#track { position: relative; }
#roll { display: block; position: sticky; left: 0; cursor: pointer; }
.zoom { display: inline-flex; gap: 4px; }
.zoom .btn.sm { min-width: 30px; }
#stats { margin: 8px 0 0; font-size: var(--block-info-text-size); color: var(--block-info-text-color); }
/* toasts */
.toast-wrap { position: fixed; top: 28px; right: 12px; left: 12px; z-index: 100; display: flex; flex-direction: column; gap: 8px; align-items: end; pointer-events: none; }
@media (min-width: 640px) { .toast-wrap { left: auto; width: 424px; } }
.toast-body { width: 100%; position: relative; display: flex; flex-direction: column; border-radius: var(--radius-sm); overflow: hidden; pointer-events: auto;
background: var(--background-fill-secondary); border: 1px solid var(--border-color-primary); box-shadow: var(--shadow-drop); animation: fade .2s; }
:root.dark .toast-body { background: var(--background-fill-primary); }
.toast-body.error { --toast-color: var(--toast-error); } .toast-body.warning { --toast-color: var(--toast-warning); }
.toast-header { display: flex; align-items: center; padding: 12px; }
.toast-icon { display: flex; justify-content: center; align-items: center; flex-shrink: 0; margin-right: 8px; border-radius: 999px; padding: 6px; width: 32px; height: 32px; color: var(--toast-color); }
.toast-icon svg { width: 100%; height: 100%; }
.toast-title { flex: 1; font-weight: 700; font-size: var(--text-lg); line-height: var(--line-sm); color: var(--toast-color); }
.toast-close { margin: 0 4px; border-radius: var(--radius-lg); padding: 4px; font-size: var(--text-xl); line-height: 1; background: transparent; border: none; cursor: pointer;
width: 28px; height: 28px; opacity: .6; color: var(--body-text-color); }
.toast-close:hover { opacity: 1; }
.toast-text { padding: 0 12px 12px 52px; font-size: var(--text-md); color: var(--body-text-color); }
.timer { position: absolute; bottom: 0; left: 0; height: 4px; width: 100%; background: var(--toast-color); transform-origin: left; animation: countdown 10s linear forwards; opacity: .7; }
@keyframes countdown { to { transform: scaleX(0); } }
@keyframes fade { from { opacity: 0; } }
footer { margin-top: 40px; text-align: center; color: var(--body-text-color-subdued); font-size: var(--text-sm); }
footer p { margin: 4px auto; max-width: 900px; }
footer a { color: inherit; } footer a:hover { color: var(--body-text-color); }
footer .made-by { margin-top: 10px; font-size: 13px; color: var(--block-label-text-color); }
footer .made-by a { text-decoration-color: var(--border-color-primary); text-underline-offset: 3px; }
footer .made-by a:hover { color: var(--color-accent); text-decoration-color: currentColor; }
</style>
</head>
<body>
<div class="app">
<div class="prose">
<h1>Basic Pitch without TensorFlow</h1>
<p>Audio to MIDI with <a href="https://huggingface.co/spotify/basic-pitch" target="_blank" rel="noopener">Spotify's Basic Pitch</a>. The model and weights are Spotify's, unchanged. They run here on a small C runtime compiled to WebAssembly, using several threads, with a faster note finder. Everything happens in your browser.</p>
</div>
<div class="row">
<div class="col">
<div class="block labelled" id="input">
<span class="label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>Audio</span>
<div class="icon-buttons"><button class="icon-button" id="remove" title="Clear" aria-label="Clear"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
<label id="drop" for="file">
<span class="icon-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></span>
Drop Audio Here
<span class="or">- or -</span>
Click to Upload
</label>
<div id="loaded">
<span class="name" id="fileName"></span>
<audio id="preview" controls></audio>
</div>
<input id="file" type="file" accept="audio/*">
</div>
<div class="block accordion" id="settings">
<button class="label-wrap" id="settingsToggle"><span>Settings</span><span class="icon">▼</span></button>
<div class="content">
<div class="block slider">
<div class="head"><label for="onset"><span class="title">Note segmentation</span><div class="info">How easily a note should be split into two.</div></label>
<div class="tab-like-container"><input type="number" id="onsetN" min="0.05" max="0.95" step="0.05"><button class="reset-button" data-reset="onset" aria-label="Reset to default value">↺</button></div></div>
<div class="slider_input_container"><span class="min_value">0.05</span><input id="onset" type="range" min="0.05" max="0.95" step="0.05" value="0.5"><span class="max_value">0.95</span></div>
<div class="ends"><span>Split notes</span><span>Merge notes</span></div>
</div>
<div class="block slider">
<div class="head"><label for="frame"><span class="title">Model confidence threshold</span><div class="info">The model confidence required to create a note.</div></label>
<div class="tab-like-container"><input type="number" id="frameN" min="0.05" max="0.95" step="0.05"><button class="reset-button" data-reset="frame" aria-label="Reset to default value">↺</button></div></div>
<div class="slider_input_container"><span class="min_value">0.05</span><input id="frame" type="range" min="0.05" max="0.95" step="0.05" value="0.3"><span class="max_value">0.95</span></div>
<div class="ends"><span>More notes</span><span>Fewer notes</span></div>
</div>
<div class="block slider">
<div class="head"><label for="minPitch"><span class="title">Minimum pitch (Hz)</span><div class="info">The minimum allowed pitch, in Hz. 0 for no limit.</div></label>
<div class="tab-like-container"><input type="number" id="minPitchN" min="0" max="2000" step="10"><button class="reset-button" data-reset="minPitch" aria-label="Reset to default value">↺</button></div></div>
<div class="slider_input_container"><span class="min_value">0</span><input id="minPitch" type="range" min="0" max="2000" step="10" value="0"><span class="max_value">2000</span></div>
<div class="ends"><span>Lower notes</span><span>Higher notes</span></div>
</div>
<div class="block slider">
<div class="head"><label for="maxPitch"><span class="title">Maximum pitch (Hz)</span><div class="info">The maximum allowed pitch, in Hz.</div></label>
<div class="tab-like-container"><input type="number" id="maxPitchN" min="40" max="3000" step="10"><button class="reset-button" data-reset="maxPitch" aria-label="Reset to default value">↺</button></div></div>
<div class="slider_input_container"><span class="min_value">40</span><input id="maxPitch" type="range" min="40" max="3000" step="10" value="3000"><span class="max_value">3000</span></div>
<div class="ends"><span>Lower notes</span><span>Higher notes</span></div>
</div>
<div class="block slider">
<div class="head"><label for="minNote"><span class="title">Minimum note length (ms)</span><div class="info">The minimum length required to emit a note, in milliseconds.</div></label>
<div class="tab-like-container"><input type="number" id="minNoteN" min="12" max="500" step="1"><button class="reset-button" data-reset="minNote" aria-label="Reset to default value">↺</button></div></div>
<div class="slider_input_container"><span class="min_value">12</span><input id="minNote" type="range" min="12" max="500" step="1" value="128"><span class="max_value">500</span></div>
<div class="ends"><span>Short notes</span><span>Long notes</span></div>
</div>
<div class="block">
<div class="info boost-info">Also start notes where a key suddenly gets louder, not only where the model hears a note start.</div>
<label class="checkbox-container"><input id="boost" type="checkbox" checked><span class="label-text">Boost onsets</span></label>
</div>
<div class="block slider">
<div class="head"><label for="tempo"><span class="title">MIDI file tempo (BPM)</span><div class="info">The tempo used to export the MIDI file. The notes keep their timing, only the file's beat grid changes.</div></label>
<div class="tab-like-container"><input type="number" id="tempoN" min="24" max="224" step="1"><button class="reset-button" data-reset="tempo" aria-label="Reset to default value">↺</button></div></div>
<div class="slider_input_container"><span class="min_value">24</span><input id="tempo" type="range" min="24" max="224" step="1" value="120"><span class="max_value">224</span></div>
</div>
<div class="reset-all"><button class="btn secondary sm" id="resetAll" disabled>Reset all</button></div>
</div>
</div>
<div class="buttons">
<button class="btn secondary" id="clear">Clear</button>
<button class="btn primary" id="submit">Submit</button>
</div>
</div>
<div class="col">
<div class="block labelled" id="output">
<span class="label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M8 4v10M13 4v10M18 4v10"/></svg>Notes</span>
<div class="status">
<div class="eta-bar" id="etaBar"></div>
<div class="meta-text" id="eta"></div>
<div class="progress-level"><div class="progress-level-inner" id="progressText"></div><div class="progress-bar-wrap"><div class="progress-bar" id="bar"></div></div></div>
</div>
<div class="empty" id="empty"><div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M8 4v10M13 4v10M18 4v10"/></svg></div></div>
<div id="shown" hidden>
<div class="player">
<button class="btn secondary sm" id="play" title="Play / pause (Space)">▶ Play</button>
<span id="time">0:00 / 0:00</span>
<span class="zoom"><button class="btn secondary sm" id="zoomOut" title="Zoom out">−</button><button class="btn secondary sm" id="zoomIn" title="Zoom in (or Ctrl / ⌘ and scroll)">+</button><button class="btn secondary sm" id="zoomFit" title="Show the whole recording">Fit</button></span>
<span class="grow"></span>
<label class="dropdown"><span class="sr-only">Sound</span>
<select id="sound" title="Sound">
<option value="synth">Synth</option>
<option value="piano">Piano</option>
<option value="epiano">Electric piano</option>
<option value="guitar">Guitar</option>
<option value="marimba">Marimba</option>
<option value="strings">Strings</option>
</select>
</label>
<label class="checkbox-container"><input id="hearAudio" type="checkbox"><span class="label-text">Original</span></label>
<label class="checkbox-container"><input id="hearNotes" type="checkbox" checked><span class="label-text">Notes</span></label>
</div>
<div id="scroll"><div id="track"><canvas id="roll"></canvas></div></div>
<p id="stats"></p>
</div>
</div>
<button class="btn secondary" id="download" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span id="downloadLabel">Download MIDI</span></button>
</div>
</div>
<div class="toast-wrap" id="toasts"></div>
<footer>
<p>Model and weights: <a href="https://huggingface.co/spotify/basic-pitch" target="_blank" rel="noopener">Basic Pitch</a> by Spotify, Apache 2.0, unchanged. Runtime and note finder: rewritten, see <code>src/</code> and <code>basicpitch/</code> in this Space's files. With the same settings it finds the same notes as basicpitch.spotify.com.</p>
<p>Page styled with the variables of Gradio's default theme (Apache 2.0). This is a static page, not a Gradio app.</p>
<p>Sounds: piano from the <a href="https://github.com/Tonejs/audio/tree/master/salamander" target="_blank" rel="noopener">Salamander Grand Piano</a> by Alexander Holm (<a href="https://creativecommons.org/licenses/by/3.0/" target="_blank" rel="noopener">CC BY 3.0</a>), electric piano and guitar from FluidR3 GM by Frank Wen (MIT), marimba and strings from <a href="https://github.com/sgossner/VSCO-2-CE" target="_blank" rel="noopener">VSCO 2 CE</a> by Versilian Studios (CC0). All shortened and made mono.</p>
<p class="made-by">Made by <a href="https://segerend.nl" target="_blank" rel="noopener">segerend.nl</a></p>
</footer>
</div>
<script type="module">
import { SAMPLE_RATE, preload, transcribe, findNotes, midiNotes, toMidi } from "./basicpitch/basicpitch.js";
const $ = (id) => document.getElementById(id);
const THREADS = Math.max(1, Math.min(8, (navigator.hardwareConcurrency || 4) - 1));
setTimeout(() => preload({ threads: THREADS }).catch(() => {}), 300);
let file = null, audio = null, output = null; // the chosen file, it decoded, the model's output
let notes = [], midi = null, midiUrl = null, midiName = "notes.mid";
let ctx = null, job = null; // the AudioContext, the running transcription's AbortController
// Upload
const input = $("input"), drop = $("drop");
$("file").addEventListener("change", () => $("file").files[0] && pick($("file").files[0]));
for (const type of ["dragenter", "dragover"]) input.addEventListener(type, (e) => { e.preventDefault(); drop.classList.add("over"); });
for (const type of ["dragleave", "drop"]) input.addEventListener(type, () => drop.classList.remove("over"));
input.addEventListener("drop", (e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) pick(f); });
function pick(f) {
file = f;
URL.revokeObjectURL($("preview").src);
$("preview").src = URL.createObjectURL(f);
$("fileName").textContent = f.name;
input.classList.add("has-file");
}
function unpick() {
file = null;
$("file").value = "";
$("preview").removeAttribute("src");
input.classList.remove("has-file");
}
$("remove").addEventListener("click", unpick);
$("clear").addEventListener("click", () => {
job?.abort();
stopPlaying();
unpick();
audio = output = midi = null;
notes = [];
offset = 0;
showOutput(false);
pending(false);
});
$("submit").addEventListener("click", () => (file ? run(file) : toast("warning", "Upload an audio file first.")));
// Running Basic Pitch
let progress = 0, started = 0, stage = "";
function pending(on) {
$("output").classList.toggle("pending", on);
if (!on) return;
$("bar").style.width = `${Math.round(progress * 100)}%`;
$("etaBar").style.transform = `translateX(${progress * 100 - 100}%)`;
$("progressText").textContent = stage === "Listening…" ? `${stage} ${Math.round(progress * 100)}%` : stage;
$("eta").textContent = `processing | ${((performance.now() - started) / 1000).toFixed(1)}s`;
}
function showOutput(on) {
$("empty").hidden = on;
$("shown").hidden = !on;
$("download").disabled = !on;
if (!on) $("downloadLabel").textContent = "Download MIDI";
}
async function run(f) {
job?.abort();
const stop = job = new AbortController();
stopPlaying();
offset = 0;
scroll.scrollLeft = 0;
progress = 0; started = performance.now(); stage = "Decoding audio…";
const clock = setInterval(() => job === stop && pending(true), 100);
pending(true);
try {
// to 22050 Hz mono exactly as basicpitch.spotify.com does it (stereo ends up at half volume there too)
ctx ??= new AudioContext();
audio = await ctx.decodeAudioData(await f.arrayBuffer());
const offline = new OfflineAudioContext(1, audio.length * (SAMPLE_RATE / audio.sampleRate), SAMPLE_RATE);
const merger = offline.createChannelMerger(audio.numberOfChannels), source = offline.createBufferSource();
source.buffer = audio;
source.connect(merger);
merger.connect(offline.destination);
source.start();
const samples = (await offline.startRendering()).getChannelData(0);
if (stop.signal.aborted) return;
const decoded = performance.now() - started;
stage = "Listening…";
output = await transcribe(samples, { threads: THREADS, signal: stop.signal, onProgress: (done) => { progress = done; } });
updateNotes();
const total = performance.now() - started;
$("stats").textContent = `${fmt(audio.duration)} of audio in ${ms(total)}: decoding ${ms(decoded)}, Basic Pitch ${ms(total - decoded)} on ${THREADS} threads.`;
showOutput(true);
draw();
} catch (error) {
if (error.name !== "AbortError") toast("error", `Couldn't read that file (${error.message}).`);
} finally {
clearInterval(clock);
if (job === stop) pending(false);
}
}
// Notes and MIDI (settings changes only redo these)
function updateNotes() {
notes = findNotes(output, {
onsetThreshold: +$("onset").value, frameThreshold: +$("frame").value, minNoteLength: +$("minNote").value,
minFrequency: +$("minPitch").value, maxFrequency: +$("maxPitch").value, boostOnsets: $("boost").checked,
});
midi = toMidi(notes, { tempo: +$("tempo").value });
URL.revokeObjectURL(midiUrl);
midiUrl = URL.createObjectURL(new Blob([midi], { type: "audio/midi" }));
midiName = `${file?.name.replace(/\.[^.]+$/, "") || "notes"}.mid`;
$("downloadLabel").textContent = `Download MIDI (${prettyBytes(midi.length)})`;
$("download").title = midiName;
}
$("download").addEventListener("click", () => {
if (midiUrl) Object.assign(document.createElement("a"), { href: midiUrl, download: midiName }).click();
});
// Settings
// the fold animation from segerend.nl, a bit quicker
const FOLD_SPRING = "cubic-bezier(0.34, 1.3, 0.64, 1)";
let folding = [];
$("settingsToggle").addEventListener("click", () => {
const fold = $("settings"), opening = !fold.classList.contains("open") || fold.classList.contains("closing");
for (const running of folding) running.cancel();
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
fold.classList.remove("closing");
fold.classList.toggle("open", opening);
return;
}
const from = fold.offsetHeight;
fold.classList.toggle("open", opening);
const to = fold.offsetHeight;
fold.classList.add("open");
fold.classList.toggle("closing", !opening);
fold.style.overflow = "clip";
const timing = opening ? { duration: 370, easing: FOLD_SPRING } : { duration: 180, easing: "cubic-bezier(0.4, 0, 1, 1)" };
const slide = fold.animate({ height: [`${from}px`, `${to}px`] }, timing);
const content = fold.querySelector(".content").animate(opening ? { opacity: [0, 1], transform: ["translateY(-6px)", "none"] } : { opacity: [1, 0] }, { ...timing, fill: "forwards" });
folding = [slide, content];
slide.onfinish = () => {
fold.classList.toggle("open", opening);
fold.classList.remove("closing");
fold.style.overflow = "";
content.cancel();
};
});
const SLIDERS = ["onset", "frame", "minPitch", "maxPitch", "minNote", "tempo"];
const defaults = Object.fromEntries(SLIDERS.map((id) => [id, $(id).value]));
let settling = 0;
function show(id) {
const range = $(id);
$(id + "N").value = range.value;
range.style.setProperty("--range_progress", `${(range.value - range.min) / (range.max - range.min) * 100}%`);
}
function changed() {
for (const id of SLIDERS) show(id);
$("resetAll").disabled = SLIDERS.every((id) => $(id).value === defaults[id]) && $("boost").checked;
clearTimeout(settling);
settling = setTimeout(() => {
if (!output) return;
updateNotes();
if (playing) queueNotes();
draw();
}, 60);
}
for (const id of SLIDERS) {
$(id).addEventListener("input", changed);
$(id + "N").addEventListener("change", () => { $(id).value = $(id + "N").value; changed(); });
document.querySelector(`[data-reset="${id}"]`).addEventListener("click", () => { $(id).value = defaults[id]; changed(); });
show(id);
}
$("boost").addEventListener("change", changed);
$("resetAll").addEventListener("click", () => {
for (const id of SLIDERS) $(id).value = defaults[id];
$("boost").checked = true;
changed();
});
// Piano roll: the canvas only covers what is in view, #track gives the scroll width
const KEY_HEIGHT = 7, MIN_ZOOM = 10, MAX_ZOOM = 1000;
let pxPerSecond = 120;
const canvas = $("roll"), scroll = $("scroll"), track = $("track");
function draw() {
if (!audio || $("shown").hidden) return;
const css = getComputedStyle(document.documentElement), color = (name) => css.getPropertyValue(name).trim();
let low = 48, high = 72;
if (notes.length) { low = Math.min(...notes.map((n) => n.pitchMidi)) - 2; high = Math.max(...notes.map((n) => n.pitchMidi)) + 2; }
const keys = high - low + 1, view = scroll.clientWidth, height = Math.max(200, keys * KEY_HEIGHT);
track.style.width = `${Math.max(view, Math.ceil(audio.duration * pxPerSecond))}px`;
const dpr = devicePixelRatio || 1;
if (canvas.width !== view * dpr || canvas.height !== height * dpr) {
canvas.width = view * dpr; canvas.height = height * dpr;
canvas.style.width = `${view}px`; canvas.style.height = `${height}px`;
}
const g = canvas.getContext("2d");
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, view, height);
const left = scroll.scrollLeft, from = left / pxPerSecond, to = (left + view) / pxPerSecond;
const x = (seconds) => seconds * pxPerSecond - left;
const rowH = height / keys, y = (pitch) => (high - pitch) * rowH;
const box = (n) => [x(n.startTimeSeconds), y(n.pitchMidi) + 0.5, Math.max(2, n.durationSeconds * pxPerSecond), Math.max(2, rowH - 1)];
g.fillStyle = color("--roll-alt");
for (let p = low; p <= high; p++) if ([1, 3, 6, 8, 10].includes(p % 12)) g.fillRect(0, y(p), view, rowH);
g.fillStyle = color("--roll-line");
const every = pxPerSecond >= 40 ? 1 : pxPerSecond >= 10 ? 5 : 10;
for (let s = Math.ceil(from / every) * every; s <= to; s += every) g.fillRect(Math.round(x(s)), 0, 1, height);
for (let p = low; p <= high; p++) if (p % 12 === 0) g.fillRect(0, Math.round(y(p) + rowH), view, 1);
const t = position(), sounding = [];
g.fillStyle = color("--color-accent");
for (const n of notes) {
const end = n.startTimeSeconds + n.durationSeconds;
if (end < from || n.startTimeSeconds > to) continue;
if (n.startTimeSeconds <= t && t < end) { sounding.push(n); continue; }
g.globalAlpha = 0.35 + 0.65 * Math.min(1, n.amplitude * 1.5);
g.fillRect(...box(n));
}
g.globalAlpha = 1;
g.fillStyle = color("--primary-600");
g.strokeStyle = color("--body-text-color");
g.lineWidth = 1.5;
for (const n of sounding) { g.fillRect(...box(n)); g.strokeRect(...box(n)); }
g.fillStyle = color("--body-text-color");
g.fillRect(Math.round(x(t)), 0, 2, height);
$("time").textContent = `${fmt(t)} / ${fmt(audio.duration)} · ${notes.length} notes`;
}
addEventListener("resize", draw);
scroll.addEventListener("scroll", () => playing || draw());
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => requestAnimationFrame(draw));
canvas.addEventListener("click", (e) => {
const t = (scroll.scrollLeft + e.clientX - scroll.getBoundingClientRect().left) / pxPerSecond;
const was = playing;
stopPlaying();
offset = Math.max(0, Math.min(audio.duration, t));
was ? play() : draw();
});
// zoom around `at` px from the left edge
function zoom(to, at = scroll.clientWidth / 2) {
const seconds = (scroll.scrollLeft + at) / pxPerSecond;
pxPerSecond = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, to));
track.style.width = `${Math.max(scroll.clientWidth, Math.ceil(audio.duration * pxPerSecond))}px`;
scroll.scrollLeft = seconds * pxPerSecond - at;
draw();
}
$("zoomIn").addEventListener("click", () => zoom(pxPerSecond * 1.5));
$("zoomOut").addEventListener("click", () => zoom(pxPerSecond / 1.5));
$("zoomFit").addEventListener("click", () => audio && zoom(scroll.clientWidth / audio.duration, 0));
scroll.addEventListener("wheel", (e) => {
if (!(e.ctrlKey || e.metaKey) || !audio) return; // a trackpad pinch is ctrl + wheel
e.preventDefault();
zoom(pxPerSecond * Math.exp(-e.deltaY * 0.01), e.clientX - scroll.getBoundingClientRect().left);
}, { passive: false });
// Playback. Notes are scheduled a little ahead on the audio clock (web.dev/articles/audio-scheduling),
// further ahead in a hidden tab, where timers run only once a second.
let playing = false, offset = 0, startedAt = 0;
let original = null, bus = null, queue = [], next = 0, scheduler = 0, animation = 0;
const outputTime = () => ctx.currentTime - (ctx.outputLatency || ctx.baseLatency || 0);
const position = () => (playing ? Math.max(0, Math.min(audio.duration, outputTime() - startedAt)) : offset);
const sound = () => $("sound").value;
async function play() {
ctx ??= new AudioContext();
ctx.resume();
if (sound() !== "synth" && !loaded[sound()]) {
$("play").textContent = "Loading…";
try {
await loadSound(sound());
} catch {
toast("error", "Couldn't load that sound.");
$("play").textContent = "▶ Play";
return;
}
if (playing) return;
}
$("preview").pause();
playing = true;
startedAt = ctx.currentTime + 0.05 - offset;
if ($("hearAudio").checked) {
original = new AudioBufferSourceNode(ctx, { buffer: audio });
original.connect(new GainNode(ctx, { gain: $("hearNotes").checked ? 0.6 : 1 })).connect(ctx.destination);
original.start(startedAt + offset, offset);
}
queueNotes();
schedule();
const animate = () => {
const now = position();
if (now >= audio.duration) { stopPlaying(); offset = 0; draw(); return; }
draw();
const x = now * pxPerSecond;
if (x < scroll.scrollLeft || x > scroll.scrollLeft + scroll.clientWidth - 40) scroll.scrollLeft = x - 40;
animation = requestAnimationFrame(animate);
};
animate();
$("play").textContent = "❚❚ Pause";
}
function stopPlaying() {
if (!playing) return;
offset = position();
playing = false;
clearTimeout(scheduler);
cancelAnimationFrame(animation);
original?.stop(); original = null;
bus?.disconnect(); bus = null;
$("play").textContent = "▶ Play";
}
const togglePlay = () => (playing ? (stopPlaying(), draw()) : play());
function queueNotes() {
bus?.disconnect();
bus = new GainNode(ctx, { gain: 0.5 });
bus.connect(new DynamicsCompressorNode(ctx)).connect(ctx.destination); // no clipping on chords
const now = ctx.currentTime - startedAt;
queue = $("hearNotes").checked ? midiNotes(notes, { tempo: +$("tempo").value }).filter((n) => n.end > now).sort((a, b) => a.start - b.start) : [];
next = 0;
}
function schedule() {
const now = ctx.currentTime - startedAt;
for (; next < queue.length && queue[next].start < now + (document.hidden ? 2 : 0.2); next++) voice(queue[next]);
if (document.hidden && now >= audio.duration) { stopPlaying(); offset = 0; return; }
scheduler = setTimeout(schedule, 25);
}
function voice(n) {
const at = Math.max(startedAt + n.start, ctx.currentTime), end = startedAt + n.end;
if (loaded[sound()]) sampleVoice(INSTRUMENTS[sound()], loaded[sound()], n, at, end);
else synthVoice(n, at, end);
}
function synthVoice(n, at, end) {
const level = n.velocity / 127;
const osc = new OscillatorNode(ctx, { type: "triangle", frequency: 440 * 2 ** ((n.pitch - 69) / 12) });
const env = new GainNode(ctx, { gain: 0 });
env.gain.setValueAtTime(0, at);
env.gain.linearRampToValueAtTime(level, at + 0.005);
env.gain.setTargetAtTime(level * 0.6, at + 0.005, 0.15);
env.gain.setTargetAtTime(0, end, 0.03);
osc.connect(env).connect(bus);
osc.start(at);
osc.stop(end + 0.2);
}
// Sampled instruments: sounds/<name>/<midi note>.mp3, a note plays the nearest one shifted to its
// pitch. gain evens out loudness, release 0 lets the recording ring out.
const steps = (from, to, by) => Array.from({ length: (to - from) / by + 1 }, (_, i) => from + i * by);
const INSTRUMENTS = {
piano: { notes: steps(21, 108, 3), gain: 1.5, release: 0.08 },
epiano: { notes: steps(21, 108, 3), gain: 2.4, release: 0.1 },
guitar: { notes: steps(39, 90, 3), gain: 2.9, release: 0.1 },
marimba: { notes: [41, 48, 55, 59, 65, 72, 79, 83, 89, 96], gain: 4, release: 0 },
strings: { notes: [36, 40, 43, 47, 50, 53, 55, 59, 62, 65, 66, 69, 72, 76, 79, 83, 86], gain: 0.7, release: 0.2 },
};
const loading = {}, loaded = {};
function loadSound(name) {
ctx ??= new AudioContext();
loading[name] ??= Promise.all(INSTRUMENTS[name].notes.map((m) => fetch(`sounds/${name}/${m}.mp3`).then((r) => r.arrayBuffer()).then((b) => ctx.decodeAudioData(b))))
.then((buffers) => (loaded[name] = buffers))
.catch((error) => { delete loading[name]; throw error; });
return loading[name];
}
function sampleVoice({ notes: recorded, gain, release }, buffers, n, at, end) {
let i = 0;
for (let k = 1; k < recorded.length; k++) if (Math.abs(recorded[k] - n.pitch) < Math.abs(recorded[i] - n.pitch)) i = k;
const src = new AudioBufferSourceNode(ctx, { buffer: buffers[i], playbackRate: 2 ** ((n.pitch - recorded[i]) / 12) });
const env = new GainNode(ctx, { gain: gain * (n.velocity / 127) ** 1.5 });
src.connect(env).connect(bus);
src.start(at);
if (release) {
env.gain.setTargetAtTime(0, end, release);
src.stop(end + 6 * release);
}
}
$("play").addEventListener("click", togglePlay);
$("hearAudio").addEventListener("change", () => { if (playing) { stopPlaying(); play(); } });
$("hearNotes").addEventListener("change", () => playing && queueNotes());
$("sound").addEventListener("change", async () => {
if (sound() !== "synth") await loadSound(sound()).catch(() => toast("error", "Couldn't load that sound."));
if (playing) queueNotes();
});
// Space plays and pauses (its keyup is swallowed so a focused button isn't pressed too)
const spaceForPlay = (e) => e.code === "Space" && audio && !$("shown").hidden && !e.target.matches?.("textarea, audio, input:not([type=checkbox]):not([type=range])");
addEventListener("keydown", (e) => {
if (!spaceForPlay(e)) return;
e.preventDefault();
if (e.repeat) return;
togglePlay();
$("play").classList.add("pressed");
setTimeout(() => $("play").classList.remove("pressed"), 120);
});
addEventListener("keyup", (e) => spaceForPlay(e) && e.preventDefault());
// Toasts
const TOAST_ICON = {
error: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>',
warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg>',
};
function toast(type, text) {
const el = document.createElement("div");
el.className = `toast-body ${type}`;
el.setAttribute("role", "status");
el.innerHTML = `<div class="toast-header"><div class="toast-icon">${TOAST_ICON[type]}</div><span class="toast-title"></span><button class="toast-close" aria-label="Close">×</button></div><div class="toast-text"></div><div class="timer"></div>`;
el.querySelector(".toast-title").textContent = type === "error" ? "Error" : "Warning";
el.querySelector(".toast-text").textContent = text;
el.querySelector(".toast-close").addEventListener("click", () => el.remove());
setTimeout(() => el.remove(), 10000);
$("toasts").append(el);
}
// Formatting
function prettyBytes(bytes) {
const units = ["B", "KB", "MB"];
let i = 0;
while (bytes > 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return `${bytes.toFixed(1)} ${units[i]}`;
}
const ms = (t) => (t < 10000 ? `${Math.round(t)} ms` : `${(t / 1000).toFixed(1)} s`);
const fmt = (s) => `${Math.floor(Math.max(0, s) / 60)}:${String(Math.floor(Math.max(0, s) % 60)).padStart(2, "0")}`;
</script>
</body>
</html>