Spaces:
Running
Running
Download index.html from SegerEnd/basic-pitch-wasm: direct link, hf CLI and curl.
- Browser
- Download file 48.2 kB
-
https://huggingface.co/spaces/SegerEnd/basic-pitch-wasm/resolve/main/index.html
- Command line
-
hf download hf://spaces/SegerEnd/basic-pitch-wasm/index.html
-
curl -L -o index.html https://huggingface.co/spaces/SegerEnd/basic-pitch-wasm/resolve/main/index.html
48.2 kB
| <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 ; } | |
| 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 ; } } | |
| .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> | |