318 lines
9.7 KiB
HTML
318 lines
9.7 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Signalsmith Stretch Web Audio demo</title>
|
|
<link rel="stylesheet" href="/style/article/dist.css">
|
|
<style>
|
|
#start-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 100;
|
|
}
|
|
button {
|
|
justify-content: center;
|
|
align-items: center;
|
|
flex-grow: 1;
|
|
font: inherit;
|
|
}
|
|
|
|
:root {
|
|
font-size: calc(min(12pt, 4.3vh));
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
position: fixed;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
max-height: 100vh;
|
|
max-width: 100vw;
|
|
display: grid;
|
|
grid-template-areas: "playstop playback upload" "controls controls controls" "scope scope scope";
|
|
grid-template-columns: 3.5rem 1fr 6rem;
|
|
grid-template-rows: max-content 2fr 6rem;
|
|
}
|
|
|
|
#controls {
|
|
grid-area: controls;
|
|
display: grid;
|
|
grid-template-columns: max-content 1fr max-content;
|
|
grid-auto-rows: max-content;
|
|
padding: 1rem;
|
|
align-content: space-evenly;
|
|
}
|
|
#controls label {
|
|
grid-column: 1;
|
|
text-align: right;
|
|
padding-right: 1ex;
|
|
}
|
|
#controls input[type=range], #controls input[type=checkbox] {
|
|
grid-column: 2;
|
|
font: inherit;
|
|
flex-grow: 0.25;
|
|
}
|
|
#controls input[type=range] {
|
|
flex-grow: 1;
|
|
}
|
|
#controls input[type=number] {
|
|
grid-column: 3;
|
|
font: inherit;
|
|
}
|
|
|
|
#scope {
|
|
grid-area: scope;
|
|
width: 100%;
|
|
height: 6rem;
|
|
max-height: 100%;
|
|
border: none;
|
|
}
|
|
#playstop {
|
|
grid-area: playstop;
|
|
}
|
|
#playback {
|
|
grid-area: playback;
|
|
height: 100%;
|
|
position: relative;
|
|
padding: 0;
|
|
margin: 0 1rex;
|
|
accent-color: currentcolor;
|
|
}
|
|
#playback::before {
|
|
content: '';
|
|
background: #DDD;
|
|
box-shadow: rgba(0, 0, 0, 0.267) 0px -8px 3px -8px inset, rgba(255, 255, 255, 0.2) 0px 6px 2px -4px inset;
|
|
position: absolute;
|
|
left: -1rem;
|
|
right: -1rem;
|
|
height: 100%;
|
|
z-index: -1;
|
|
}
|
|
#upload, #upload-file {
|
|
grid-area: upload;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<button id="playstop" alt="toggle play">...</button>
|
|
<input id="playback" type="range" value="0" min="0" max="1" step="0.001">
|
|
<input id="upload-file" type="file" style="visibility: hidden" accept="audio/*">
|
|
<button id="upload">upload</button>
|
|
<div id="controls">
|
|
<label>rate</label>
|
|
<input type="range" min="0" max="4" step="0.01" data-key="rate" class="diagram-blue">
|
|
<input type="number" min="0" max="4" step="0.01" data-key="rate" class="diagram-blue">
|
|
<label>semitones</label>
|
|
<input type="range" min="-12" max="12" step="1" data-key="semitones" class="diagram-red">
|
|
<input type="number" min="-12" max="12" step="1" data-key="semitones" class="diagram-red">
|
|
<label>tonality limit</label>
|
|
<input type="range" min="2000" max="20000" step="100" data-key="tonalityHz" class="diagram-red">
|
|
<input type="number" min="2000" max="20000" step="1" data-key="tonalityHz" class="diagram-red">
|
|
<label>formant</label>
|
|
<div style="display: flex">
|
|
<input type="checkbox" data-key="formantCompensation" class="diagram-yellow">
|
|
<input type="range" min="50" max="500" step="1" data-key="formantBaseHz" class="diagram-brown">
|
|
</div>
|
|
<input type="number" min="50" max="500" step="1" data-key="formantBaseHz" class="diagram-brown">
|
|
<label>block (ms)</label>
|
|
<input type="range" min="50" max="180" step="1" data-key="blockMs" class="diagram-green">
|
|
<input type="number" min="50" max="180" step="1" data-key="blockMs" class="diagram-green">
|
|
<label>overlap</label>
|
|
<input type="range" min="2" max="8" step="0.1" data-key="overlap" class="diagram-green">
|
|
<input type="number" min="2" max="8" step="0.1" data-key="overlap" class="diagram-green">
|
|
</div>
|
|
<script type="module">
|
|
import SignalsmithStretch from "../release/SignalsmithStretch.mjs";
|
|
import Scope from './Scope.mjs';
|
|
let $ = document.querySelector.bind(document);
|
|
let $$ = document.querySelectorAll.bind(document);
|
|
|
|
(async () => {
|
|
let audioContext = new AudioContext();
|
|
let stretch;
|
|
let audioDuration = 1;
|
|
|
|
let controlValuesInitial = {
|
|
active: false,
|
|
rate: 1,
|
|
semitones: 0,
|
|
tonalityHz: 8000,
|
|
formantSemitones: 0,
|
|
formantCompensation: false,
|
|
formantBaseHz: 200,
|
|
loopStart: 0,
|
|
loopEnd: 0 // disabled (<= start), but this gets set when we load an audio file
|
|
};
|
|
let controlValues = Object.assign({}, controlValuesInitial);
|
|
let configValuesInitial = {
|
|
blockMs: 120,
|
|
overlap: 4,
|
|
splitComputation: true
|
|
};
|
|
let configValues = Object.assign({}, configValuesInitial);
|
|
|
|
let scope;
|
|
if (!/Mobi|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile/.test(navigator.userAgent)) {
|
|
// add scope for fun, but only on desktop
|
|
scope = await Scope(audioContext);
|
|
scope.connect(audioContext.destination);
|
|
let scopeFrame = scope.openInterface();
|
|
scopeFrame.id = 'scope';
|
|
document.body.appendChild(scopeFrame);
|
|
}
|
|
|
|
// Drop zone
|
|
document.body.ondragover = event => {
|
|
event.preventDefault();
|
|
}
|
|
document.body.ondrop = handleDrop;
|
|
|
|
function handleDrop(event) {
|
|
event.preventDefault();
|
|
var dt = event.dataTransfer;
|
|
handleFile(dt.items ? dt.items[0].getAsFile() : dt.files[0]);
|
|
}
|
|
function handleFile(file) {
|
|
return new Promise((pass, fail) => {
|
|
var reader = new FileReader();
|
|
reader.onload = e => pass(handleArrayBuffer(reader.result));
|
|
reader.onerror = fail;
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
}
|
|
async function handleArrayBuffer(arrayBuffer) {
|
|
let audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
audioDuration = audioBuffer.duration;
|
|
let channelBuffers = []
|
|
for (let c = 0; c < audioBuffer.numberOfChannels; ++c) {
|
|
channelBuffers.push(audioBuffer.getChannelData(c));
|
|
}
|
|
// fresh node
|
|
if (stretch) {
|
|
stretch.stop();
|
|
stretch.disconnect();
|
|
}
|
|
stretch = await SignalsmithStretch(audioContext);
|
|
stretch.connect(scope || audioContext.destination);
|
|
await stretch.addBuffers(channelBuffers);
|
|
controlValues.loopEnd = audioDuration;
|
|
configChanged();
|
|
controlsChanged();
|
|
}
|
|
|
|
// fetch audio and add buffer
|
|
let response = await fetch('loop.mp3');
|
|
handleArrayBuffer(await response.arrayBuffer());
|
|
|
|
$('#playstop').onclick = e => {
|
|
controlValues.active = !controlValues.active;
|
|
controlsChanged(0.15);
|
|
};
|
|
$$('#controls input').forEach(input => {
|
|
let isCheckbox = input.type == 'checkbox';
|
|
let key = input.dataset.key;
|
|
input.oninput = input.onchange = e => {
|
|
let value = isCheckbox ? input.checked : parseFloat(input.value);
|
|
if (key in controlValues) {
|
|
controlValues[key] = value;
|
|
controlsChanged();
|
|
} else if (key in configValues) {
|
|
configValues[key] = value;
|
|
configChanged();
|
|
}
|
|
};
|
|
if (!isCheckbox) input.ondblclick = e => {
|
|
if (key in controlValues) {
|
|
controlValues[key] = controlValuesInitial[key];
|
|
controlsChanged();
|
|
} else if (key in configValues) {
|
|
configValues[key] = configValuesInitial[key];
|
|
configChanged();
|
|
}
|
|
};
|
|
});
|
|
function controlsChanged(scheduleAhead) {
|
|
$('#playstop').innerHTML = '<svg alt="toggle play" height="1em" width="1em" viewbox="0 0 8 8" style="vertical-align:middle"><path d="' + (controlValues.active ? 'M1 1L3 1 3 7 1 7ZM5 1 7 1 7 7 5 7Z' : 'M1 0L8 4 1 8') + '" fill="currentColor"/></svg>';
|
|
|
|
$$('#controls input').forEach(input => {
|
|
let key = input.dataset.key;
|
|
if (key in controlValues) {
|
|
let value = controlValues[key];
|
|
// Update value if it doesn't match
|
|
if (value !== parseFloat(input.value)) input.value = value;
|
|
}
|
|
});
|
|
|
|
if (stretch) {
|
|
let obj = Object.assign({output: audioContext.currentTime + (scheduleAhead || 0)}, controlValues);
|
|
stretch.schedule(obj);
|
|
}
|
|
audioContext.resume();
|
|
}
|
|
controlsChanged();
|
|
let configTimeout = null;
|
|
function configChanged() {
|
|
$$('#controls input').forEach(input => {
|
|
let key = input.dataset.key;
|
|
if (key in configValues) {
|
|
let value = configValues[key];
|
|
// Update value if it doesn't match
|
|
if (value !== parseFloat(input.value)) input.value = value;
|
|
}
|
|
});
|
|
|
|
if (configTimeout == null) {
|
|
configTimeout = setTimeout(_ => {
|
|
configTimeout = null;
|
|
if (stretch) {
|
|
stretch.configure({
|
|
blockMs: configValues.blockMs,
|
|
intervalMs: configValues.blockMs/configValues.overlap,
|
|
splitComputation: configValues.splitComputation,
|
|
});
|
|
}
|
|
}, 50);
|
|
}
|
|
audioContext.resume();
|
|
}
|
|
controlsChanged();
|
|
|
|
$('#upload').onclick = e => $('#upload-file').click();
|
|
$('#upload-file').onchange = async e => {
|
|
stretch.stop();
|
|
await handleFile($('#upload-file').files[0]).catch(e => alert(e.message));
|
|
if (stretch) {
|
|
controlValues.active = true;
|
|
controlsChanged();
|
|
}
|
|
}
|
|
|
|
let playbackPosition = $('#playback');
|
|
setInterval(_ => {
|
|
playbackPosition.max = audioDuration;
|
|
playbackPosition.value = stretch?.inputTime;
|
|
}, 100);
|
|
let playbackHeld = false;
|
|
function updatePlaybackPosition(e) {
|
|
let inputTime = parseFloat(playbackPosition.value);
|
|
let obj = Object.assign({}, controlValues);
|
|
if (playbackHeld) obj.rate = 0;
|
|
stretch.schedule(Object.assign({input: inputTime}, obj));
|
|
}
|
|
playbackPosition.onmousedown = e => {
|
|
playbackHeld = true;
|
|
};
|
|
playbackPosition.onmouseup = playbackPosition.onmousecancel = e => {
|
|
playbackHeld = false;
|
|
};
|
|
playbackPosition.oninput = playbackPosition.onchange = updatePlaybackPosition;
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|