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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ All notable changes to the Band Studio plugin are documented here.

### Changed

- **ES-module migration, step 6 — extract the visualisation layer to
`src/viz.js`.** The requestAnimationFrame playhead loop (`_startAnimLoop`/
`_stopAnimLoop`), waveform canvas rendering + zoom/scroll (`_drawWaveform`/
`_redrawWaveform`/`_drawAllCursors`/`_clampScroll`/`_initWaveformWheelZoom`),
and the master level meter (`_startMasterMeter`/`_debounceSaveMaster`) move out
of `main.js`. Upper layer over the audio engine: imports `_getAudioCtx` from
audio-graph.js + `_formatTime` from util.js, reaches the stop action via the
`window.studioStop` global (no import-back). The 3 animation/meter hooks it
exports are the ones `main.js` injects into audio-graph via `configureAudioGraph`
(no cycle — audio-graph never imports viz). Move-only, no behaviour change.

- **ES-module migration, step 5 — extract the Web Audio engine to
`src/audio-graph.js`.** The playback graph (`_getAudioCtx`, `_createReverbBus`,
`_play`/`_pause`, `_stopAllSources`, `_applyMixToLiveAudio`/`_applyAllMixToLive`,
Expand Down
286 changes: 6 additions & 280 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import {
_parseTimeInput, _formatTime, _formatDate, _esc, Path_stem,
_eqLabel, _compLabel, _describeArc,
_eqLabel, _compLabel, _describeArc, _getTrackColor,
} from './util.js';
import { S } from './state.js';
import { _loadSettings, _saveSettings } from './prefs.js';
import {
_getAudioCtx, _play, _pause, _stopAllSources,
_applyMixToLiveAudio, _applyAllMixToLive, configureAudioGraph,
} from './audio-graph.js';
import {
_startAnimLoop, _stopAnimLoop, _startMasterMeter, _debounceSaveMaster,
_drawWaveform, _drawAllCursors, _clampScroll, _initWaveformWheelZoom,
} from './viz.js';

(function () {
'use strict';
Expand Down Expand Up @@ -394,19 +398,7 @@ import {

// ── Track Rendering ────────────────────────────────────────────────

const TRACK_COLORS = [
'#4080e0', '#e05040', '#40c070', '#c060e0', '#e0a030',
'#50b0d0', '#e07090', '#80c040', '#a080e0', '#d0a060',
];

const INSTRUMENT_COLORS = {
lead: '#4080e0', solo: '#4080e0',
rhythm: '#e05040', clean: '#e07090', acoustic: '#d0a060',
bass: '#40c070',
drums: '#c060e0',
vocals: '#e0a030',
other: '#50b0d0',
};
// Track colours (_getTrackColor + TRACK_COLORS/INSTRUMENT_COLORS) → src/util.js.

const COLOR_PALETTE = [
'#4080e0', '#60a0ff', '#2060b0',
Expand All @@ -419,14 +411,6 @@ import {
'#80c040', '#a0e060', '#608020',
];

function _getTrackColor(t) {
if (t.color) return t.color;
const name = (t.track_name || t.instrument || '').toLowerCase();
for (const [key, col] of Object.entries(INSTRUMENT_COLORS)) {
if (name.includes(key)) return col;
}
return TRACK_COLORS[t.id % TRACK_COLORS.length];
}

function _renderTracks() {
const container = document.getElementById('studio-recorded-tracks');
Expand Down Expand Up @@ -585,30 +569,7 @@ import {

// ── Animation Loop ─────────────────────────────────────────────────

function _startAnimLoop() {
_stopAnimLoop();
function tick() {
if (!S.isPlaying) return;
const ctx = _getAudioCtx();
const elapsed = ctx.currentTime - S.startTime;
document.getElementById('studio-time-current').textContent = _formatTime(elapsed);
document.getElementById('studio-seek-bar').value = elapsed;
_drawAllCursors(elapsed);
if (elapsed >= S.duration) {
studioStop();
return;
}
S.animFrame = requestAnimationFrame(tick);
}
S.animFrame = requestAnimationFrame(tick);
}

function _stopAnimLoop() {
if (S.animFrame) {
cancelAnimationFrame(S.animFrame);
S.animFrame = null;
}
}

// ── Mix Controls ───────────────────────────────────────────────────

Expand Down Expand Up @@ -1727,189 +1688,11 @@ import {

// ── Waveform Rendering ─────────────────────────────────────────────

function _drawWaveform(key, audioBuffer, canvas) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const W = canvas.clientWidth * dpr;
const H = canvas.clientHeight * dpr;
canvas.width = W;
canvas.height = H;

// Compute peaks normalized to the session's total duration so all
// waveforms use the same timescale and shorter tracks don't stretch
// to fill the entire canvas width.
const channelData = audioBuffer.getChannelData(0);
const trackDuration = audioBuffer.duration;
const totalDuration = S.duration || trackDuration;
const peakCount = Math.min(W, 800);
// How many peaks this track actually fills (proportional to duration)
const filledPeaks = Math.round(peakCount * (trackDuration / totalDuration));
const samplesPerPeak = filledPeaks > 0 ? Math.ceil(channelData.length / filledPeaks) : 1;
const peaks = new Float32Array(peakCount); // full array, unfilled = 0
for (let i = 0; i < filledPeaks && i < peakCount; i++) {
const start = i * samplesPerPeak;
const end = Math.min(start + samplesPerPeak, channelData.length);
let peak = 0;
for (let j = start; j < end; j++) {
peak = Math.max(peak, Math.abs(channelData[j]));
}
peaks[i] = peak;
}
S.waveformPeaks[key] = peaks;

_redrawWaveform(key, canvas, 0);
}

function _redrawWaveform(key, canvas, cursorTime) {
const peaks = S.waveformPeaks[key];
if (!peaks || !canvas) return;

const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
const mid = H / 2;

ctx.clearRect(0, 0, W, H);

if (S.duration <= 0) return;

// Visible time window based on zoom
const visibleDur = S.duration / S.zoomLevel;
const visStart = S.scrollOffset;
const visEnd = visStart + visibleDur;

// Track time offset
const st = S.mixState[key] || {};
const offsetSec = (st.offset_ms || 0) / 1000;

// Get track color for waveform tint
let waveColor = '64, 128, 224'; // default blue
if (key !== 'original' && S.currentSession) {
const track = S.currentSession.tracks.find(t => t.id === key);
if (track) {
const hex = _getTrackColor(track);
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
waveColor = `${r}, ${g}, ${b}`;
}
}

// Map peak index to time: peaks span the track's portion of total duration
const peakTimeStep = S.duration / peaks.length;

const barW = Math.max(1, (W / peaks.length) * S.zoomLevel);
for (let i = 0; i < peaks.length; i++) {
const peakTime = (i * peakTimeStep) + offsetSec;
if (peakTime < visStart || peakTime > visEnd) continue;
const x = ((peakTime - visStart) / visibleDur) * W;
const bh = peaks[i] * (mid - 2);
const isPast = peakTime < cursorTime;
ctx.fillStyle = isPast ? `rgba(${waveColor}, 0.7)` : `rgba(${waveColor}, 0.3)`;
ctx.fillRect(x, mid - bh, Math.max(barW, 1), bh * 2);
}

// Draw fade zones as gradient overlays
const fadeInSec = (st.fade_in_ms || 0) / 1000;
const fadeOutSec = (st.fade_out_ms || 0) / 1000;
if (fadeInSec > 0) {
const fadeStartPx = Math.max(0, ((offsetSec - visStart) / visibleDur) * W);
const fadeEndPx = ((offsetSec + fadeInSec - visStart) / visibleDur) * W;
if (fadeEndPx > 0 && fadeStartPx < W) {
const grad = ctx.createLinearGradient(fadeStartPx, 0, fadeEndPx, 0);
grad.addColorStop(0, 'rgba(0,0,0,0.6)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.fillRect(fadeStartPx, 0, fadeEndPx - fadeStartPx, H);
}
}
if (fadeOutSec > 0 && S.duration > 0) {
// Fade out starts at track end minus fade duration
const buf = S.trackBuffers[key];
const trackEnd = offsetSec + (buf ? buf.duration : S.duration);
const fadeStartPx = ((trackEnd - fadeOutSec - visStart) / visibleDur) * W;
const fadeEndPx = ((trackEnd - visStart) / visibleDur) * W;
if (fadeEndPx > 0 && fadeStartPx < W) {
const grad = ctx.createLinearGradient(fadeStartPx, 0, fadeEndPx, 0);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.6)');
ctx.fillStyle = grad;
ctx.fillRect(fadeStartPx, 0, fadeEndPx - fadeStartPx, H);
}
}

// Draw markers
if (S.currentSession && S.currentSession.markers) {
for (const m of S.currentSession.markers) {
if (m.time >= visStart && m.time <= visEnd) {
const mx = ((m.time - visStart) / visibleDur) * W;
ctx.fillStyle = m.color || '#e0a030';
ctx.globalAlpha = 0.6;
ctx.fillRect(mx - 0.5, 0, 1, H);
ctx.globalAlpha = 1;
// Label (only on first/original track canvas to avoid clutter)
if (key === 'original') {
ctx.font = `${Math.min(10, H * 0.2)}px Inter, sans-serif`;
ctx.fillStyle = m.color || '#e0a030';
ctx.fillText(m.name, mx + 3, 10);
}
}
}
}

// Draw cursor
if (cursorTime >= visStart && cursorTime <= visEnd) {
const cx = ((cursorTime - visStart) / visibleDur) * W;
ctx.fillStyle = '#fff';
ctx.fillRect(cx - 0.5, 0, 1, H);
}
}

function _drawAllCursors(timeSeconds) {
if (S.duration <= 0) return;

// Auto-scroll: keep cursor visible when playing
if (S.isPlaying) {
const visibleDur = S.duration / S.zoomLevel;
if (timeSeconds < S.scrollOffset || timeSeconds > S.scrollOffset + visibleDur) {
S.scrollOffset = Math.max(0, timeSeconds - visibleDur * 0.1);
}
}

const origCanvas = document.getElementById('studio-waveform-original');
_redrawWaveform('original', origCanvas, timeSeconds);

if (S.currentSession && S.currentSession.tracks) {
for (const t of S.currentSession.tracks) {
const canvas = document.getElementById(`studio-waveform-${t.id}`);
_redrawWaveform(t.id, canvas, timeSeconds);
}
}
}

// ── Master Bus Controls ──────────────────────────────────────────

function _startMasterMeter() {
if (S.masterMeterInterval) clearInterval(S.masterMeterInterval);
S.masterMeterInterval = setInterval(() => {
if (!S.masterAnalyser) return;
const data = new Uint8Array(S.masterAnalyser.frequencyBinCount);
S.masterAnalyser.getByteTimeDomainData(data);
let peak = 0;
for (let i = 0; i < data.length; i++) {
const v = Math.abs(data[i] - 128) / 128;
if (v > peak) peak = v;
}
const bar = document.getElementById('studio-master-meter-bar');
if (bar) {
const pct = Math.min(100, Math.round(peak * 100));
bar.style.width = pct + '%';
bar.className = 'h-full rounded-full transition-all ' +
(peak > 0.95 ? 'bg-red-500' : peak > 0.7 ? 'bg-yellow-500' : 'bg-green-500');
}
}, 50);
}

window.studioSetMasterVolume = function (value) {
S.masterVolume = Math.max(0, Math.min(2, parseFloat(value)));
Expand All @@ -1930,19 +1713,6 @@ import {
_debounceSaveMaster();
};

function _debounceSaveMaster() {
if (S.saveMasterTimer) clearTimeout(S.saveMasterTimer);
S.saveMasterTimer = setTimeout(async () => {
if (!S.currentSession) return;
try {
await fetch(`/api/plugins/studio/sessions/${S.currentSession.id}/master`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ master_volume: S.masterVolume, master_limiter: S.masterLimiterOn }),
});
} catch (e) { /* ignore */ }
}, 1000);
}

// ── Zoom & Scroll ───────────────────────────────────────────────────

Expand Down Expand Up @@ -1972,51 +1742,7 @@ import {
_drawAllCursors(curTime);
};

function _clampScroll() {
const visibleDur = S.duration / S.zoomLevel;
S.scrollOffset = Math.max(0, Math.min(S.duration - visibleDur, S.scrollOffset));
// Update scroll bar
const bar = document.getElementById('studio-scroll-bar');
if (bar) {
bar.max = Math.max(0, S.duration - visibleDur);
bar.value = S.scrollOffset;
bar.step = visibleDur / 100;
}
// Update zoom display
const zoomLabel = document.getElementById('studio-zoom-label');
if (zoomLabel) zoomLabel.textContent = S.zoomLevel <= 1 ? 'Fit' : S.zoomLevel.toFixed(1) + 'x';
}

function _initWaveformWheelZoom() {
const container = document.getElementById('studio-tracks-container');
if (!container || container._wheelZoomInit) return;
container._wheelZoomInit = true;
container.addEventListener('wheel', (e) => {
if (!e.ctrlKey && !e.metaKey) {
// Plain scroll = horizontal pan
if (S.zoomLevel > 1) {
const visibleDur = S.duration / S.zoomLevel;
S.scrollOffset += (e.deltaY > 0 ? 1 : -1) * visibleDur * 0.1;
_clampScroll();
const curTime = S.isPlaying ? (_getAudioCtx().currentTime - S.startTime) : S.pauseOffset;
_drawAllCursors(curTime);
e.preventDefault();
}
return;
}
// Ctrl+scroll = zoom
e.preventDefault();
const maxZoom = Math.max(1, S.duration / 2);
if (e.deltaY < 0) {
S.zoomLevel = Math.min(maxZoom, S.zoomLevel * 1.2);
} else {
S.zoomLevel = Math.max(1, S.zoomLevel / 1.2);
}
_clampScroll();
const curTime = S.isPlaying ? (_getAudioCtx().currentTime - S.startTime) : S.pauseOffset;
_drawAllCursors(curTime);
}, { passive: false });
}

// ── Input Device Enumeration ───────────────────────────────────────

Expand Down
23 changes: 23 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,26 @@ export function _describeArc(cx, cy, r, startAngle, endAngle) {
const largeArc = endAngle - startAngle <= 180 ? '0' : '1';
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 0 ${end.x} ${end.y}`;
}

// Track waveform colours: honour an explicit color, else match the instrument
// name, else cycle the palette by track id. Pure (reads the track object only).
const TRACK_COLORS = [
'#4080e0', '#e05040', '#40c070', '#c060e0', '#e0a030',
'#50b0d0', '#e07090', '#80c040', '#a080e0', '#d0a060',
];
const INSTRUMENT_COLORS = {
lead: '#4080e0', solo: '#4080e0',
rhythm: '#e05040', clean: '#e07090', acoustic: '#d0a060',
bass: '#40c070',
drums: '#c060e0',
vocals: '#e0a030',
other: '#50b0d0',
};
export function _getTrackColor(t) {
if (t.color) return t.color;
const name = (t.track_name || t.instrument || '').toLowerCase();
for (const [key, col] of Object.entries(INSTRUMENT_COLORS)) {
if (name.includes(key)) return col;
}
return TRACK_COLORS[t.id % TRACK_COLORS.length];
}
Loading
Loading