P1a: material import, sixteen faces, sample engine and quarry
This commit is contained in:
+2
-1
@@ -8,7 +8,8 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5173 --strictPort",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"smoke": "node scripts/smoke.mjs"
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"quarry-smoke": "node scripts/quarry-smoke.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome';
|
||||
const browser = await chromium.launch({ executablePath: chrome, args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
|
||||
page.on('console', (message) => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); });
|
||||
|
||||
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' });
|
||||
await page.waitForTimeout(2400);
|
||||
await page.locator('.room', { hasText: 'quarry' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: '../preview-p1-quarry-empty.png' });
|
||||
|
||||
await page.locator('input[type=file]').setInputFiles('../fixtures/clicks-01.wav');
|
||||
await page.waitForFunction(() => document.querySelector('.core-name')?.textContent?.includes('clicks-01'), null, { timeout: 20000 });
|
||||
await page.waitForTimeout(700);
|
||||
await page.locator('.face').nth(10).click();
|
||||
await page.waitForTimeout(250);
|
||||
await page.locator('.assign').click();
|
||||
await page.waitForTimeout(500);
|
||||
if (!(await page.locator('.play.is-playing').count())) await page.locator('.play').click();
|
||||
await page.waitForTimeout(1900);
|
||||
await page.screenshot({ path: '../preview-p1-quarry.png' });
|
||||
|
||||
const report = await page.evaluate(() => ({
|
||||
net: document.querySelector('.net b')?.textContent,
|
||||
cores: [...document.querySelectorAll('.core-name')].map((el) => el.textContent),
|
||||
faces: document.querySelectorAll('.face').length,
|
||||
selectedFace: document.querySelector('.face.is-on .face-index')?.textContent,
|
||||
patternFace: document.querySelector('.pattern-row .pattern-face')?.textContent?.trim(),
|
||||
steps: document.querySelectorAll('.step').length,
|
||||
stepsOn: document.querySelectorAll('.step.is-on').length,
|
||||
wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-8),
|
||||
master: document.querySelector('.master-db')?.textContent,
|
||||
cilia: [...document.querySelectorAll('.cilium')].map((el) => Number(getComputedStyle(el).getPropertyValue('--level'))),
|
||||
canvas: { w: document.querySelector('canvas.wave')?.width, h: document.querySelector('canvas.wave')?.height }
|
||||
}));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
await page.locator('.room', { hasText: 'nave' }).click();
|
||||
await page.waitForTimeout(1600);
|
||||
await page.screenshot({ path: '../preview-p1-nave.png' });
|
||||
const nave = await page.evaluate(() => ({
|
||||
canvas: { w: document.querySelector('.descent canvas')?.width, h: document.querySelector('.descent canvas')?.height },
|
||||
lanes: document.querySelectorAll('.lane').length,
|
||||
faceRows: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-4)
|
||||
}));
|
||||
console.log(JSON.stringify(nave, null, 2));
|
||||
|
||||
const failures = [];
|
||||
if (!report.cores.length) failures.push('no core imported');
|
||||
if (report.faces !== 16) failures.push('the face wall is not 16');
|
||||
if (!report.patternFace || report.patternFace.startsWith('procedural')) failures.push('the pattern face was not assigned');
|
||||
if (report.canvas.w < 200) failures.push('the waveform canvas did not size');
|
||||
if (!report.cilia.some((level) => level > 0.001)) failures.push('no level moved');
|
||||
if (errors.length) failures.push('console errors');
|
||||
await browser.close();
|
||||
if (failures.length) {
|
||||
console.error('QUARRY SMOKE FAILED: ' + failures.join('; '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('quarry smoke passed');
|
||||
+117
-8
@@ -4,12 +4,14 @@
|
||||
import { onMount } from 'svelte';
|
||||
import Descent from './components/Descent.svelte';
|
||||
import EnginePanel from './components/EnginePanel.svelte';
|
||||
import Quarry from './components/Quarry.svelte';
|
||||
import Transport from './components/Transport.svelte';
|
||||
import Wire from './components/Wire.svelte';
|
||||
import { Bridge } from './lib/bridge';
|
||||
import { commandLabel } from './lib/format';
|
||||
import type {
|
||||
Command,
|
||||
CoreSummary,
|
||||
Event,
|
||||
HelloMessage,
|
||||
MeterFrame,
|
||||
@@ -30,6 +32,11 @@
|
||||
let selected: VeinId = $state('bass');
|
||||
let entries: WireEntry[] = $state([]);
|
||||
let hello: HelloMessage | null = $state(null);
|
||||
let view: 'nave' | 'quarry' = $state('nave');
|
||||
let selectedCoreId: string | null = $state(null);
|
||||
let selectedFace: number = $state(-1);
|
||||
let selectedRow: number = $state(0);
|
||||
let importing: boolean = $state(false);
|
||||
let bridge: Bridge | null = null;
|
||||
let entryId = 0;
|
||||
let snapshotLogged = false;
|
||||
@@ -57,12 +64,23 @@
|
||||
|
||||
function onState(next: State) {
|
||||
project = next;
|
||||
const cores = next.cores ?? [];
|
||||
if (!selectedCoreId || !cores.some((core) => core.id === selectedCoreId)) {
|
||||
const first = cores[0];
|
||||
selectedCoreId = first?.id ?? null;
|
||||
selectedFace = first ? (first.faces.find((face) => face.rank === 0)?.index ?? 0) : -1;
|
||||
}
|
||||
if (!snapshotLogged) {
|
||||
snapshotLogged = true;
|
||||
addEntry('info', 'snapshot', `${next.veins.length} veins · ${next.pattern.steps} steps · rev ${next.revision}`);
|
||||
addEntry('info', 'snapshot', `${next.veins.length} veins · ${next.pattern.steps} steps · ${cores.length} cores · rev ${next.revision}`);
|
||||
}
|
||||
}
|
||||
|
||||
function onImported(core: CoreSummary) {
|
||||
selectedCoreId = core.id;
|
||||
selectedFace = core.faces.find((face) => face.rank === 0)?.index ?? 0;
|
||||
}
|
||||
|
||||
function onMeter(next: MeterFrame) {
|
||||
meter = next;
|
||||
}
|
||||
@@ -74,6 +92,12 @@
|
||||
else if (event.kind === 'vein_gain') {
|
||||
const vein = project.veins.find((entry) => entry.id === event.vein);
|
||||
if (vein) vein.gain = event.gain;
|
||||
} else if (event.kind === 'pattern_step') {
|
||||
const row = project.pattern.rows[event.row];
|
||||
if (row) row.velocities[event.step] = event.velocity;
|
||||
} else if (event.kind === 'pattern_face') {
|
||||
const row = project.pattern.rows[event.row];
|
||||
if (row) row.face = { core: event.core, face: event.face };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +140,55 @@
|
||||
void run({ type: 'set_vein_gain', vein, gain });
|
||||
}
|
||||
|
||||
async function importCore(file: File) {
|
||||
if (!bridge || importing) return;
|
||||
importing = true;
|
||||
addEntry('send', `import ${file.name}`);
|
||||
try {
|
||||
const data = await file.arrayBuffer();
|
||||
const core = await bridge.importCore(file.name, 'found', data);
|
||||
selectedCoreId = core.id;
|
||||
selectedFace = core.faces.find((face) => face.rank === 0)?.index ?? 0;
|
||||
addEntry('recv', 'core', `${core.name} · ${core.faces.length} faces · ${core.duration_sec.toFixed(1)} s`);
|
||||
} catch (error) {
|
||||
addEntry('error', 'import', error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectCore(id: string) {
|
||||
selectedCoreId = id;
|
||||
const core = project?.cores.find((item) => item.id === id);
|
||||
selectedFace = core ? (core.faces.find((face) => face.rank === 0)?.index ?? 0) : -1;
|
||||
}
|
||||
|
||||
function strikeFace(core: string, face: number) {
|
||||
void run({ type: 'strike_face', core, face, velocity: 0.85 });
|
||||
}
|
||||
|
||||
function strikeSelected() {
|
||||
if (view === 'quarry' && selectedCoreId && selectedFace >= 0) {
|
||||
strikeFace(selectedCoreId, selectedFace);
|
||||
} else {
|
||||
strike();
|
||||
}
|
||||
}
|
||||
|
||||
function assignFace() {
|
||||
if (!selectedCoreId || selectedFace < 0) return;
|
||||
void run({
|
||||
type: 'set_pattern_face',
|
||||
row: selectedRow,
|
||||
core: selectedCoreId,
|
||||
face: selectedFace
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStep(row: number, step: number, on: boolean) {
|
||||
void run({ type: 'set_pattern_step', row, step, velocity: on ? 0 : 96 });
|
||||
}
|
||||
|
||||
function select(vein: VeinId) {
|
||||
selected = vein;
|
||||
}
|
||||
@@ -138,12 +211,17 @@
|
||||
}
|
||||
if (event.key === 's' || event.key === 'S') {
|
||||
event.preventDefault();
|
||||
strike();
|
||||
strikeSelected();
|
||||
return;
|
||||
}
|
||||
if (event.key >= '1' && event.key <= '5') {
|
||||
const vein = VEINS[Number(event.key) - 1];
|
||||
if (vein) select(vein);
|
||||
const index = Number(event.key) - 1;
|
||||
if (view === 'quarry') {
|
||||
selectedRow = index;
|
||||
} else {
|
||||
const vein = VEINS[index];
|
||||
if (vein) select(vein);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +240,8 @@
|
||||
onState,
|
||||
onMeter,
|
||||
onEvent,
|
||||
onError
|
||||
onError,
|
||||
onImported
|
||||
});
|
||||
bridge.connect();
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -180,10 +259,22 @@
|
||||
<span class="version">0.1</span>
|
||||
</div>
|
||||
<div class="rail-center">
|
||||
<span class="room is-on">nave</span>
|
||||
<button class="room" class:is-on={view === 'nave'} aria-pressed={view === 'nave'} onclick={() => (view = 'nave')}>
|
||||
nave
|
||||
</button>
|
||||
<span class="rail-rule" aria-hidden="true"></span>
|
||||
<button class="room" class:is-on={view === 'quarry'} aria-pressed={view === 'quarry'} onclick={() => (view = 'quarry')}>
|
||||
quarry
|
||||
</button>
|
||||
<span class="rail-rule" aria-hidden="true"></span>
|
||||
<span class="room is-on"><span class="mode-dot" aria-hidden="true"></span>hand</span>
|
||||
<span class="rail-note">{project ? `${project.veins.length} veins` : 'five veins'}</span>
|
||||
<span class="rail-note">
|
||||
{view === 'quarry'
|
||||
? `${project?.cores.length ?? 0} core${(project?.cores.length ?? 0) === 1 ? '' : 's'}`
|
||||
: project
|
||||
? `${project.veins.length} veins`
|
||||
: 'five veins'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="net" class:is-live={status === 'live'}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
@@ -193,7 +284,25 @@
|
||||
</header>
|
||||
|
||||
<main class="hero">
|
||||
<Descent state={project} {meter} {selected} onselect={select} {strike} />
|
||||
{#if view === 'nave'}
|
||||
<Descent state={project} {meter} {selected} onselect={select} {strike} />
|
||||
{:else}
|
||||
<Quarry
|
||||
cores={project?.cores ?? []}
|
||||
pattern={project?.pattern ?? null}
|
||||
selectedCore={selectedCoreId}
|
||||
selectedFace={selectedFace}
|
||||
selectedRow={selectedRow}
|
||||
{importing}
|
||||
onselectcore={selectCore}
|
||||
onselectface={(face) => (selectedFace = face)}
|
||||
onselectrow={(row) => (selectedRow = row)}
|
||||
onimport={importCore}
|
||||
onassign={assignFace}
|
||||
onstrikeface={strikeFace}
|
||||
onstep={toggleStep}
|
||||
/>
|
||||
{/if}
|
||||
<Wire {entries} {status} onreconnect={reconnect} />
|
||||
<EnginePanel state={project} {meter} {status} session={hello?.session ?? '—'} ongain={setGain} />
|
||||
</main>
|
||||
|
||||
+546
@@ -1039,6 +1039,507 @@ a:focus-visible,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* — quarry — */
|
||||
|
||||
button.room {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quarry {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
padding: 22px calc(var(--panel-r) + 20px) 26px calc(var(--panel-l) + 22px);
|
||||
overflow-y: auto;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 4%, rgba(232, 64, 42, 0.06), transparent 55%),
|
||||
var(--resin);
|
||||
animation: fade 0.7s 0.05s ease both;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(155, 133, 121, 0.3) transparent;
|
||||
}
|
||||
|
||||
.quarry-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(155, 133, 121, 0.12);
|
||||
}
|
||||
|
||||
.quarry-head h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 30px;
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.quarry-head p {
|
||||
margin: 5px 0 0;
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.quarry-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-state {
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ash-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.import,
|
||||
.empty-action {
|
||||
padding: 7px 11px;
|
||||
border: 1px solid rgba(232, 64, 42, 0.35);
|
||||
background: rgba(232, 64, 42, 0.04);
|
||||
color: var(--bone);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.04em;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.import:hover,
|
||||
.empty-action:hover {
|
||||
border-color: var(--ember);
|
||||
background: rgba(232, 64, 42, 0.1);
|
||||
}
|
||||
|
||||
.import:disabled,
|
||||
.empty-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quarry-empty {
|
||||
max-width: 460px;
|
||||
margin: 13vh auto 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 30px;
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.empty-copy {
|
||||
margin: 14px 0 18px;
|
||||
font-size: 10px;
|
||||
line-height: 1.8;
|
||||
color: var(--ash);
|
||||
}
|
||||
|
||||
.empty-formats {
|
||||
margin-top: 14px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.core-strip {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-top: 10px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid rgba(155, 133, 121, 0.1);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.core-strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.core-tab {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 10px 6px;
|
||||
border-top: 1px solid rgba(155, 133, 121, 0.16);
|
||||
text-align: left;
|
||||
color: var(--ash);
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.core-tab:hover,
|
||||
.core-tab.is-on {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.core-tab.is-on {
|
||||
border-top-color: var(--ember);
|
||||
}
|
||||
|
||||
.core-name {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.core-meta {
|
||||
display: block;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ash-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.quarry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 196px;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.quarry-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.material-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.material-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 25px;
|
||||
font-weight: 300;
|
||||
line-height: 1.1;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.material-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ash-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.material-rank {
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ash);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wave {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 154px;
|
||||
margin-top: 7px;
|
||||
border-top: 1px solid rgba(155, 133, 121, 0.12);
|
||||
border-bottom: 1px solid rgba(155, 133, 121, 0.12);
|
||||
}
|
||||
|
||||
.wave-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0 9px;
|
||||
font-size: 8px;
|
||||
color: var(--ash-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.face-wall {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.face {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
overflow: hidden;
|
||||
padding: 4px 5px;
|
||||
border: 1px solid rgba(155, 133, 121, 0.14);
|
||||
background: rgba(12, 9, 8, 0.62);
|
||||
text-align: left;
|
||||
color: var(--ash);
|
||||
transition: border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.face:hover {
|
||||
border-color: rgba(232, 64, 42, 0.5);
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.face.is-on {
|
||||
border-color: var(--ember);
|
||||
color: var(--bone);
|
||||
box-shadow: inset 0 0 0 1px rgba(232, 64, 42, 0.35);
|
||||
}
|
||||
|
||||
.face-index {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
font-size: 8.5px;
|
||||
line-height: 1.3;
|
||||
color: var(--bone);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.face-rank {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
font-size: 7.5px;
|
||||
line-height: 1.3;
|
||||
color: var(--ash-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.face-energy {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: calc(var(--energy) * 100%);
|
||||
background: linear-gradient(to top, rgba(232, 64, 42, 0.28), rgba(232, 64, 42, 0.02));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.material-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.face-strike,
|
||||
.assign {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(155, 133, 121, 0.25);
|
||||
color: var(--bone);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.03em;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.face-strike:hover,
|
||||
.assign:hover {
|
||||
border-color: var(--ember);
|
||||
background: rgba(232, 64, 42, 0.08);
|
||||
}
|
||||
|
||||
.assign {
|
||||
border-color: rgba(232, 64, 42, 0.42);
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.face-strike:disabled,
|
||||
.assign:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.assign-note {
|
||||
font-size: 8px;
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.pattern-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0 7px;
|
||||
margin-top: 9px;
|
||||
border-top: 1px solid rgba(155, 133, 121, 0.12);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.pattern {
|
||||
display: grid;
|
||||
grid-template-columns: 94px minmax(0, 1fr);
|
||||
gap: 1px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pattern-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid transparent;
|
||||
text-align: left;
|
||||
color: var(--ash);
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.pattern-row:hover,
|
||||
.pattern-row.is-on {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.pattern-row.is-on {
|
||||
border-top-color: var(--ember);
|
||||
}
|
||||
|
||||
.pattern-name {
|
||||
font-size: 9.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pattern-face {
|
||||
overflow: hidden;
|
||||
font-size: 7.5px;
|
||||
line-height: 1.4;
|
||||
color: var(--ash-2);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pattern-cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(16, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid rgba(155, 133, 121, 0.14);
|
||||
background: rgba(155, 133, 121, 0.03);
|
||||
}
|
||||
|
||||
.step.is-downbeat {
|
||||
border-top-color: rgba(155, 133, 121, 0.34);
|
||||
}
|
||||
|
||||
.step.is-on {
|
||||
background: rgba(232, 64, 42, 0.07);
|
||||
}
|
||||
|
||||
.step:hover {
|
||||
background: rgba(232, 64, 42, 0.16);
|
||||
}
|
||||
|
||||
.step-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: calc(var(--velocity) * 100%);
|
||||
background: linear-gradient(to top, rgba(232, 64, 42, 0.55), rgba(232, 64, 42, 0.14));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quarry-side {
|
||||
min-width: 0;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid rgba(155, 133, 121, 0.12);
|
||||
}
|
||||
|
||||
.side-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.side-note {
|
||||
margin: 7px 0 11px;
|
||||
font-size: 9px;
|
||||
line-height: 1.75;
|
||||
color: var(--ash);
|
||||
}
|
||||
|
||||
.face-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.face-stats div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(155, 133, 121, 0.07);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.face-stats span {
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.face-stats b {
|
||||
overflow: hidden;
|
||||
color: var(--bone);
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.side-rule {
|
||||
height: 1px;
|
||||
margin: 14px 0;
|
||||
background: rgba(155, 133, 121, 0.1);
|
||||
}
|
||||
|
||||
.side-legend {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
padding: 3px 0;
|
||||
font-size: 8px;
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.legend-key {
|
||||
min-width: 64px;
|
||||
color: var(--ash);
|
||||
}
|
||||
|
||||
.side-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin-top: 20px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(155, 133, 121, 0.1);
|
||||
font-size: 8px;
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.side-footer b {
|
||||
color: var(--bone);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* — responsive — */
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
@@ -1082,6 +1583,24 @@ a:focus-visible,
|
||||
min-height: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
.quarry {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
width: 100%;
|
||||
padding: 20px 18px 24px;
|
||||
}
|
||||
.quarry-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.quarry-side {
|
||||
padding-top: 14px;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid rgba(155, 133, 121, 0.12);
|
||||
border-left: 0;
|
||||
}
|
||||
.wave {
|
||||
height: 130px;
|
||||
}
|
||||
.scene {
|
||||
top: 27%;
|
||||
width: 86%;
|
||||
@@ -1215,6 +1734,33 @@ a:focus-visible,
|
||||
margin-left: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.quarry {
|
||||
padding: 18px 14px 22px;
|
||||
}
|
||||
.quarry-head h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
.quarry-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
.import-state {
|
||||
display: none;
|
||||
}
|
||||
.face-wall {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.pattern {
|
||||
grid-template-columns: 66px minmax(0, 1fr);
|
||||
}
|
||||
.pattern-cells {
|
||||
gap: 1px;
|
||||
}
|
||||
.step {
|
||||
height: 19px;
|
||||
}
|
||||
.pattern-face {
|
||||
display: none;
|
||||
}
|
||||
.pool {
|
||||
padding: 18px 16px 14px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { CoreSummary, Pattern } from '../lib/types';
|
||||
|
||||
let {
|
||||
cores,
|
||||
pattern,
|
||||
selectedCore,
|
||||
selectedFace,
|
||||
selectedRow,
|
||||
importing,
|
||||
onselectcore,
|
||||
onselectface,
|
||||
onselectrow,
|
||||
onimport,
|
||||
onassign,
|
||||
onstrikeface,
|
||||
onstep
|
||||
}: {
|
||||
cores: CoreSummary[];
|
||||
pattern: Pattern | null;
|
||||
selectedCore: string | null;
|
||||
selectedFace: number;
|
||||
selectedRow: number;
|
||||
importing: boolean;
|
||||
onselectcore: (id: string) => void;
|
||||
onselectface: (face: number) => void;
|
||||
onselectrow: (row: number) => void;
|
||||
onimport: (file: File) => void;
|
||||
onassign: () => void;
|
||||
onstrikeface: (core: string, face: number) => void;
|
||||
onstep: (row: number, step: number, on: boolean) => void;
|
||||
} = $props();
|
||||
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let wave: HTMLCanvasElement | null = $state(null);
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
const core = $derived(cores.find((item) => item.id === selectedCore) ?? cores[0] ?? null);
|
||||
const face = $derived(
|
||||
core?.faces.find((item) => item.index === selectedFace) ??
|
||||
core?.faces.find((item) => item.rank === 0) ??
|
||||
null
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (!wave) return;
|
||||
observer = new ResizeObserver(() => draw());
|
||||
observer.observe(wave);
|
||||
draw();
|
||||
return () => observer?.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
core;
|
||||
face;
|
||||
draw();
|
||||
});
|
||||
|
||||
function draw() {
|
||||
const canvas = wave;
|
||||
if (!canvas || !core) return;
|
||||
const width = Math.max(200, canvas.clientWidth);
|
||||
const height = 154;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
if (canvas.width !== Math.floor(width * dpr) || canvas.height !== Math.floor(height * dpr)) {
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
}
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const center = height / 2;
|
||||
const duration = Math.max(0.001, core.duration_sec);
|
||||
|
||||
for (const item of core.faces) {
|
||||
const start = (item.start_sec / duration) * width;
|
||||
const end = (item.end_sec / duration) * width;
|
||||
if (face && item.index === face.index) {
|
||||
context.fillStyle = 'rgba(232,64,42,0.08)';
|
||||
context.fillRect(start, 0, end - start, height);
|
||||
}
|
||||
context.strokeStyle =
|
||||
item.index % 4 === 0 ? 'rgba(232,64,42,0.34)' : 'rgba(155,133,121,0.14)';
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(start + 0.5, 0);
|
||||
context.lineTo(start + 0.5, height);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
const step = width / Math.max(1, core.peaks.length - 1);
|
||||
context.fillStyle = 'rgba(232,64,42,0.58)';
|
||||
for (let index = 0; index < core.peaks.length; index += 1) {
|
||||
const amplitude = core.peaks[index] * (height * 0.38);
|
||||
context.fillRect(index * step, center - amplitude, Math.max(1, step * 0.72), amplitude * 2);
|
||||
}
|
||||
context.strokeStyle = 'rgba(239,228,212,0.14)';
|
||||
context.beginPath();
|
||||
context.moveTo(0, center + 0.5);
|
||||
context.lineTo(width, center + 0.5);
|
||||
context.stroke();
|
||||
|
||||
if (face) {
|
||||
const start = (face.start_sec / duration) * width;
|
||||
const end = (face.end_sec / duration) * width;
|
||||
context.strokeStyle = 'rgba(239,228,212,0.72)';
|
||||
context.lineWidth = 1;
|
||||
context.strokeRect(start + 0.5, 0.5, Math.max(1, end - start - 1), height - 1);
|
||||
context.font = '9px "JetBrains Mono", monospace';
|
||||
context.fillStyle = 'rgba(239,228,212,0.8)';
|
||||
context.fillText(`f${String(face.index + 1).padStart(2, '0')}`, start + 5, 13);
|
||||
}
|
||||
}
|
||||
|
||||
function pickFile() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
function fileChanged() {
|
||||
const file = fileInput?.files?.[0];
|
||||
if (file) onimport(file);
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
function dropped(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) onimport(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="quarry" aria-label="quarry" ondragover={(event) => event.preventDefault()} ondrop={dropped}>
|
||||
<header class="quarry-head">
|
||||
<div>
|
||||
<h1>quarry</h1>
|
||||
<p>material · archive · sixteen faces</p>
|
||||
</div>
|
||||
<div class="quarry-actions">
|
||||
<span class="import-state">
|
||||
{importing ? 'cutting…' : `${cores.length} core${cores.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
<button class="import" onclick={pickFile} disabled={importing}>take a core</button>
|
||||
<input
|
||||
class="file"
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="audio/*,.wav,.flac,.mp3,.ogg"
|
||||
onchange={fileChanged}
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !cores.length}
|
||||
<div class="quarry-empty">
|
||||
<p class="empty-title">the archive is empty.</p>
|
||||
<p class="empty-copy">
|
||||
Import a field recording. The quarry finds the seams and cuts sixteen faces. They can be
|
||||
struck on their own or laid on a vein.
|
||||
</p>
|
||||
<button class="empty-action" onclick={pickFile} disabled={importing}>take a core</button>
|
||||
<p class="empty-formats">WAV · FLAC · MP3 · OGG</p>
|
||||
</div>
|
||||
{:else if core}
|
||||
<div class="core-strip" aria-label="cores">
|
||||
{#each cores as item}
|
||||
<button
|
||||
class="core-tab"
|
||||
class:is-on={core.id === item.id}
|
||||
onclick={() => onselectcore(item.id)}
|
||||
aria-pressed={core.id === item.id}
|
||||
>
|
||||
<span class="core-name">{item.name}</span>
|
||||
<span class="core-meta">{item.duration_sec.toFixed(1)} s · {item.sample_rate / 1000} k</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="quarry-grid">
|
||||
<div class="quarry-left">
|
||||
<div class="material-head">
|
||||
<div>
|
||||
<h2>{core.name}</h2>
|
||||
<p>{core.sample_rate} Hz · {core.duration_sec.toFixed(1)} s · seal {core.seal}</p>
|
||||
</div>
|
||||
<div class="material-rank">
|
||||
face {face ? String(face.index + 1).padStart(2, '0') : '—'} · rank {face ? String(face.rank + 1).padStart(2, '0') : '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas class="wave" bind:this={wave} aria-hidden="true"></canvas>
|
||||
<div class="wave-axis">
|
||||
<span>0.0 s</span>
|
||||
<span>{core.duration_sec.toFixed(1)} s</span>
|
||||
</div>
|
||||
|
||||
<div class="face-wall" role="group" aria-label="sixteen faces">
|
||||
{#each core.faces as item}
|
||||
<button
|
||||
class="face"
|
||||
class:is-on={face?.index === item.index}
|
||||
style={`--energy:${Math.min(1, item.rms * 3.2).toFixed(3)}`}
|
||||
onclick={() => onselectface(item.index)}
|
||||
ondblclick={() => onstrikeface(core.id, item.index)}
|
||||
aria-pressed={face?.index === item.index}
|
||||
aria-label={`face ${item.index + 1}, rank ${item.rank + 1}`}
|
||||
title={`face ${item.index + 1} · rank ${item.rank + 1} · double-click to strike`}
|
||||
>
|
||||
<span class="face-index">{String(item.index + 1).padStart(2, '0')}</span>
|
||||
<span class="face-rank">{String(item.rank + 1).padStart(2, '0')}</span>
|
||||
<span class="face-energy" aria-hidden="true"></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="material-actions">
|
||||
<button class="face-strike" onclick={() => face && onstrikeface(core.id, face.index)} disabled={!face}>
|
||||
strike face
|
||||
</button>
|
||||
<button class="assign" onclick={onassign} disabled={!face}>
|
||||
lay on {pattern?.rows[selectedRow]?.vein ?? 'bass'}
|
||||
</button>
|
||||
<span class="assign-note">select a face, then lay it on the chosen vein</span>
|
||||
</div>
|
||||
|
||||
<div class="pattern-head">
|
||||
<span>pattern</span>
|
||||
<span>{pattern?.steps ?? 16} steps · two bars</span>
|
||||
</div>
|
||||
<div class="pattern">
|
||||
{#each pattern?.rows ?? [] as row, rowIndex}
|
||||
<button
|
||||
class="pattern-row"
|
||||
class:is-on={selectedRow === rowIndex}
|
||||
onclick={() => onselectrow(rowIndex)}
|
||||
aria-pressed={selectedRow === rowIndex}
|
||||
aria-label={`select ${row.vein}`}
|
||||
>
|
||||
<span class="pattern-name">{row.vein}</span>
|
||||
<span class="pattern-face">
|
||||
{row.face ? `${row.face.core.slice(-6)} f${String(row.face.face + 1).padStart(2, '0')}` : 'procedural'}
|
||||
</span>
|
||||
</button>
|
||||
<div class="pattern-cells" role="group" aria-label={`${row.vein} steps`}>
|
||||
{#each row.velocities as velocity, step}
|
||||
<button
|
||||
class="step"
|
||||
class:is-on={velocity > 0}
|
||||
class:is-downbeat={step % 8 === 0}
|
||||
aria-pressed={velocity > 0}
|
||||
aria-label={`${row.vein}, step ${step + 1}`}
|
||||
onclick={() => onstep(rowIndex, step, velocity > 0)}
|
||||
>
|
||||
<span class="step-fill" style={`--velocity:${(velocity / 127).toFixed(3)}`}></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="quarry-side" aria-label="face details">
|
||||
<div class="side-head"><span>faces</span><span>energy</span></div>
|
||||
<p class="side-note">
|
||||
Sixteen seams from the core. Double-click a face to strike it. The loudest face carries
|
||||
rank 01.
|
||||
</p>
|
||||
<div class="face-stats">
|
||||
<div><span>rank</span><b>{face ? String(face.rank + 1).padStart(2, '0') : '—'}</b></div>
|
||||
<div><span>rms</span><b>{face ? face.rms.toFixed(3) : '—'}</b></div>
|
||||
<div><span>length</span><b>{face ? `${(face.end_sec - face.start_sec).toFixed(2)} s` : '—'}</b></div>
|
||||
<div><span>seal</span><b>{core.seal}</b></div>
|
||||
</div>
|
||||
<div class="side-rule"></div>
|
||||
<div class="side-head"><span>next</span></div>
|
||||
<p class="side-note">
|
||||
Select a face, then lay it on the chosen vein. The steps keep their rhythm; the engine
|
||||
swaps the procedural voice for the sample.
|
||||
</p>
|
||||
<div class="side-legend"><span class="legend-key">double-click</span><span>strike a face</span></div>
|
||||
<div class="side-legend"><span class="legend-key">click</span><span>select a step</span></div>
|
||||
<div class="side-legend"><span class="legend-key">space</span><span>play the loop</span></div>
|
||||
<div class="side-footer">
|
||||
<span>cores</span><b>{cores.length}</b>
|
||||
<span>faces</span><b>{cores.length * 16}</b>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,9 +1,11 @@
|
||||
import type {
|
||||
Command,
|
||||
CommandResult,
|
||||
CoreSummary,
|
||||
Event,
|
||||
HelloMessage,
|
||||
MeterFrame,
|
||||
Seal,
|
||||
ServerMessage,
|
||||
State,
|
||||
Status
|
||||
@@ -17,6 +19,7 @@ export interface BridgeHandlers {
|
||||
onMeter: (meter: MeterFrame) => void;
|
||||
onEvent: (event: Event) => void;
|
||||
onError: (message: string) => void;
|
||||
onImported?: (core: CoreSummary) => void;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
@@ -25,11 +28,19 @@ interface Pending {
|
||||
timer: number;
|
||||
}
|
||||
|
||||
interface PendingImport {
|
||||
resolve: (core: CoreSummary) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: number;
|
||||
}
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 5000;
|
||||
const IMPORT_TIMEOUT_MS = 60_000;
|
||||
|
||||
export class Bridge {
|
||||
private ws: WebSocket | null = null;
|
||||
private pending = new Map<number, Pending>();
|
||||
private imports = new Map<number, PendingImport>();
|
||||
private nextId = 0;
|
||||
private closed = false;
|
||||
private reconnectTimer: number | null = null;
|
||||
@@ -81,6 +92,7 @@ export class Bridge {
|
||||
ws.onclose = () => {
|
||||
if (this.ws === ws) this.ws = null;
|
||||
this.rejectAll(new Error('the wire closed'));
|
||||
this.rejectImports(new Error('the wire closed'));
|
||||
this.setStatus('offline');
|
||||
if (!this.closed) this.scheduleReconnect();
|
||||
};
|
||||
@@ -102,6 +114,23 @@ export class Bridge {
|
||||
});
|
||||
}
|
||||
|
||||
importCore(name: string, seal: Seal, data: ArrayBuffer): Promise<CoreSummary> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('the engine is not on the wire'));
|
||||
return;
|
||||
}
|
||||
const id = ++this.nextId;
|
||||
const timer = window.setTimeout(() => {
|
||||
this.imports.delete(id);
|
||||
reject(new Error('the quarry did not answer in time'));
|
||||
}, IMPORT_TIMEOUT_MS);
|
||||
this.imports.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify({ type: 'import_core', id, name, seal }));
|
||||
this.ws.send(data);
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.closed = true;
|
||||
if (this.reconnectTimer !== null) {
|
||||
@@ -111,6 +140,7 @@ export class Bridge {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.rejectAll(new Error('the wire was closed by the surface'));
|
||||
this.rejectImports(new Error('the wire was closed by the surface'));
|
||||
this.setStatus('offline');
|
||||
}
|
||||
|
||||
@@ -160,6 +190,15 @@ export class Bridge {
|
||||
case 'event':
|
||||
this.handlers.onEvent(message.event);
|
||||
break;
|
||||
case 'imported': {
|
||||
const pending = this.imports.get(message.id);
|
||||
if (!pending) break;
|
||||
this.imports.delete(message.id);
|
||||
window.clearTimeout(pending.timer);
|
||||
this.handlers.onImported?.(message.core);
|
||||
pending.resolve(message.core);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (message.id !== null) {
|
||||
const pending = this.pending.get(message.id);
|
||||
@@ -169,6 +208,13 @@ export class Bridge {
|
||||
pending.reject(new Error(message.message));
|
||||
break;
|
||||
}
|
||||
const importing = this.imports.get(message.id);
|
||||
if (importing) {
|
||||
this.imports.delete(message.id);
|
||||
window.clearTimeout(importing.timer);
|
||||
importing.reject(new Error(message.message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.handlers.onError(message.message);
|
||||
break;
|
||||
@@ -196,6 +242,14 @@ export class Bridge {
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private rejectImports(error: Error): void {
|
||||
for (const [, pending] of this.imports) {
|
||||
window.clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.imports.clear();
|
||||
}
|
||||
|
||||
private setStatus(status: Status): void {
|
||||
if (this.status === status) return;
|
||||
this.status = status;
|
||||
|
||||
@@ -30,6 +30,9 @@ export function commandLabel(command: Command): string {
|
||||
case 'strike': return `strike ${command.vein} ${command.velocity.toFixed(2)}`;
|
||||
case 'set_tempo': return `tempo ${fmtTempo(command.bpm)}`;
|
||||
case 'set_vein_gain': return `${command.vein} gain ${command.gain.toFixed(2)}`;
|
||||
case 'strike_face': return `strike ${command.core} f${command.face + 1}`;
|
||||
case 'set_pattern_step': return `step ${command.row + 1}:${command.step + 1} at ${command.velocity}`;
|
||||
case 'set_pattern_face': return `${command.core} f${command.face + 1} on row ${command.row + 1}`;
|
||||
case 'get_snapshot': return 'snapshot';
|
||||
}
|
||||
}
|
||||
|
||||
+41
-3
@@ -1,6 +1,30 @@
|
||||
// Wire types for protocol v1. Kept in step with crates/zhila-protocol.
|
||||
export type VeinId = 'bass' | 'crack' | 'voice' | 'noise' | 'silt';
|
||||
export type Status = 'connecting' | 'live' | 'offline';
|
||||
export type Seal = 'found' | 'taken' | 'gift' | 'loan' | 'unknown';
|
||||
|
||||
export interface FaceSummary {
|
||||
index: number;
|
||||
rank: number;
|
||||
start_sec: number;
|
||||
end_sec: number;
|
||||
rms: number;
|
||||
}
|
||||
|
||||
export interface CoreSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
seal: Seal;
|
||||
sample_rate: number;
|
||||
duration_sec: number;
|
||||
faces: FaceSummary[];
|
||||
peaks: number[];
|
||||
}
|
||||
|
||||
export interface FaceRef {
|
||||
core: string;
|
||||
face: number;
|
||||
}
|
||||
|
||||
export interface AudioInfo {
|
||||
running: boolean;
|
||||
@@ -25,6 +49,7 @@ export interface VeinState {
|
||||
|
||||
export interface PatternRow {
|
||||
vein: VeinId;
|
||||
face: FaceRef | null;
|
||||
velocities: number[];
|
||||
}
|
||||
|
||||
@@ -39,6 +64,7 @@ export interface State {
|
||||
transport: TransportState;
|
||||
veins: VeinState[];
|
||||
pattern: Pattern;
|
||||
cores: CoreSummary[];
|
||||
audio: AudioInfo;
|
||||
journal_len: number;
|
||||
}
|
||||
@@ -47,7 +73,10 @@ export type Event =
|
||||
| { kind: 'transport'; playing: boolean }
|
||||
| { kind: 'tempo'; bpm: number }
|
||||
| { kind: 'vein_gain'; vein: VeinId; gain: number }
|
||||
| { kind: 'struck'; vein: VeinId; velocity: number };
|
||||
| { kind: 'struck'; vein: VeinId; velocity: number }
|
||||
| { kind: 'pattern_step'; row: number; step: number; velocity: number }
|
||||
| { kind: 'pattern_face'; row: number; core: string; face: number }
|
||||
| { kind: 'face_struck'; core: string; face: number; velocity: number };
|
||||
|
||||
export type Command =
|
||||
| { type: 'get_snapshot' }
|
||||
@@ -55,7 +84,10 @@ export type Command =
|
||||
| { type: 'stop' }
|
||||
| { type: 'strike'; vein: VeinId; velocity: number }
|
||||
| { type: 'set_tempo'; bpm: number }
|
||||
| { type: 'set_vein_gain'; vein: VeinId; gain: number };
|
||||
| { type: 'set_vein_gain'; vein: VeinId; gain: number }
|
||||
| { type: 'strike_face'; core: string; face: number; velocity: number }
|
||||
| { type: 'set_pattern_step'; row: number; step: number; velocity: number }
|
||||
| { type: 'set_pattern_face'; row: number; core: string; face: number };
|
||||
|
||||
export type CommandResult =
|
||||
| { kind: 'snapshot'; state: State }
|
||||
@@ -93,7 +125,13 @@ export interface ErrorMessage {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ServerMessage = HelloMessage | SnapshotMessage | EventMessage | ResultMessage | ErrorMessage;
|
||||
export interface ImportedMessage {
|
||||
type: 'imported';
|
||||
id: number;
|
||||
core: CoreSummary;
|
||||
}
|
||||
|
||||
export type ServerMessage = HelloMessage | SnapshotMessage | EventMessage | ResultMessage | ErrorMessage | ImportedMessage;
|
||||
|
||||
export interface MeterFrame {
|
||||
bar: number;
|
||||
|
||||
Reference in New Issue
Block a user