Skip to content

Commit 3bb5639

Browse files
authored
Add event highlighting to the TidalCycles package (#213)
* Add event highlighting to the TidalCycles package * Delete styles/tidal.less * Restore tidal.less file * Fix: first line will be considered now and is not producing errors * Add condition to ensure, that meta data will only be added, when the feature is enabled. * Refactor osc handling * Introduce eventIds * Chore: rename row to eventId where it fits * Add multiple text editor support * Fix PR issues * Refactor osc server transformer logic * chore: move highlight feature enable check to main * chore: optimize event highlighting comments * chore: remove uneccessary imports * Add LineProcessor helper class * Replace olda tokenizer logic with new one * Remove osc style changes * Add OSC server tests * Extract regex statements * Remove osc css style change in README
1 parent fbcbbe4 commit 3bb5639

File tree

12 files changed

+603
-22
lines changed

12 files changed

+603
-22
lines changed

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,50 @@ Filter all the console log and show only errors
8888
#### Only Log Last Message
8989
Filter all the console old logs and show only the last one
9090

91+
### Event Highlighting
92+
93+
The event highlighting allows to visualize the active events within a mini notation pattern. This means, that only events within quotation marks will be considered.
94+
95+
#### TidalCycles configuration
96+
97+
TidalCycles needs to be configured to send editor highlight events. This is usually done by modifying the `BootTidal.hs` file and adding an editor highlight target. Here is a working example:
98+
99+
```haskell
100+
let editorTarget = Target {oName = "editor", oAddress = "127.0.0.1", oPort = 6013, oLatency = 0.02, oSchedule = Pre BundleStamp, oWindow = Nothing, oHandshake = False, oBusPort = Nothing }
101+
let editorShape = OSCContext "/editor/highlights"
102+
103+
tidal <- startStream (defaultConfig {cFrameTimespan = 1/50}) [(superdirtTarget {oLatency = 0.2}, [superdirtShape]), (editorTarget, [editorShape])]
104+
```
105+
106+
The path to the `BootTidal.hs` file can be found in the TidalCycles output console after TidalCycles has been booted in the editor.
107+
108+
#### Framerate
109+
110+
The event highlight animation is in relation to the refresh rate of the users display and the `cFrameTimespan` value of TidalCycles. This means, that the animation fps needs to be smaller then the denominator of the `cFrameTimespan` value. However a good value is somehow between `20 fps` and `30 fps`.
111+
112+
#### Custom Styles
113+
114+
It is possible to customize the event highlighting css styles. For this you can add the css classes under `Pulsar -> Stylesheet...`.
115+
116+
There is a default style, that can be overriden like this in the global stylesheet:
117+
118+
```css
119+
.event-highlight {
120+
outline: 2px solid orange;
121+
outline-offset: 0px;
122+
}
123+
```
124+
125+
And it is possible to override the styles for every individual stream like this:
126+
127+
```css
128+
.event-highlight-2 {
129+
background-color: white;
130+
}
131+
```
132+
133+
The pattern of the css class is `.event-highlight-[streamID]`.
134+
91135
### Osc Eval
92136
It's possibile to evaluate tidal code with OSC messages.
93137

lib/BootTidal.hs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import Sound.Tidal.Context
66
import System.IO (hSetEncoding, stdout, utf8)
77
hSetEncoding stdout utf8
88

9-
tidal <- startTidal (superdirtTarget {oLatency = 0.05, oAddress = "127.0.0.1", oPort = 57120}) (defaultConfig {cVerbose = True, cFrameTimespan = 1/20})
9+
let editorTarget = Target {oName = "editor", oAddress = "127.0.0.1", oPort = 6013, oLatency = 0.02, oSchedule = Pre BundleStamp, oWindow = Nothing, oHandshake = False, oBusPort = Nothing }
10+
let editorShape = OSCContext "/editor/highlights"
11+
12+
tidal <- startStream (defaultConfig {cFrameTimespan = 1/50}) [(superdirtTarget {oLatency = 0.2}, [superdirtShape]), (editorTarget, [editorShape])]
1013

1114
:{
1215
let only = (hush >>)

lib/config.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,37 @@ export default {
125125
}
126126
}
127127
}
128+
},
129+
'eventHighlighting': {
130+
type: 'object',
131+
title: 'Event Highlighting',
132+
description: '',
133+
properties: {
134+
'enable': {
135+
type: 'boolean',
136+
default: false,
137+
title: 'enable',
138+
description: 'Check to enable event highlighting.',
139+
order: 95
140+
},
141+
'fps': {
142+
type: 'number',
143+
default: 30,
144+
description: 'Reduce this value if the event highlighting flickers. Higher values make the result smoother, but require more computational capacity and it is limited by the cFrameTimespan value configured in TidalCycles. It is recommended to use a value between 20 and 30 fps.',
145+
order: 100,
146+
},
147+
'ip': {
148+
type: 'string',
149+
default: "127.0.0.1",
150+
description: 'OSC IP where you expect to receive TidalCycles highlight events.',
151+
order: 105,
152+
},
153+
'port': {
154+
type: 'number',
155+
default: 6013,
156+
description: 'OSC Port where you expect to receive TidalCycles highlight events.',
157+
order: 110,
158+
}
159+
}
128160
}
129161
}

