… a work in progress and prototype http://cieweb.ddns.net:3001/player-v1.html


Here is latest as of April 25 on web audio node.js development. Using Gemini, I was able to extend the previous simple convolution app adding features:
- waveform display
- playback progress indicator
- loop repeat slider
- start and end points
The base functionality provides a
- list of sample and impulses
- gain control
- wet/dry mix
- pitch/speed
The current iteration was tested standalone and will be ported under cieweb.ddns.net in the next few days.
Table of Contents
Intended Use
Sketches for new installation, performance and web work for various projects.
Related links
- Convolver node.js V2 with OSC post
- Web Audio Links
- BBC Orchestrator Open Source
- AI Generate HTML to show the relative distance to each speaker
- On node.js convolution
- AI generated node.js convolution player
- AI on “generate a web page to select an audio clip to play through a selected impulse convolution”
To Do
- fix the link to the project information
- test over slow network connection (would a load progress indicator help rather than the simple change in shading as is)
- consider reintegrating the OSC messages
click for details
Previous work included various OSC messages sent on the backend to be used for interacting with local sound objects. Gemini couldn’t quite easily carry that forward.
- consider separate pitch shifter
click for details
Earlier variation had separate pitch and speed controls. The Gemini prompts need to be very specific . Consider separate frequency shift. Gemini was ok at explaining differences.
- smooth looping to suppress click
- host under cieweb.ddns.net
- use hostname for deriving ip address on backend to improve portability
- test front ed across iPhone, iPad, Android, RPI in addition to Desktop
- Spectrogram visualization of the impulse and possibly the sample
- Github and improve IDE integration (VS)
- consider Claude code subscription for accelerated development
- link to sweep generator and other projects in progress
- a major area to consider is the variation of the variables over time and iterations
- consider features separate for performance vs. novice visitor interaction
- add IMU gestures (and record playback morphing)
Code
Click here for HTML: player-v1.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Resonance Player V3.6</title>
<style>
:root { --primary: #007bff; --success: #28a745; --danger: #dc3545; --warning: #ffc107; --dark: #212529; }
body { font-family: sans-serif; background: #121212; color: #eee; padding: 15px; margin: 0; }
.container { max-width: 800px; margin: auto; background: #1e1e1e; padding: 20px; border-radius: 12px; }
canvas { width: 100%; height: 120px; background: #000; border-radius: 8px; margin-bottom: 15px; border: 1px solid #444; }
.control-group { margin-bottom: 20px; padding: 12px; background: #2a2a2a; border-radius: 8px; }
label { display: block; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; color: #aaa; }
.val-disp { float: right; color: var(--warning); font-family: monospace; }
.playback-monitor { margin-bottom: 20px; }
.bar-bg { width: 100%; height: 6px; background: #111; border-radius: 3px; overflow: hidden; position: relative; }
#progressBar { height: 100%; width: 0%; background: var(--success); box-shadow: 0 0 10px var(--success); }
.range-container { position: relative; height: 20px; margin: 10px 0; }
.range-slider { position: absolute; width: 100%; pointer-events: none; -webkit-appearance: none; background: none; top: 0; margin: 0; z-index: 2; }
.range-slider::-webkit-slider-thumb { pointer-events: auto; -webkit-appearance: none; width: 14px; height: 24px; background: var(--primary); cursor: pointer; border-radius: 3px; border: 1px solid #fff; }
.meta-row { display: flex; justify-content: space-between; font-family: monospace; font-size: 0.75rem; color: #888; margin-bottom: 10px; }
.meta-row span { color: var(--warning); }
select, input[type="range"] { width: 100%; margin-bottom: 10px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 120px; }
button { padding: 12px; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; text-transform: uppercase; }
.btn-play { background: var(--success); color: white; flex: 2; }
.btn-stop { background: var(--danger); color: white; flex: 1; }
footer { margin-top: 30px; text-align: center; font-size: 0.7rem; opacity: 0.6; }
a { color: var(--primary); }
</style>
</head>
<body>
<div class="container">
<canvas id="viz"></canvas>
<div class="playback-monitor">
<label>Playback Position <span class="val-disp" id="iterDisp">Remaining: 0</span></label>
<div class="bar-bg">
<div id="progressBar"></div>
</div>
</div>
<div class="control-group">
<label>Loop Region Selection <span class="val-disp" id="loopDisp">0% - 100%</span></label>
<div class="range-container">
<input type="range" id="loopStart" class="range-slider" min="0" max="100" value="0">
<input type="range" id="loopEnd" class="range-slider" min="0" max="100" value="100">
</div>
</div>
<div class="meta-row">
<div>Sample: <span id="sampleLen">0.00</span>s</div>
<div>Impulse: <span id="impulseLen">0.00</span>s</div>
<div>Loop: <span id="loopLen">0.00</span>s</div>
</div>
<div class="control-group">
<label>Iteration Count <span class="val-disp" id="countDisp">1</span></label>
<input type="range" id="loopIterations" min="1" max="50" step="1" value="1">
</div>
<div class="row">
<div class="control-group">
<label>Source Sample</label>
<select id="sampleSelect"></select>
</div>
<div class="control-group">
<label>Convolution Impulse</label>
<select id="impulseSelect"></select>
</div>
</div>
<div class="control-group">
<label>Main Volume <span class="val-disp" id="volDisp">0.50</span></label>
<input type="range" id="vol" min="0" max="1" step="0.01" value="0.5">
<label>Wet/Dry Mix <span class="val-disp" id="mixDisp">0.50</span></label>
<input type="range" id="mix" min="0" max="1" step="0.01" value="0.5">
<label>Pitch/Speed <span class="val-disp" id="pitchDisp">1.00x</span></label>
<input type="range" id="pitch" min="0.5" max="5.0" step="0.01" value="1.0">
</div>
<div class="row">
<button id="playBtn" class="btn-play">Play Source</button>
<button id="stopBtn" class="btn-stop">Stop</button>
</div>
<footer>
CIE-WP20 Project
</footer>
</div>
<script>
window.addEventListener('DOMContentLoaded', () => {
const ctx = new AudioContext();
let source, startTime = 0, currentDuration = 0;
let currentSampleBuffer = null, currentImpulseBuffer = null;
let iterationsRemaining = 0, manualStop = false;
const mainGain = ctx.createGain();
const dryGain = ctx.createGain();
const wetGain = ctx.createGain();
const convolver = ctx.createConvolver();
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
dryGain.connect(mainGain);
wetGain.connect(mainGain);
mainGain.connect(ctx.destination);
mainGain.connect(analyser);
const canvas = document.getElementById('viz');
const canvasCtx = canvas.getContext('2d');
const progressBar = document.getElementById('progressBar');
function draw() {
requestAnimationFrame(draw);
analyser.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#000';
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.strokeStyle = '#00ff00';
canvasCtx.lineWidth = 2;
canvasCtx.beginPath();
let sliceWidth = canvas.width / dataArray.length;
let x = 0;
for(let i=0; i<dataArray.length; i++) {
let v = dataArray[i] / 128.0;
let y = v * canvas.height / 2;
if(i===0) canvasCtx.moveTo(x,y); else canvasCtx.lineTo(x,y);
x += sliceWidth;
}
canvasCtx.stroke();
if (source && currentDuration > 0) {
let elapsed = ctx.currentTime - startTime;
let progress = (elapsed / currentDuration) * 100;
progressBar.style.width = Math.min(progress, 100) + "%";
}
}
draw();
fetch('/files').then(r => r.json()).then(data => {
const sSelect = document.getElementById('sampleSelect');
const iSelect = document.getElementById('impulseSelect');
data.samples.forEach(s => sSelect.add(new Option(s, s)));
data.impulses.forEach(i => iSelect.add(new Option(i, i)));
});
async function play() {
if(ctx.state === 'suspended') await ctx.resume();
manualStop = false;
const [sRes, iRes] = await Promise.all([
fetch(`/samples/${document.getElementById('sampleSelect').value}`).then(r => r.arrayBuffer()),
fetch(`/impulses/${document.getElementById('impulseSelect').value}`).then(r => r.arrayBuffer())
]);
currentSampleBuffer = await ctx.decodeAudioData(sRes);
currentImpulseBuffer = await ctx.decodeAudioData(iRes);
document.getElementById('sampleLen').innerText = currentSampleBuffer.duration.toFixed(2);
document.getElementById('impulseLen').innerText = currentImpulseBuffer.duration.toFixed(2);
iterationsRemaining = parseInt(document.getElementById('loopIterations').value);
triggerSound();
}
function triggerSound() {
if (!currentSampleBuffer || manualStop) return;
if (source) { try { source.stop(); } catch(e) {} }
document.getElementById('iterDisp').innerText = `Remaining: ${iterationsRemaining}`;
source = ctx.createBufferSource();
source.buffer = currentSampleBuffer;
convolver.buffer = currentImpulseBuffer;
const sPct = parseFloat(document.getElementById('loopStart').value) / 100;
const ePct = parseFloat(document.getElementById('loopEnd').value) / 100;
const startPct = Math.min(sPct, ePct);
const endPct = Math.max(sPct, ePct);
const offset = currentSampleBuffer.duration * startPct;
const duration = (currentSampleBuffer.duration * endPct) - offset;
const pitch = parseFloat(document.getElementById('pitch').value);
document.getElementById('loopLen').innerText = duration.toFixed(2);
source.playbackRate.value = pitch;
source.connect(dryGain);
source.connect(convolver).connect(wetGain);
startTime = ctx.currentTime;
currentDuration = (duration > 0 ? duration : 0.01) / pitch;
source.onended = () => {
iterationsRemaining--;
if (iterationsRemaining > 0 && !manualStop) {
triggerSound();
} else {
progressBar.style.width = "0%";
document.getElementById('iterDisp').innerText = "Remaining: 0";
}
};
source.start(0, offset, duration > 0 ? duration : 0.01);
fetch(`/play-trigger/1`);
}
document.getElementById('playBtn').onclick = play;
document.getElementById('stopBtn').onclick = () => {
manualStop = true;
if(source) source.stop();
currentDuration = 0;
progressBar.style.width = "0%";
document.getElementById('iterDisp').innerText = "Remaining: 0";
fetch('/stop-trigger');
};
document.getElementById('vol').oninput = (e) => {
mainGain.gain.setTargetAtTime(e.target.value, ctx.currentTime, 0.02);
document.getElementById('volDisp').innerText = parseFloat(e.target.value).toFixed(2);
fetch(`/gain-trigger/${e.target.value}`);
};
document.getElementById('mix').oninput = (e) => {
const val = parseFloat(e.target.value);
dryGain.gain.setTargetAtTime(1-val, ctx.currentTime, 0.02);
wetGain.gain.setTargetAtTime(val, ctx.currentTime, 0.02);
document.getElementById('mixDisp').innerText = val.toFixed(2);
};
document.getElementById('pitch').oninput = (e) => {
const val = parseFloat(e.target.value);
document.getElementById('pitchDisp').innerText = val.toFixed(2) + "x";
if (source) {
source.playbackRate.setTargetAtTime(val, ctx.currentTime, 0.05);
const sPct = parseFloat(document.getElementById('loopStart').value) / 100;
const ePct = parseFloat(document.getElementById('loopEnd').value) / 100;
currentDuration = (currentSampleBuffer.duration * Math.abs(ePct - sPct)) / val;
}
};
document.getElementById('loopIterations').oninput = (e) => {
document.getElementById('countDisp').innerText = e.target.value;
};
const handleLoopChange = () => {
document.getElementById('loopDisp').innerText = `${document.getElementById('loopStart').value}% - ${document.getElementById('loopEnd').value}%`;
};
document.getElementById('loopStart').oninput = document.getElementById('loopEnd').oninput = handleLoopChange;
document.getElementById('loopStart').onchange = document.getElementById('loopEnd').onchange = () => {
if (source && !manualStop) {
iterationsRemaining = parseInt(document.getElementById('loopIterations').value);
triggerSound();
}
};
});
</script>
</body>
</html>
Click here for JS backend- player-v1.js
const express = require('express');
const { Client } = require('node-osc');
const fs = require('fs').promises;
const path = require('path');
const app = express();
//const HOST = '192.168.254.25'; // Update to your machine's IP
//const HOST = '192.168.254.50'; // Update to your machine's IP
const HOST = 'localhost'; // Update to your machine's IP
const PORT = 3000;
const oscClient = new Client(HOST, 3333);
app.use(express.static('public'));
app.use('/samples', express.static(path.join(__dirname, 'samples')));
app.use('/impulses', express.static(path.join(__dirname, 'impulses')));
app.get('/files', async (req, res) => {
const filter = f => f.match(/\.(mp3|wav|ogg)$/i);
try {
const [samples, impulses] = await Promise.all([
fs.readdir('./samples'),
fs.readdir('./impulses')
]);
res.json({ samples: samples.filter(filter), impulses: impulses.filter(filter) });
} catch (err) { res.status(500).json({ error: "Disk read error" }); }
});
// Standard OSC Routes
app.get('/play-trigger/:id', (req, res) => {
oscClient.send('/play', parseInt(req.params.id), () => res.sendStatus(200));
});
app.get('/stop-trigger', (req, res) => {
oscClient.send('/stop', () => res.sendStatus(200));
});
app.get('/pitch-trigger/:value', (req, res) => {
oscClient.send('/pitch', parseFloat(req.params.value), () => res.sendStatus(200));
});
app.get('/gain-trigger/:value', (req, res) => {
oscClient.send('/gain', parseFloat(req.params.value), () => res.sendStatus(200));
});
app.get('/file-names', (req, res) => {
oscClient.send('/names/sample', req.query.sample || "none");
oscClient.send('/names/impulse', req.query.impulse || "none", () => res.sendStatus(200));
});
// Advanced Scanner & Envelope OSC Routes
app.get('/scan-toggle/:state', (req, res) => {
oscClient.send('/scan/active', parseInt(req.params.state), () => res.sendStatus(200));
});
app.get('/scan-update', (req, res) => {
const freq = parseFloat(req.query.freq);
const amp = parseFloat(req.query.amp);
oscClient.send('/scan/data', [freq, amp]);
res.sendStatus(200);
});
app.get('/env-trigger/:value', (req, res) => {
oscClient.send('/env', parseFloat(req.params.value));
res.sendStatus(200);
});
app.listen(PORT, HOST, () => console.log(`Server: http://${HOST}:${PORT}`));
Other Notes
- source code on ~/Dropbox/JS/osc-audio-app
