diff --git a/index.html b/index.html
index 370f4d1..82e1aa0 100644
--- a/index.html
+++ b/index.html
@@ -268,6 +268,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/API.ts b/src/API.ts
index 61624fc..95c77aa 100644
--- a/src/API.ts
+++ b/src/API.ts
@@ -1,5 +1,5 @@
import { seededRandom } from "zifferjs";
-import { MidiConnection } from "./IO/MidiConnection";
+import { MidiCCEvent, MidiConnection, MidiNoteEvent } from "./IO/MidiConnection";
import { tryEvaluate, evaluateOnce } from "./Evaluator";
import { DrunkWalk } from "./Utils/Drunk";
import { Editor } from "./main";
@@ -57,11 +57,12 @@ export class UserAPI {
public patternCache = new LRUCache({ max: 1000, ttl: 1000 * 60 * 5 });
private errorTimeoutID: number = 0;
private printTimeoutID: number = 0;
-
- MidiConnection: MidiConnection = new MidiConnection();
+ public MidiConnection: MidiConnection;
load: samples;
- constructor(public app: Editor) {}
+ constructor(public app: Editor) {
+ this.MidiConnection = new MidiConnection(this, app.settings);
+ }
_loadUniverseFromInterface = (universe: string) => {
this.app.loadUniverse(universe as string);
@@ -447,6 +448,127 @@ export class UserAPI {
this.MidiConnection.panic();
};
+ public active_note_events = (channel?: number): MidiNoteEvent[]|undefined => {
+ /**
+ * @returns A list of currently active MIDI notes
+ */
+ let events;
+ if(channel) {
+ events = this.MidiConnection.activeNotesFromChannel(channel);
+ } else {
+ events = this.MidiConnection.activeNotes;
+ }
+ if(events.length>0) return events
+ else return undefined;
+ }
+
+ public transmission(): boolean {
+ /**
+ * Returns true if there are active notes
+ */
+ return this.MidiConnection.activeNotes.length > 0;
+ }
+
+ public active_notes = (channel?: number): number[]|undefined => {
+ /**
+ * @returns A list of currently active MIDI notes
+ */
+ const notes = this.active_note_events(channel);
+ if(notes && notes.length > 0) return notes.map((e) => e.note);
+ else return undefined;
+ }
+
+ public kill_active_notes = (): void => {
+ /**
+ * Clears all active notes
+ */
+ this.MidiConnection.activeNotes = [];
+ }
+
+ public sticky_notes = (channel?: number): number[]|undefined => {
+ /**
+ *
+ * @param channel
+ * @returns
+ */
+ let notes;
+ if(channel) notes = this.MidiConnection.stickyNotesFromChannel(channel);
+ else notes = this.MidiConnection.stickyNotes;
+ if(notes.length > 0) return notes.map((e) => e.note);
+ else return undefined;
+ }
+
+ public kill_sticky_notes = (): void => {
+ /**
+ * Clears all sticky notes
+ */
+ this.MidiConnection.stickyNotes = [];
+ }
+
+ public buffer = (channel?: number): boolean => {
+ /**
+ * Return true if there is last note event
+ */
+ if(channel) return this.MidiConnection.findNoteFromBufferInChannel(channel) !== undefined;
+ else return this.MidiConnection.noteInputBuffer.length > 0;
+ }
+
+ public buffer_event = (channel?: number): MidiNoteEvent|undefined => {
+ /**
+ * @returns Returns latest unlistened note event
+ */
+ if(channel) return this.MidiConnection.findNoteFromBufferInChannel(channel);
+ else return this.MidiConnection.noteInputBuffer.shift();
+ }
+
+ public buffer_note = (channel?: number): number|undefined => {
+ /**
+ * @returns Returns latest received note
+ */
+ const note = this.buffer_event(channel);
+ return note ? note.note : undefined;
+ }
+
+ public last_note_event = (channel?: number): MidiNoteEvent|undefined => {
+ /**
+ * @returns Returns last received note
+ */
+ if(channel) return this.MidiConnection.lastNoteInChannel[channel];
+ else return this.MidiConnection.lastNote;
+ }
+
+ public last_note = (channel?: number): number|undefined => {
+ /**
+ * @returns Returns last received note
+ */
+ const note = this.last_note_event(channel);
+ return note ? note.note : undefined;
+ }
+
+ public last_cc = (control: number, channel?: number): number|undefined => {
+ /**
+ * @returns Returns last received cc
+ */
+ if(channel) return this.MidiConnection.lastCCInChannel[channel][control];
+ else return this.MidiConnection.lastCC[control];
+ }
+
+ public has_cc = (channel?: number): boolean => {
+ /**
+ * Return true if there is last cc event
+ */
+ if(channel) return this.MidiConnection.findCCFromBufferInChannel(channel) !== undefined;
+ else return this.MidiConnection.ccInputBuffer.length > 0;
+ }
+
+ public buffer_cc = (channel?: number): MidiCCEvent|undefined => {
+ /**
+ * @returns Returns latest unlistened cc event
+ */
+ if(channel) return this.MidiConnection.findCCFromBufferInChannel(channel);
+ else return this.MidiConnection.ccInputBuffer.shift();
+ }
+
// =============================================================
// Ziffers related functions
// =============================================================
diff --git a/src/AppSettings.ts b/src/AppSettings.ts
index ff6e7f2..c40c628 100644
--- a/src/AppSettings.ts
+++ b/src/AppSettings.ts
@@ -44,6 +44,11 @@ export interface Settings {
* @param line_numbers - Whether or not to show line numbers
* @param time_position - Whether or not to show time position
* @param tips - Whether or not to show tips
+ * @param send_clock - Whether or not to send midi clock
+ * @param midi_channels_scripts - Whether midi input channels fires scripts
+ * @param midi_clock_input - The name of the midi clock input
+ * @param midi_clock_ppqn - The pulses per quarter note for midi clock
+ * @param default_midi_input - The default midi input for incoming messages
*/
vimMode: boolean;
theme: string;
@@ -55,6 +60,11 @@ export interface Settings {
time_position: boolean;
load_demo_songs: boolean;
tips: boolean;
+ send_clock: boolean;
+ midi_channels_scripts: boolean;
+ midi_clock_input: string|undefined;
+ midi_clock_ppqn: number;
+ default_midi_input: string|undefined;
}
export const template_universe = {
@@ -111,7 +121,11 @@ export class AppSettings {
* @param line_numbers - Whether or not to show line numbers
* @param time_position - Whether or not to show time position
* @param tips - Whether or not to show tips
-
+ * @param send_clock - Whether or not to send midi clock
+ * @param midi_channels_scripts - Whether midi input channels fires scripts
+ * @param midi_clock_input - The name of the midi clock input
+ * @param midi_clock_ppqn - The pulses per quarter note for midi clock
+ * @param default_midi_input - The default midi input for incoming messages
*/
public vimMode: boolean = false;
@@ -123,6 +137,11 @@ export class AppSettings {
public line_numbers: boolean = true;
public time_position: boolean = true;
public tips: boolean = true;
+ public send_clock: boolean = false;
+ public midi_channels_scripts: boolean = true;
+ public midi_clock_input: string|undefined = undefined;
+ public default_midi_input: string|undefined = undefined;
+ public midi_clock_ppqn: number = 24;
public load_demo_songs: boolean = true;
constructor() {
@@ -141,6 +160,11 @@ export class AppSettings {
this.line_numbers = settingsFromStorage.line_numbers;
this.time_position = settingsFromStorage.time_position;
this.tips = settingsFromStorage.tips;
+ this.send_clock = settingsFromStorage.send_clock;
+ this.midi_channels_scripts = settingsFromStorage.midi_channels_scripts;
+ this.midi_clock_input = settingsFromStorage.midi_clock_input;
+ this.midi_clock_ppqn = settingsFromStorage.midi_clock_ppqn || 24;
+ this.default_midi_input = settingsFromStorage.default_midi_input;
this.load_demo_songs = settingsFromStorage.load_demo_songs;
} else {
this.universes = template_universes;
@@ -165,6 +189,11 @@ export class AppSettings {
line_numbers: this.line_numbers,
time_position: this.time_position,
tips: this.tips,
+ send_clock: this.send_clock,
+ midi_channels_scripts: this.midi_channels_scripts,
+ midi_clock_input: this.midi_clock_input,
+ midi_clock_ppqn: this.midi_clock_ppqn,
+ default_midi_input: this.default_midi_input,
load_demo_songs: this.load_demo_songs,
};
}
@@ -187,6 +216,11 @@ export class AppSettings {
this.line_numbers = settings.line_numbers;
this.time_position = settings.time_position;
this.tips = settings.tips;
+ this.send_clock = settings.send_clock;
+ this.midi_channels_scripts = settings.midi_channels_scripts;
+ this.midi_clock_input = settings.midi_clock_input;
+ this.midi_clock_ppqn = settings.midi_clock_ppqn;
+ this.default_midi_input = settings.default_midi_input;
this.load_demo_songs = settings.load_demo_songs;
localStorage.setItem("topos", JSON.stringify(this.data));
}
diff --git a/src/IO/MidiConnection.ts b/src/IO/MidiConnection.ts
index d5b1ec7..e0d8d66 100644
--- a/src/IO/MidiConnection.ts
+++ b/src/IO/MidiConnection.ts
@@ -1,3 +1,20 @@
+import { UserAPI } from "../API";
+import { AppSettings } from "../AppSettings";
+
+export type MidiNoteEvent = {
+ note: number;
+ velocity: number;
+ channel: number;
+ timestamp: number;
+}
+
+export type MidiCCEvent = {
+ control: number;
+ value: number;
+ channel: number;
+ timestamp: number;
+}
+
export class MidiConnection {
/**
* Wrapper class for Web MIDI API. Provides methods for sending MIDI messages.
@@ -9,12 +26,45 @@ export class MidiConnection {
* @param scheduledNotes - Object containing scheduled notes. Keys are note numbers and values are timeout IDs.
*/
+ /* Midi output */
+ private api: UserAPI;
+ private settings: AppSettings;
private midiAccess: MIDIAccess | null = null;
- public midiOutputs: MIDIOutput[] = [];
+ public midiOutputs: MIDIOutput[] = [];
private currentOutputIndex: number = 0;
private scheduledNotes: { [noteNumber: number]: number } = {}; // { noteNumber: timeoutId }
+
+ /* Midi input */
+ public midiInputs: MIDIInput[] = [];
+ private currentInputIndex: number|undefined = undefined;
+ public bufferLength: number = 512; // 32*16
+ public noteInputBuffer: MidiNoteEvent[] = [];
+ public ccInputBuffer: MidiCCEvent[] = [];
+ public activeNotes: MidiNoteEvent[] = [];
+ public stickyNotes: MidiNoteEvent[] = [];
+ public lastNote: MidiNoteEvent|undefined = undefined;
+ public lastCC: { [control: number]: number } = {};
+ public lastNoteInChannel: { [channel: number]: MidiNoteEvent } = {};
+ public lastCCInChannel: { [channel: number]: { [control: number]: number } } = {};
- constructor() {
+ /* MIDI clock stuff */
+ private midiClockInputIndex: number|undefined = undefined;
+ private midiClockInput?: MIDIInput|undefined = undefined;
+ private lastTimestamp: number = 0;
+ private midiClockDelta: number = 0;
+ private lastBPM: number;
+ private roundedBPM: number = 0;
+ private clockBuffer: number[] = [];
+ private clockBufferLength = 24;
+ private clockTicks = 0;
+ private clockErrorCount = 0;
+ private skipOnError = 0;
+
+ constructor(api: UserAPI, settings: AppSettings) {
+ this.api = api;
+ this.settings = settings;
+ this.lastBPM = api.bpm();
+ this.roundedBPM = this.lastBPM;
this.initializeMidiAccess();
}
@@ -31,6 +81,12 @@ export class MidiConnection {
console.warn("No MIDI outputs available.");
this.currentOutputIndex = -1;
}
+ this.midiInputs = Array.from(this.midiAccess.inputs.values());
+ if (this.midiInputs.length === 0) {
+ console.warn("No MIDI inputs available.");
+ } else {
+ this.updateInputSelects();
+ }
} catch (error) {
console.error("Failed to initialize MIDI:", error);
}
@@ -56,21 +112,25 @@ export class MidiConnection {
public sendStartMessage(): void {
/**
- * Sends a MIDI Start message to the currently selected MIDI output.
+ * Sends a MIDI Start message to the currently selected MIDI output and MIDI clock is not used
*/
- const output = this.midiOutputs[this.currentOutputIndex];
- if (output) {
- output.send([0xfa]); // Send MIDI Start message
+ if(!this.midiClockInput) {
+ const output = this.midiOutputs[this.currentOutputIndex];
+ if (output) {
+ output.send([0xfa]); // Send MIDI Start message
+ }
}
}
public sendStopMessage(): void {
/**
- * Sends a MIDI Stop message to the currently selected MIDI output.
+ * Sends a MIDI Stop message to the currently selected MIDI output and MIDI clock is not used
*/
- const output = this.midiOutputs[this.currentOutputIndex];
- if (output) {
- output.send([0xfc]); // Send MIDI Stop message
+ if(!this.midiClockInput) {
+ const output = this.midiOutputs[this.currentOutputIndex];
+ if (output) {
+ output.send([0xfc]); // Send MIDI Stop message
+ }
}
}
@@ -92,13 +152,344 @@ export class MidiConnection {
}
}
+ public setMidiClock(inputName: string|number): void {
+ /**
+ * Sets the MIDI input to use for MIDI clock messages.
+ *
+ * @param inputName Name of the MIDI input to use for MIDI clock messages
+ */
+ const inputIndex = this.getMidiInputIndex(inputName);
+ if (inputIndex !== -1) {
+ this.midiClockInputIndex = inputIndex;
+ this.midiClockInput = this.midiInputs[inputIndex];
+ this.registerMidiInputListener(inputIndex);
+ } else {
+ this.midiClockInput = undefined;
+ }
+ }
+
+ public updateInputSelects() {
+ /**
+ * Updates the MIDI clock input select element with the available MIDI inputs.
+ */
+ if(this.midiInputs.length > 0) {
+ const midiClockSelect = document.getElementById("midi-clock-input") as HTMLSelectElement;
+ const midiInputSelect = document.getElementById("default-midi-input") as HTMLSelectElement;
+
+ midiClockSelect.innerHTML = "";
+ midiInputSelect.innerHTML = "";
+
+ // Set Midi clock as Internal by default
+ const defaultOption = document.createElement("option");
+ defaultOption.value = "-1";
+ defaultOption.text = "Internal";
+ midiClockSelect.appendChild(defaultOption);
+
+ // Set default input as None by default
+ const defaultInputOption = document.createElement("option");
+ defaultInputOption.value = "-1";
+ defaultInputOption.text = "None";
+ midiInputSelect.appendChild(defaultInputOption);
+
+ // Add MIDI inputs to clock select input and default midi input
+ this.midiInputs.forEach((input, index) => {
+ const option = document.createElement("option");
+ option.value = index.toString();
+ option.text = input.name || index.toString();
+ midiClockSelect.appendChild(option);
+ midiInputSelect.appendChild(option.cloneNode(true));
+ });
+
+ if(this.settings.midi_clock_input) {
+ const clockMidiInputIndex = this.getMidiInputIndex(this.settings.midi_clock_input);
+ midiClockSelect.value = clockMidiInputIndex.toString();
+ if(clockMidiInputIndex > 0) {
+ this.midiClockInput = this.midiInputs[clockMidiInputIndex];
+ this.registerMidiInputListener(clockMidiInputIndex);
+ }
+ } else {
+ midiClockSelect.value = "-1";
+ }
+
+ if(this.settings.default_midi_input) {
+ const defaultMidiInputIndex = this.getMidiInputIndex(this.settings.default_midi_input);
+ midiInputSelect.value = defaultMidiInputIndex.toString();
+ if(defaultMidiInputIndex > 0) {
+ this.currentInputIndex = defaultMidiInputIndex;
+ this.registerMidiInputListener(defaultMidiInputIndex);
+ }
+ } else {
+ midiInputSelect.value = "-1";
+ }
+
+ // Add midi clock listener
+ midiClockSelect.addEventListener("change", (event) => {
+ const value = (event.target as HTMLSelectElement).value;
+ if(value === "-1") {
+ if(this.midiClockInput && this.midiClockInputIndex!=this.currentInputIndex) this.midiClockInput.onmidimessage = null;
+ this.midiClockInput = undefined;
+ this.settings.midi_clock_input = undefined;
+ } else {
+ const clockInputIndex = parseInt(value);
+ this.midiClockInputIndex = clockInputIndex;
+ if(this.midiClockInput && this.midiClockInputIndex!=this.currentInputIndex) this.midiClockInput.onmidimessage = null;
+ this.midiClockInput = this.midiInputs[clockInputIndex];
+ this.registerMidiInputListener(clockInputIndex);
+ this.settings.midi_clock_input = this.midiClockInput.name || undefined;
+ }
+ });
+
+ // Add mini input listener
+ midiInputSelect.addEventListener("change", (event) => {
+ const value = (event.target as HTMLSelectElement).value;
+ if(value === "-1") {
+ if(this.currentInputIndex && this.currentInputIndex!=this.midiClockInputIndex) this.unregisterMidiInputListener(this.currentInputIndex);
+ this.currentInputIndex = undefined;
+ this.settings.default_midi_input = undefined;
+ } else {
+ if(this.currentInputIndex && this.currentInputIndex!=this.midiClockInputIndex) this.unregisterMidiInputListener(this.currentInputIndex);
+ this.currentInputIndex = parseInt(value);
+ this.registerMidiInputListener(this.currentInputIndex);
+ this.settings.default_midi_input = this.midiInputs[this.currentInputIndex].name || undefined;
+ }
+ });
+
+ }
+ }
+
+ public registerMidiInputListener(inputIndex: number): void {
+ /**
+ * Register midi input listener and store last value as global parameter named channel_{number}
+ */
+ if(inputIndex !== undefined) {
+ const input = this.midiInputs[inputIndex];
+ if(input && !input.onmidimessage) {
+ input.onmidimessage = (event: Event) => {
+ const message = event as MIDIMessageEvent;
+ /* MIDI CLOCK */
+ if(input.name === this.settings.midi_clock_input) {
+ if (message.data[0] === 0xf8) {
+ if(this.skipOnError>0) {
+ this.skipOnError -= 1;
+ } else {
+ this.onMidiClock(event.timeStamp);
+ }
+ } else if(message.data[0] === 0xfa) {
+ console.log("MIDI start received");
+ this.api.stop();
+ this.api.play();
+ } else if(message.data[0] === 0xfc) {
+ console.log("MIDI stop received");
+ this.api.pause();
+ } else if(message.data[0] === 0xfb) {
+ console.log("MIDI continue received");
+ this.api.play();
+ } else if(message.data[0] === 0xfe) {
+ console.log("MIDI active sensing received");
+ }
+ }
+ /* DEFAULT MIDI INPUT */
+ if(input.name === this.settings.default_midi_input) {
+
+ // If message is one of note ons
+ if(message.data[0] >= 0x90 && message.data[0] <= 0x9F) {
+ const channel = message.data[0] - 0x90 + 1;
+ const note = message.data[1];
+ const velocity = message.data[2];
+
+ this.lastNote = {note, velocity, channel, timestamp: event.timeStamp};
+ this.lastNoteInChannel[channel] = {note, velocity, channel, timestamp: event.timeStamp};
+
+ if(this.settings.midi_channels_scripts) this.api.script(channel);
+
+ //console.log(`NOTE: ${note} VELOCITY: ${velocity} CHANNEL: ${channel}`);
+
+ this.pushToMidiInputBuffer({note, velocity, channel, timestamp: event.timeStamp});
+ this.activeNotes.push({note, velocity, channel, timestamp: event.timeStamp});
+
+ const sticky = this.removeFromStickyNotes(note, channel);
+ if(!sticky) this.stickyNotes.push({note, velocity, channel, timestamp: event.timeStamp});
+ }
+
+ // If note off
+ if(message.data[0] >= 0x80 && message.data[0] <= 0x8F) {
+ const channel = message.data[0] - 0x80 + 1;
+ const note = message.data[1];
+ this.removeFromActiveNotes(note, channel);
+ }
+
+ // If message is one of CCs
+ if(message.data[0]>=0xB0 && message.data[0]<=0xBF) {
+ const channel = message.data[0] - 0xB0 + 1;
+ const control = message.data[1];
+ const value = message.data[2];
+
+ this.lastCC[control] = value;
+ this.lastCCInChannel[channel][control] = value;
+
+
+ //console.log(`CC: ${control} VALUE: ${value} CHANNEL: ${channel}`);
+
+ this.pushToMidiCCBuffer({control, value, channel, timestamp: event.timeStamp});
+
+ }
+
+
+ }
+ }
+ }
+ }
+ }
+
+ /* Methods for handling active midi notes */
+
+ public removeFromActiveNotes(note: number, channel: number): void {
+ const index = this.activeNotes.findIndex((e) => e.note===note && e.channel===channel);
+ if(index>=0) this.activeNotes.splice(index, 1);
+ }
+
+ public removeFromStickyNotes(note: number, channel: number): boolean {
+ const index = this.stickyNotes.findIndex((e) => e.note===note && e.channel===channel);
+ if(index>=0) {
+ this.stickyNotes.splice(index, 1);
+ return true;
+ } else { return false; }
+ }
+
+ public stickyNotesFromChannel(channel: number): MidiNoteEvent[] {
+ return this.stickyNotes.filter((e) => e.channel===channel);
+ }
+
+ public activeNotesFromChannel(channel: number): MidiNoteEvent[] {
+ return this.activeNotes.filter((e) => e.channel===channel);
+ }
+
+ public killActiveNotes(): void {
+ this.activeNotes = [];
+ }
+
+ public killActiveNotesFromChannel(channel: number): void {
+ this.activeNotes = this.activeNotes.filter((e) => e.channel!==channel);
+ }
+
+ /* Methods for handling midi input buffers */
+
+ private pushToMidiInputBuffer(event: MidiNoteEvent): void {
+ this.noteInputBuffer.push(event);
+ if(this.noteInputBuffer.length>this.bufferLength) {
+ this.noteInputBuffer.shift();
+ }
+ }
+
+ private pushToMidiCCBuffer(event: MidiCCEvent): void {
+ this.ccInputBuffer.push(event);
+ if(this.ccInputBuffer.length>this.bufferLength) {
+ this.ccInputBuffer.shift();
+ }
+ }
+
+ public findNoteFromBufferInChannel(channel: number|undefined) {
+ const index = this.noteInputBuffer.findIndex((e) => e.channel===channel);
+ if(index>=0) {
+ const event = this.noteInputBuffer[index];
+ this.noteInputBuffer.splice(index, 1);
+ return event;
+ } else {
+ return undefined;
+ }
+ }
+
+ public findCCFromBufferInChannel(channel: number|undefined) {
+ const index = this.ccInputBuffer.findIndex((e) => e.channel===channel);
+ if(index>=0) {
+ const event = this.ccInputBuffer[index];
+ this.ccInputBuffer.splice(index, 1);
+ return event;
+ } else {
+ return undefined;
+ }
+ }
+
+ public unregisterMidiInputListener(inputIndex: number): void {
+ /**
+ * Unregister midi input listener
+ */
+ if(inputIndex !== undefined) {
+ const input = this.midiInputs[inputIndex];
+ if(input) {
+ input.onmidimessage = null;
+ }
+ }
+ }
+
+
+ public onMidiClock(timestamp: number): void {
+ /**
+ * Called when a MIDI clock message is received.
+ */
+
+ this.clockTicks += 1;
+
+ if(this.lastTimestamp > 0) {
+
+ if(this.lastTimestamp===timestamp) {
+ // This is error handling for odd MIDI clock messages with the same timestamp
+ this.clockErrorCount+=1;
+ } else {
+ if(this.clockErrorCount>0) {
+ console.log("Timestamp error count: ", this.clockErrorCount);
+ console.log("Current timestamp: ", timestamp);
+ console.log("Last timestamp: ", this.lastTimestamp);
+ console.log("Last delta: ", this.midiClockDelta);
+ console.log("Current delta: ", timestamp - this.lastTimestamp);
+ console.log("BPMs", this.clockBuffer);
+ this.clockErrorCount = 0;
+ /* I dont know why this happens. But when it does, deltas for the following messages are off.
+ So skipping ~ quarted of clock resolution usually helps */
+ this.skipOnError = this.settings.midi_clock_ppqn/4;
+ timestamp = 0; // timestamp 0 == lastTimestamp 0
+ } else {
+
+ this.midiClockDelta = timestamp - this.lastTimestamp;
+ this.lastBPM = 60 * (1000 / this.midiClockDelta / this.settings.midi_clock_ppqn);
+
+ this.clockBuffer.push(this.lastBPM);
+ if(this.clockBuffer.length>this.clockBufferLength) this.clockBuffer.shift();
+
+ const estimatedBPM = this.estimatedBPM();
+ if(estimatedBPM !== this.roundedBPM) {
+ console.log("Esimated BPM: ", estimatedBPM);
+ this.api.bpm(estimatedBPM);
+ this.roundedBPM = estimatedBPM;
+ }
+
+ }
+ }
+ }
+
+ this.lastTimestamp = timestamp;
+
+ }
+
+ public estimatedBPM(): number {
+ /**
+ * Returns the estimated BPM based on the last 24 MIDI clock messages.
+ *
+ * @returns Estimated BPM
+ */
+ const sum = this.clockBuffer.reduce((a, b) => a + b);
+ return Math.round(sum / this.clockBuffer.length);
+ }
+
public sendMidiClock(): void {
/**
* Sends a single MIDI clock message to the currently selected MIDI output.
*/
- const output = this.midiOutputs[this.currentOutputIndex];
- if (output) {
- output.send([0xf8]); // Send a single MIDI clock message
+ if(!this.midiClockInput) {
+ const output = this.midiOutputs[this.currentOutputIndex];
+ if (output) {
+ output.send([0xf8]); // Send a single MIDI clock message
+ }
}
}
@@ -148,6 +539,36 @@ export class MidiConnection {
}
}
+ public getMidiInputIndex(input: string | number): number {
+ /**
+ * Returns the index of the MIDI input with the specified name.
+ *
+ * @param input Name or index of the MIDI input
+ * @returns Index of the new MIDI input or -1 if not valid
+ *
+ */
+ if (typeof input === "number") {
+ if (input < 0 || input >= this.midiInputs.length) {
+ console.error(
+ `Invalid MIDI input index. Index must be in the range 0-${
+ this.midiInputs.length - 1
+ }.`
+ );
+ return -1;
+ } else {
+ return input;
+ }
+ } else {
+ const index = this.midiInputs.findIndex((o) => o.name === input);
+ if (index !== -1) {
+ return index;
+ } else {
+ console.error(`MIDI input "${input}" not found.`);
+ return -1;
+ }
+ }
+ }
+
public listMidiOutputs(): string {
/**
* Lists all available MIDI outputs to the console.
diff --git a/src/TransportNode.js b/src/TransportNode.js
index e9752b6..4af3380 100644
--- a/src/TransportNode.js
+++ b/src/TransportNode.js
@@ -13,7 +13,7 @@ export class TransportNode extends AudioWorkletNode {
/** @type {(this: MessagePort, ev: MessageEvent) => any} */
handleMessage = (message) => {
if (message.data && message.data.type === "bang") {
- this.app.api.MidiConnection.sendMidiClock();
+ if(this.app.settings.send_clock) this.app.api.MidiConnection.sendMidiClock();
this.app.clock.tick++;
const futureTimeStamp = this.app.clock.convertTicksToTimeposition(
this.app.clock.tick
diff --git a/src/classes/ZPlayer.ts b/src/classes/ZPlayer.ts
index 28f345b..dce79a8 100644
--- a/src/classes/ZPlayer.ts
+++ b/src/classes/ZPlayer.ts
@@ -99,6 +99,7 @@ export class Player extends Event {
this.index = 0;
this.waitTime = 0;
this.skipIndex = 0;
+ this.ziffers.index = 0;
}
const patternIsStarting = (this.notStarted() &&
diff --git a/src/documentation/interaction.ts b/src/documentation/interaction.ts
index 89123ca..b1413e7 100644
--- a/src/documentation/interaction.ts
+++ b/src/documentation/interaction.ts
@@ -9,6 +9,72 @@ export const interaction = (application: Editor): string => {
Topos can interact with the physical world or react to events coming from outside the system (_MIDI_, physical control, etc).
+## Midi input
+
+Topos can use MIDI input to estimate the bpm from incoming clock messages and to control sounds with incoming note and cc messages. Sending MIDI messages to Topos is like sending messages to far away universe; you can't expect a quick response, but the messages will be received and processed eventually.
+
+Midi input can be enabled in the settings panel. Once you have done that, you can use the following functions to control values. All methods have channel parameter as optional value to receive only notes from a certain channel:
+* active_notes(channel?: number): returns array of the active notes / pressed keys as an array of MIDI note numbers (0-127). Returns undefined if no notes are active.
+* sticky_notes(channel?: number): returns array of the last pressed keys as an array of MIDI note numbers (0-127). Notes are added and removed from the list with the "Note on"-event. Returns undefined if no keys have been pressed.
+* last_note(channel?: number): returns the last note that has been received. Returns undefined if no notes have been received.
+* buffer(): return true if there are notes in the buffer.
+* buffer_note(channel?: number): returns last unread note that has been received. Note is fetched and removed from start of the buffer once this is called. Returns undefined if no notes have been received.
+
+${makeExample(
+ "Play active notes as chords",
+ `
+ beat(1) && active_notes() && sound('sine').chord(active_notes()).out()
+ `,
+ true
+)}
+
+${makeExample(
+ "Play active notes as arpeggios",
+ `
+ beat(0.25) && active_notes() && sound('juno').note(
+ active_notes().beat(0.5)+[12,24].beat(0.25)
+ ).cutoff(300 + usine(1/4) * 2000).out()
+ `,
+ true
+)}
+
+${makeExample(
+ "Play continous arpeggio with sticky notes",
+ `
+ beat(0.25) && sticky_notes() && sound('arp')
+ .note(sticky_notes().palindrome().beat(0.25)).out()
+ `,
+ true
+)}
+
+${makeExample(
+ "Play last note",
+ `
+ beat(0.5) && last_note() :: sound('sawtooth').note(last_note())
+ .vib([1, 3, 5].beat(1))
+ .vibmod([1,3,2,4].beat(2)).out()
+ `,
+ true
+)}
+
+${makeExample(
+ "Play buffered note",
+ `
+ beat(1) && buffer() && sound('sine').note(buffer_note()).out()
+ `,
+ true
+)}
+
+
+### Run scripts with mini note messages and channels
+
+Midi note messages with channels can also be used to run scripts. This can be enabled in the settings panel by setting "Route channels to scripts".
+
+### Midi clock
+
+Topos can controlled from external hadware or software using Midi clock messages. To enable this feature, you need to connect a MIDI input as Midi Clock in the settings panel. Once you have done that, Topos will listen to incoming clock messages and will use them to estimate the current bpm. Topos will also listen to Start, Stop and Continue messages to start and stop the evaluation. Different MIDI devices can send clock at different resolution, define Clock PPQN in settings to match the resolution of your device.
+
+## Mouse input
<<<<<<< Updated upstream
## Mouse
diff --git a/src/main.ts b/src/main.ts
index 442f45c..a70eb15 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -100,7 +100,7 @@ export class Editor {
exampleCounter: number = 0;
exampleIsPlaying: boolean = false;
- settings = new AppSettings();
+ settings: AppSettings = new AppSettings();
editorExtensions: Extension[] = [];
userPlugins: Extension[] = [];
state: EditorState;
@@ -232,6 +232,18 @@ export class Editor {
"show-tips"
) as HTMLInputElement;
+ midi_clock_checkbox: HTMLInputElement = document.getElementById(
+ "send-midi-clock"
+ ) as HTMLInputElement;
+
+ midi_channels_scripts: HTMLInputElement = document.getElementById(
+ "midi-channels-scripts"
+ ) as HTMLInputElement;
+
+ midi_clock_ppqn: HTMLSelectElement = document.getElementById(
+ "midi-clock-ppqn-input"
+ ) as HTMLSelectElement;
+
// Loading demo songs when starting
load_demo_songs: HTMLInputElement = document.getElementById(
"load-demo-songs"
@@ -276,6 +288,9 @@ export class Editor {
this.line_numbers_checkbox.checked = this.settings.line_numbers;
this.time_position_checkbox.checked = this.settings.time_position;
this.tips_checkbox.checked = this.settings.tips;
+ this.midi_clock_checkbox.checked = this.settings.send_clock;
+ this.midi_channels_scripts.checked = this.settings.midi_channels_scripts;
+ this.midi_clock_ppqn.value = this.settings.midi_clock_ppqn.toString();
if (!this.settings.time_position) {
document.getElementById("timeviewer")!.classList.add("hidden");
}
@@ -720,6 +735,9 @@ export class Editor {
this.line_numbers_checkbox.checked = this.settings.line_numbers;
this.time_position_checkbox.checked = this.settings.time_position;
this.tips_checkbox.checked = this.settings.tips;
+ this.midi_clock_checkbox.checked = this.settings.send_clock;
+ this.midi_channels_scripts.checked = this.settings.midi_channels_scripts;
+ this.midi_clock_ppqn.value = this.settings.midi_clock_ppqn.toString();
this.load_demo_songs.checked = this.settings.load_demo_songs;
this.vim_mode_checkbox.checked = this.settings.vimMode;
@@ -800,6 +818,21 @@ export class Editor {
});
});
+ this.midi_clock_checkbox.addEventListener("change", () => {
+ let checked = this.midi_clock_checkbox.checked ? true : false;
+ this.settings.send_clock = checked;
+ });
+
+ this.midi_channels_scripts.addEventListener("change", () => {
+ let checked = this.midi_channels_scripts.checked ? true : false;
+ this.settings.midi_channels_scripts = checked;
+ });
+
+ this.midi_clock_ppqn.addEventListener("change", () => {
+ let value = parseInt(this.midi_clock_ppqn.value);
+ this.settings.midi_clock_ppqn = value;
+ });
+
this.load_demo_songs.addEventListener("change", () => {
let checked = this.load_demo_songs.checked ? true : false;
this.settings.load_demo_songs = checked;