lib/editors.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ export class Editors extends EventEmitter {
194194
return currentEditor()
195195
}
196196

197+
editorsGroupedByTextBufferId() {
198+
return atom.workspace.getTextEditors().reduce((groups, editor) => {
199+
const bufferId = editor.getBuffer().id;
200+
if (!groups[bufferId]) {
201+
groups[bufferId] = [];
202+
}
203+
groups[bufferId].push(editor);
204+
return groups;
205+
}, {});
206+
}
197207
}
198208

199209
let currentEditor = () => atom.workspace.getActiveTextEditor()

lib/event-highlighter.js

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
'use babel'
2+
3+
import OscServer from './osc-server';
4+
import LineProcessor from './line-processor';
5+
6+
const CLASS = Object.freeze({
7+
base: "event-highlight",
8+
idPrefix: "event-highlight-",
9+
});
10+
11+
/** Helper that creates (or returns existing) nested maps. */
12+
function ensureNestedMap(root, key) {
13+
if (!root.has(key)) root.set(key, new Map());
14+
return root.get(key);
15+
}
16+
17+
/**
18+
* Responsible for receiving OSC events describing source‑code positions and
19+
* visually highlighting those regions in Pulsar TextEditor.
20+
*/
21+
export default class EventHighlighter {
22+
23+
constructor(consoleView, editors) {
24+
this.consoleView = consoleView;
25+
26+
this.editors = editors;
27+
28+
// Data‑structures -------------------------------------------------------
29+
this.markers = new Map(); // textbuffer.id → row → col → Marker
30+
this.highlights = new Map(); // eventId → texteditor.id → col → Marker
31+
this.filteredMessages = new Map(); // eventId → event
32+
this.receivedThisFrame = new Map(); // eventId → event
33+
this.addedThisFrame = new Set(); // Set<event>
34+
this.eventIds = []; // [{textbufferId, row}]
35+
36+
// Animation state -------------------------------------------------------
37+
this.then = 0; // time at previous frame
38+
39+
// Bind instance methods used as callbacks -----------------------------
40+
this.animate = this.animate.bind(this);
41+
}
42+
43+
// ----------------------------------------------------------------------
44+
// Public API
45+
// ----------------------------------------------------------------------
46+
47+
/** Initialise OSC listeners & start animation loop */
48+
init() {
49+
this.#installBaseHighlightStyle();
50+
51+
// Kick‑off animation loop
52+
this.then = window.performance.now();
53+
requestAnimationFrame(this.animate);
54+
}
55+
56+
/** Clean‑up resources when package is deactivated */
57+
destroy() {
58+
try {
59+
this.server?.destroy();
60+
} catch (e) {
61+
this.consoleView.logStderr(`OSC server encountered an error while destroying:\n${e}`);
62+
}
63+
}
64+
65+
/**
66+
* Inject additional metadata into a TidalCycles mini‑notation line.
67+
* Returns transformed line (string).
68+
*/
69+
addMetadata(line, lineNumber) {
70+
this.#createPositionMarkers(line, lineNumber);
71+
72+
// Replace quoted segments with `(deltaContext …)` unless they belong to
73+
// known control‑pattern contexts.
74+
return line.replace(LineProcessor.controlPatternsRegex(), (match, content, offset) => {
75+
const before = line.slice(0, offset);
76+
if (LineProcessor.exceptedFunctionPatterns().test(before)) return match; // ignore control‑patterns
77+
78+
this.eventIds.push({
79+
rowStart: lineNumber,
80+
bufferId: this.editors.currentEditor().buffer.id
81+
});
82+
83+
return `(deltaContext ${offset} ${this.eventIds.length - 1} "${content}")`;
84+
});
85+
}
86+
87+
/** Handle OSC message describing a highlight event */
88+
oscHighlightSubscriber() {
89+
return (args: {}): void => {
90+
const message = OscServer.asDictionary(this.highlightTransformer(args));
91+
this.#queueEvent(message);
92+
}
93+
}
94+
95+
highlightTransformer(args) {
96+
const result = [
97+
{value: "id"}, args[0],
98+
{value: "duration"}, args[1],
99+
{value: "cycle"}, args[2],
100+
{value: "colStart"}, args[3],
101+
{value: "eventId"}, {value: args[4].value - 1},
102+
{value: "colEnd"}, args[5],
103+
];
104+
105+
return result;
106+
}
107+
108+
/** requestAnimationFrame callback */
109+
animate(now) {
110+
const elapsed = now - this.then;
111+
const configFPS = atom.config.get('tidalcycles.eventHighlighting.fps');
112+
const fpsInterval = 1000 / configFPS;
113+
114+
if (elapsed >= fpsInterval) {
115+
this.then = now - (elapsed % fpsInterval);
116+
117+
// Add newly‑received highlights -------------------------------------
118+
this.addedThisFrame.forEach((evt) => {
119+
this.#addHighlight(evt);
120+
});
121+
122+
// Remove highlights no longer present ------------------------------
123+
const { updated, removed } = this.#diffEventMaps(
124+
this.filteredMessages,
125+
this.receivedThisFrame,
126+
);
127+
this.filteredMessages = updated;
128+
removed.forEach((evt) => this.#removeHighlight(evt));
129+
130+
// Reset per‑frame collections --------------------------------------
131+
this.receivedThisFrame.clear();
132+
this.addedThisFrame.clear();
133+
}
134+
135+
requestAnimationFrame(this.animate);
136+
}
137+
138+
// ----------------------------------------------------------------------
139+
// Private helpers
140+
// ----------------------------------------------------------------------
141+
142+
/** Injects the base CSS rule used for all highlights */
143+
#installBaseHighlightStyle() {
144+
atom.styles.addStyleSheet(`
145+
.${CLASS.base} {
146+
outline: 2px solid red;
147+
outline-offset: 0;
148+
}
149+
`);
150+
}
151+
152+
/** Store events until the next animation frame */
153+
#queueEvent(event) {
154+
const eventMap = ensureNestedMap(this.filteredMessages, event.eventId);
155+
const recvMap = ensureNestedMap(this.receivedThisFrame, event.eventId);
156+
157+
if (!eventMap.has(event.colStart)) eventMap.set(event.colStart, event);
158+
if (!recvMap.has(event.colStart)) {
159+
this.addedThisFrame.add(event);
160+
recvMap.set(event.colStart, event);
161+
}
162+
}
163+
164+
// Highlight management
165+
#addHighlight({ id, colStart, eventId }) {
166+
const bufferId = this.eventIds[eventId].bufferId;
167+
const editors = this.editors.editorsGroupedByTextBufferId()[bufferId];
168+
169+
if (!editors) return;
170+
171+
const textBufferIdMarkers = this.markers.get(bufferId);
172+
const rowMarkers = textBufferIdMarkers.get(this.eventIds[eventId].rowStart);
173+
const baseMarker = rowMarkers?.get(colStart);
174+
175+
if (!baseMarker?.isValid()) return;
176+
177+
const highlightEvent = ensureNestedMap(this.highlights, eventId);
178+
179+
editors.forEach(editor => {
180+
const textEditorEvent = ensureNestedMap(highlightEvent, editor.id);
181+
182+
if (textEditorEvent.has(colStart)) return; // already highlighted
183+
184+
const marker = editor.markBufferRange(baseMarker.getBufferRange(), {
185+
invalidate: "inside",
186+
});
187+
188+
// Base style
189+
editor.decorateMarker(marker, { type: "text", class: CLASS.base });
190+
191+
// Style by numeric id
192+
editor.decorateMarker(marker, { type: "text", class: `${CLASS.idPrefix}${id}` });
193+
194+
textEditorEvent.set(colStart, marker);
195+
// eventId → texteditor.id → col → Marker
196+
});
197+
}
198+
199+
#removeHighlight({ colStart, eventId }) {
200+
201+
const highlightEvents = this.highlights.get(eventId);
202+
// console.log("removeHighlight", highlightEvents, eventId, colStart);
203+
204+
if (!highlightEvents.size) return;
205+
206+
highlightEvents.forEach(textEditorIdEvent => {
207+
const marker = textEditorIdEvent.get(colStart);
208+
textEditorIdEvent.delete(colStart);
209+
210+
if (!marker) return;
211+
marker.destroy();
212+
})
213+
214+
215+
}
216+
217+
// Marker generation (per line)
218+
219+
/**
220+
* Builds Atom markers for every word inside quoted mini‑notation strings so
221+
* that OSC events can map onto them later.
222+
*/
223+
#createPositionMarkers(line, lineNumber) {
224+
const currentEditor = this.editors.currentEditor();
225+
const currentTextBufferId = this.editors.currentEditor().buffer.id;
226+
227+
const textBufferIdMarkers = ensureNestedMap(this.markers, currentTextBufferId);
228+
const rowMarkers = ensureNestedMap(textBufferIdMarkers, lineNumber);
229+
230+
LineProcessor.findTidalWordRanges(line, (range) => {
231+
const bufferRange = [[lineNumber, range.start], [lineNumber, range.end + 1]];
232+
const marker = currentEditor.markBufferRange(bufferRange, { invalidate: "inside" });
233+
rowMarkers.set(range.start, marker);
234+
});
235+
}
236+
237+
#diffEventMaps(prevEvents, currentEvents) {
238+
const removed = new Set();
239+
const updated = new Map(prevEvents);
240+
241+
for (const [event, prevCols] of prevEvents) {
242+
const currCols = currentEvents.get(event);
243+
if (!currCols) {
244+
for (const [, prevEvt] of prevCols) removed.add(prevEvt);
245+
updated.delete(event);
246+
continue;
247+
}
248+
249+
for (const [col, prevEvt] of prevCols) {
250+
if (!currCols.has(col)) {
251+
removed.add(prevEvt);
252+
updated.get(event).delete(col);
253+
}
254+
}
255+
}
256+
257+
return { updated, removed };
258+
}
259+
}

0 commit comments

Comments
 (0)