Merge branch 'main' into main

This commit is contained in:
froos
2025-10-22 15:18:53 +02:00
5 changed files with 366 additions and 12 deletions
+14
View File
@@ -45,6 +45,20 @@ tidal-drum-machines
webaudiofontdata
src-tauri/target
### BEGIN Visual Studio Code ###
# Blanket, recursive exclude for .vscode directory and files
.vscode/**/*
# Unexclude specific files and directories within .vscode
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
### END Visual Studio Code ###
# BEGIN JetBrains -> END JetBrains
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+338 -10
View File
@@ -1,6 +1,9 @@
import jsdoc from '../../doc.json';
import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html';
import { Scale } from '@tonaljs/tonal';
import { soundMap } from 'superdough';
import { complex } from '@strudel/tonal';
const escapeHtml = (str) => {
const div = document.createElement('div');
@@ -75,6 +78,51 @@ const isValidDoc = (doc) => {
const hasExcludedTags = (doc) =>
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
export function bankCompletions() {
const soundDict = soundMap.get();
const banks = new Set();
for (const key of Object.keys(soundDict)) {
const [bank, suffix] = key.split('_');
if (suffix && bank) banks.add(bank);
}
return Array.from(banks)
.sort()
.map((name) => ({ label: name, type: 'bank' }));
}
// Attempt to get all scale names from Tonal
let scaleCompletions = [];
try {
scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
} catch (e) {
console.warn('[autocomplete] Could not load scale names from Tonal:', e);
}
// Valid mode values for voicing
const modeCompletions = [
{ label: 'below', type: 'mode' },
{ label: 'above', type: 'mode' },
{ label: 'duck', type: 'mode' },
{ label: 'root', type: 'mode' },
];
// Valid chord symbols from ireal dictionary plus empty string for major triads
const chordSymbols = ['', ...Object.keys(complex)].sort();
const chordSymbolCompletions = chordSymbols.map((symbol) => {
if (symbol === '') {
return {
label: 'major',
apply: '',
type: 'chord-symbol',
};
}
return {
label: symbol,
apply: symbol,
type: 'chord-symbol',
};
});
export const getSynonymDoc = (doc, synonym) => {
const synonyms = doc.synonyms || [];
const docLabel = getDocLabel(doc);
@@ -113,19 +161,299 @@ const jsdocCompletions = (() => {
return completions;
})();
export const strudelAutocomplete = (context) => {
const word = context.matchBefore(/\w*/);
if (word.from === word.to && !context.explicit) return null;
// --- Handler functions for each context ---
const pitchNames = [
'C',
'C#',
'Db',
'D',
'D#',
'Eb',
'E',
'E#',
'Fb',
'F',
'F#',
'Gb',
'G',
'G#',
'Ab',
'A',
'A#',
'Bb',
'B',
'B#',
'Cb',
];
// Cached regex patterns for scaleHandler
const SCALE_NO_QUOTES_REGEX = /scale\(\s*$/;
const SCALE_AFTER_COLON_REGEX = /scale\(\s*['"][^'"]*:[^'"]*$/;
const SCALE_PRE_COLON_REGEX = /scale\(\s*['"][^'"]*$/;
const SCALE_PITCH_MATCH_REGEX = /([A-Ga-g][#b]*)?$/;
const SCALE_SPACES_TO_COLON_REGEX = /\s+/g;
function scaleHandler(context) {
// First check for scale context without quotes - block with empty completions
let scaleNoQuotesContext = context.matchBefore(SCALE_NO_QUOTES_REGEX);
if (scaleNoQuotesContext) {
return {
from: scaleNoQuotesContext.to,
options: [],
};
}
// Check for after-colon context first (more specific)
let scaleAfterColonContext = context.matchBefore(SCALE_AFTER_COLON_REGEX);
if (scaleAfterColonContext) {
const text = scaleAfterColonContext.text;
const colonIdx = text.lastIndexOf(':');
if (colonIdx !== -1) {
const fragment = text.slice(colonIdx + 1);
const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment));
const options = filteredScales.map((s) => ({
...s,
apply: s.label.replace(SCALE_SPACES_TO_COLON_REGEX, ':'),
}));
const from = scaleAfterColonContext.from + colonIdx + 1;
return {
from,
options,
};
}
}
// Then check for pre-colon context
let scalePreColonContext = context.matchBefore(SCALE_PRE_COLON_REGEX);
if (scalePreColonContext) {
if (!scalePreColonContext.text.includes(':')) {
if (context.explicit) {
const text = scalePreColonContext.text;
const match = text.match(SCALE_PITCH_MATCH_REGEX);
const fragment = match ? match[0] : '';
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
const from = scalePreColonContext.to - fragment.length;
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
return { from, options };
} else {
return { from: scalePreColonContext.to, options: [] };
}
}
}
return null;
}
// Cached regex patterns for soundHandler
const SOUND_NO_QUOTES_REGEX = /(s|sound)\(\s*$/;
const SOUND_WITH_QUOTES_REGEX = /(s|sound)\(\s*['"][^'"]*$/;
const SOUND_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w]*)$/;
function soundHandler(context) {
// First check for sound context without quotes - block with empty completions
let soundNoQuotesContext = context.matchBefore(SOUND_NO_QUOTES_REGEX);
if (soundNoQuotesContext) {
return {
from: soundNoQuotesContext.to,
options: [],
};
}
// Then check for sound context with quotes - provide completions
let soundContext = context.matchBefore(SOUND_WITH_QUOTES_REGEX);
if (!soundContext) return null;
const text = soundContext.text;
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
if (quoteIdx === -1) return null;
const inside = text.slice(quoteIdx + 1);
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside;
const soundNames = Object.keys(soundMap.get()).sort();
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
const from = soundContext.to - fragment.length;
return {
from: word.from,
options: jsdocCompletions,
/* options: [
{ label: 'match', type: 'keyword' },
{ label: 'hello', type: 'variable', info: '(World)' },
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
], */
from,
options,
};
}
// Cached regex patterns for bankHandler
const BANK_NO_QUOTES_REGEX = /bank\(\s*$/;
const BANK_WITH_QUOTES_REGEX = /bank\(\s*['"][^'"]*$/;
function bankHandler(context) {
// First check for bank context without quotes - block with empty completions
let bankNoQuotesContext = context.matchBefore(BANK_NO_QUOTES_REGEX);
if (bankNoQuotesContext) {
return {
from: bankNoQuotesContext.to,
options: [],
};
}
// Then check for bank context with quotes - provide completions
let bankMatch = context.matchBefore(BANK_WITH_QUOTES_REGEX);
if (!bankMatch) return null;
const text = bankMatch.text;
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
if (quoteIdx === -1) return null;
const inside = text.slice(quoteIdx + 1);
const fragment = inside;
let banks = bankCompletions();
const filteredBanks = banks.filter((b) => b.label.startsWith(fragment));
const from = bankMatch.to - fragment.length;
return {
from,
options: filteredBanks,
};
}
// Cached regex patterns for modeHandler
const MODE_NO_QUOTES_REGEX = /mode\(\s*$/;
const MODE_AFTER_COLON_REGEX = /mode\(\s*['"][^'"]*:[^'"]*$/;
const MODE_PRE_COLON_REGEX = /mode\(\s*['"][^'"]*$/;
const MODE_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w:]*)$/;
function modeHandler(context) {
// First check for mode context without quotes - block with empty completions
let modeNoQuotesContext = context.matchBefore(MODE_NO_QUOTES_REGEX);
if (modeNoQuotesContext) {
return {
from: modeNoQuotesContext.to,
options: [],
};
}
// Check for after-colon context first (more specific)
let modeAfterColonContext = context.matchBefore(MODE_AFTER_COLON_REGEX);
if (modeAfterColonContext) {
const text = modeAfterColonContext.text;
const colonIdx = text.lastIndexOf(':');
if (colonIdx !== -1) {
const fragment = text.slice(colonIdx + 1);
// For anchor after colon, we can suggest pitch names
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
const from = modeAfterColonContext.from + colonIdx + 1;
return {
from,
options,
};
}
}
// Then check for pre-colon context
let modeContext = context.matchBefore(MODE_PRE_COLON_REGEX);
if (!modeContext) return null;
const text = modeContext.text;
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
if (quoteIdx === -1) return null;
const inside = text.slice(quoteIdx + 1);
const fragMatch = inside.match(MODE_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside;
const filteredModes = modeCompletions.filter((m) => m.label.startsWith(fragment));
const from = modeContext.to - fragment.length;
return {
from,
options: filteredModes,
};
}
// Cached regex patterns for chordHandler
const CHORD_NO_QUOTES_REGEX = /chord\(\s*$/;
const CHORD_WITH_QUOTES_REGEX = /chord\(\s*['"][^'"]*$/;
const CHORD_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w#b+^:-]*)$/;
function chordHandler(context) {
// First check for chord context without quotes - block with empty completions
let chordNoQuotesContext = context.matchBefore(CHORD_NO_QUOTES_REGEX);
if (chordNoQuotesContext) {
return {
from: chordNoQuotesContext.to,
options: [],
};
}
// Then check for chord context with quotes - provide completions
let chordContext = context.matchBefore(CHORD_WITH_QUOTES_REGEX);
if (!chordContext) return null;
const text = chordContext.text;
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
if (quoteIdx === -1) return null;
const inside = text.slice(quoteIdx + 1);
// Use same fragment matching as sound/mode for expressions like "<G Am>"
const fragMatch = inside.match(CHORD_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside;
// Check if fragment contains any pitch name at start (for root + symbol)
let rootMatch = null;
let symbolFragment = fragment;
for (const pitch of pitchNames) {
if (fragment.toLowerCase().startsWith(pitch.toLowerCase())) {
rootMatch = pitch;
symbolFragment = fragment.slice(pitch.length);
break;
}
}
if (rootMatch) {
// We have a root, now complete chord symbols
const filteredSymbols = chordSymbolCompletions.filter((s) =>
s.label.toLowerCase().startsWith(symbolFragment.toLowerCase()),
);
// Create completions that replace the entire chord, not just the symbol part
const options = filteredSymbols;
const from = chordContext.to - symbolFragment.length;
return { from, options };
} else {
// No root yet, complete with pitch names
const filteredPitches = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
const options = filteredPitches.map((p) => ({ label: p, type: 'pitch' }));
const from = chordContext.to - fragment.length;
return { from, options };
}
}
// Cached regex patterns for fallbackHandler
const FALLBACK_WORD_REGEX = /\w*/;
function fallbackHandler(context) {
const word = context.matchBefore(FALLBACK_WORD_REGEX);
if (word && word.from === word.to && !context.explicit) return null;
if (word) {
return {
from: word.from,
options: jsdocCompletions,
};
}
return null;
}
const handlers = [
soundHandler,
bankHandler,
chordHandler,
scaleHandler,
modeHandler,
// this handler *must* be last
fallbackHandler,
];
export const strudelAutocomplete = (context) => {
for (const handler of handlers) {
const result = handler(context);
if (result) {
return result;
}
}
return null;
};
export const isAutoCompletionEnabled = (enabled) =>
+3
View File
@@ -46,7 +46,10 @@
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*",
"@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*",
"@tonaljs/tonal": "^4.10.0",
"superdough": "workspace:*",
"nanostores": "^0.11.3"
},
"devDependencies": {
+2 -2
View File
@@ -1,9 +1,9 @@
import './tonal.mjs';
import './voicings.mjs';
import './ireal.mjs';
export * from './tonal.mjs';
export * from './voicings.mjs';
import './ireal.mjs';
export * from './ireal.mjs';
export const packageName = '@strudel/tonal';
+9
View File
@@ -212,12 +212,21 @@ importers:
'@strudel/draw':
specifier: workspace:*
version: link:../draw
'@strudel/tonal':
specifier: workspace:*
version: link:../tonal
'@strudel/transpiler':
specifier: workspace:*
version: link:../transpiler
'@tonaljs/tonal':
specifier: ^4.10.0
version: 4.10.0
nanostores:
specifier: ^0.11.3
version: 0.11.3
superdough:
specifier: workspace:*
version: link:../superdough
devDependencies:
vite:
specifier: ^6.0.11