P1a: material import, sixteen faces, sample engine and quarry
This commit is contained in:
@@ -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