Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb4add695 | ||
|
|
338ea5be49 |
25
audiowidget.tmpl.stub
Normal file
25
audiowidget.tmpl.stub
Normal file
@ -0,0 +1,25 @@
|
||||
{# audiowidget.tmpl.stub
|
||||
Copy to neusician/templates/ and rename to audiowidget.tmpl.
|
||||
Include from base.tmpl or any score page with:
|
||||
{% include 'audiowidget.tmpl' %}
|
||||
|
||||
Variables expected in context (all provided by /sompyle/status.json
|
||||
via the sompyler_status_json route):
|
||||
result_url – URL to the rendered audio file (e.g. /sompyle/result.mp3)
|
||||
errors – non-empty string on synthesis failure, else empty/absent
|
||||
|
||||
The default JS audio widget in PaneCP renders a plain <audio> element.
|
||||
Replace or extend this stub to match your site's look and feel.
|
||||
#}
|
||||
|
||||
{% if errors %}
|
||||
<div class="se-error">{{ errors }}</div>
|
||||
{% elif result_url %}
|
||||
<figure class="audio-result">
|
||||
<figcaption>Rendered result</figcaption>
|
||||
<audio controls preload="metadata">
|
||||
<source src="{{ result_url }}" type="audio/mpeg">
|
||||
<a href="{{ result_url }}">Download MP3</a>
|
||||
</audio>
|
||||
</figure>
|
||||
{% endif %}
|
||||
@ -328,6 +328,7 @@ function buildBar(node) {
|
||||
const bar = {
|
||||
type: 'bar',
|
||||
id: node.positionals[0] ?? '',
|
||||
isDirty: false,
|
||||
stressor: null,
|
||||
tempoShape: null,
|
||||
tempoLevels: null,
|
||||
|
||||
@ -26,6 +26,10 @@ function shortView(node) {
|
||||
return { typeTag: 'label', label: node.label ?? '(no label)', meta: [] };
|
||||
case 'bar':
|
||||
return { typeTag: 'bar', label: node.id, meta: [] };
|
||||
case 'voice':
|
||||
return { typeTag: 'voice', label: node.name, meta: [] };
|
||||
case 'offset':
|
||||
return { typeTag: 'tick', label: String(node.tick ?? '?'), meta: [] };
|
||||
default:
|
||||
return { typeTag: node.type, label: node.type, meta: [] };
|
||||
}
|
||||
@ -43,7 +47,7 @@ export const PaneCP = {
|
||||
try {
|
||||
const raw = await fetchScoreText(props.store.credentials);
|
||||
props.store.rawScoreText = raw;
|
||||
const patched = patchScore(raw, props.store.scoreModel.instruments);
|
||||
const patched = patchScore(raw, props.store.scoreModel.instruments, props.store.scoreModel.bars);
|
||||
await putScoreText(patched, props.store.credentials);
|
||||
props.store.synthesisStatus = { frozen: false, progress: 0 };
|
||||
} catch (e) {
|
||||
@ -110,14 +114,22 @@ export const PaneCP = {
|
||||
// Export error
|
||||
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
||||
|
||||
// Status poller
|
||||
// Status poller (while running)
|
||||
store.synthesisStatus && !store.synthesisStatus.frozen
|
||||
? h(StatusPoller, { store }) : null,
|
||||
|
||||
// Result link
|
||||
store.synthesisStatus?.frozen && !store.synthesisStatus?.error
|
||||
? h('a', { href: '/sompyle/result.mp3', style: 'display:block;margin-top:0.5rem' },
|
||||
'Download result') : null,
|
||||
// Synthesis error
|
||||
store.synthesisStatus?.frozen && store.synthesisStatus?.errors
|
||||
? h('div', { class: 'se-error', style: 'margin-top:0.5rem' },
|
||||
store.synthesisStatus.errors) : null,
|
||||
|
||||
// Audio result
|
||||
store.synthesisStatus?.file_accomplished
|
||||
? h('audio', {
|
||||
controls: true,
|
||||
src: '/sompyle/result.mp3',
|
||||
style: 'display:block;width:100%;margin-top:0.5rem',
|
||||
}) : null,
|
||||
]);
|
||||
};
|
||||
},
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
import { h, ref } from 'vue';
|
||||
import { ObjectExtended } from './ObjectExtended.js';
|
||||
import { EnvelopeEditor } from './EnvelopeEditor.js';
|
||||
import { ShapeEditor } from './ShapeEditor.js';
|
||||
import { LinkedInstrumentModal } from './LinkedInstrumentModal.js';
|
||||
|
||||
function stressorToString(s) {
|
||||
if (!s?.groups?.length) return '';
|
||||
return s.groups.map(g => g.join(',')).join(';');
|
||||
}
|
||||
|
||||
function parseStressor(str) {
|
||||
const groups = str.split(';').map(seg =>
|
||||
seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))
|
||||
).filter(g => g.length > 0);
|
||||
return groups.length ? { type: 'stressor', groups } : null;
|
||||
}
|
||||
|
||||
function scoreInfoFields(info) {
|
||||
return [
|
||||
{ key: 'title', value: info?.title ?? '', editable: true },
|
||||
@ -136,11 +149,69 @@ export const PaneFO = {
|
||||
: null,
|
||||
);
|
||||
} else if (node.type === 'bar') {
|
||||
const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); };
|
||||
children.push(
|
||||
h('h4', { style: 'margin:0 0 0.5rem' }, `Bar: ${node.id}`),
|
||||
h(ObjectExtended, { fields: [
|
||||
h(ObjectExtended, {
|
||||
fields: [
|
||||
{ key: 'id', value: node.id, editable: false },
|
||||
], onChange: null }),
|
||||
{ key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' },
|
||||
{ key: 'stress_pattern', value: stressorToString(node.stressor), editable: true },
|
||||
],
|
||||
onChange: ({ key, value }) => {
|
||||
if (key === 'beats_per_minute') node.tempoLevels = isNaN(value) ? null : value;
|
||||
if (key === 'stress_pattern') node.stressor = parseStressor(value);
|
||||
markBarDirty();
|
||||
},
|
||||
}),
|
||||
node.upperStressBound ? h('div', { style: 'margin-top:0.5rem' }, [
|
||||
h('strong', null, 'Upper stress bound'),
|
||||
h(ShapeEditor, { shape: node.upperStressBound, onChange: markBarDirty }),
|
||||
]) : null,
|
||||
node.lowerStressBound ? h('div', { style: 'margin-top:0.5rem' }, [
|
||||
h('strong', null, 'Lower stress bound'),
|
||||
h(ShapeEditor, { shape: node.lowerStressBound, onChange: markBarDirty }),
|
||||
]) : null,
|
||||
node.tempoShape ? h('div', { style: 'margin-top:0.5rem' }, [
|
||||
h('strong', null, 'Tempo shape'),
|
||||
h(ShapeEditor, { shape: node.tempoShape, onChange: markBarDirty }),
|
||||
]) : null,
|
||||
);
|
||||
} else if (node.type === 'voice') {
|
||||
children.push(
|
||||
h('h4', { style: 'margin:0 0 0.5rem' }, `Voice: ${node.name}`),
|
||||
h(ObjectExtended, {
|
||||
fields: [
|
||||
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
|
||||
{ key: 'motifs', value: node.motifs.join(', ') || '—', editable: false },
|
||||
{ key: 'offsets', value: String(node.offsets.length), editable: false },
|
||||
],
|
||||
onChange: null,
|
||||
}),
|
||||
);
|
||||
} else if (node.type === 'offset') {
|
||||
const noteStr = n => `${n.pitch}${n.effLength != null ? ' ' + n.effLength : ''}`;
|
||||
const clusterStr = c => c.notes.length
|
||||
? c.notes.map(n => `${n.letter ?? ''}${n.shift != null ? n.shift : ''}${n.length != null ? ' ' + n.length : ''}`).join(', ')
|
||||
: `cluster[${c.index}]`;
|
||||
children.push(
|
||||
h('h4', { style: 'margin:0 0 0.5rem' }, `Tick: ${node.tick}`),
|
||||
h(ObjectExtended, {
|
||||
fields: [{ key: 'tick', value: node.tick, editable: false }],
|
||||
onChange: null,
|
||||
}),
|
||||
node.stemNotes.length ? h('div', { style: 'margin-top:0.5rem' }, [
|
||||
h('strong', null, 'Stem notes'),
|
||||
h('ul', { class: 'se-object-list' }, node.stemNotes.map((n, i) =>
|
||||
h('li', { class: 'se-object-item', key: i },
|
||||
h('span', { class: 'se-object-label' }, noteStr(n))))),
|
||||
]) : null,
|
||||
node.clusters.length ? h('div', { style: 'margin-top:0.5rem' }, [
|
||||
h('strong', null, 'Clusters'),
|
||||
h('ul', { class: 'se-object-list' }, node.clusters.map((c, i) =>
|
||||
h('li', { class: 'se-object-item', key: i },
|
||||
h('span', { class: 'se-object-label' }, clusterStr(c))))),
|
||||
]) : null,
|
||||
);
|
||||
} else {
|
||||
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },
|
||||
|
||||
@ -51,7 +51,14 @@ export const PaneSubObjects = {
|
||||
}
|
||||
if (node.type === 'bar') {
|
||||
return Object.entries(node.voices).map(([name, v]) => ({
|
||||
kind: 'voice', node: v, label: name,
|
||||
kind: 'voice', node: v, label: name, hasChildren: v.offsets.length > 0,
|
||||
}));
|
||||
}
|
||||
if (node.type === 'voice') {
|
||||
return node.offsets.map((o, idx) => ({
|
||||
kind: 'offset', node: o,
|
||||
label: `tick ${o.tick ?? idx}`,
|
||||
hasChildren: false,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
|
||||
@ -134,11 +134,11 @@ export function exportInstrument(instr) {
|
||||
}
|
||||
|
||||
// ── Score patch ────────────────────────────────────────────────────────────
|
||||
// Replace dirty instrument blocks in rawScoreText with RFC-serialized output.
|
||||
// Non-dirty instruments are left verbatim.
|
||||
// Replace dirty instrument blocks and dirty bar _meta blocks.
|
||||
// Voice note content in bar documents is left verbatim.
|
||||
|
||||
export function patchScore(rawScoreText, instruments) {
|
||||
const lines = rawScoreText.split('\n');
|
||||
function patchInstrumentHeader(text, instruments) {
|
||||
const lines = text.split('\n');
|
||||
const result = [];
|
||||
const instrMap = {};
|
||||
for (const instr of instruments) {
|
||||
@ -149,7 +149,6 @@ export function patchScore(rawScoreText, instruments) {
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
// RFC §4.4: "instrument NAME:" — strip optional quotes around name
|
||||
const m = line.match(/^instrument\s+(.+?)\s*:/);
|
||||
if (m) {
|
||||
const rawName = m[1].replace(/^'|'$/g, '');
|
||||
@ -171,3 +170,62 @@ export function patchScore(rawScoreText, instruments) {
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
function patchBarMeta(doc, bar) {
|
||||
const props = [];
|
||||
if (bar.stressor?.groups?.length)
|
||||
props.push(` stress_pattern: ${bar.stressor.groups.map(g => g.join(',')).join(';')}`);
|
||||
if (bar.tempoLevels != null)
|
||||
props.push(` beats_per_minute: ${bar.tempoLevels}`);
|
||||
const ub = serializeShape(bar.upperStressBound);
|
||||
if (ub) props.push(` upper_stress_bound: ${ub}`);
|
||||
const lb = serializeShape(bar.lowerStressBound);
|
||||
if (lb) props.push(` lower_stress_bound: ${lb}`);
|
||||
if (bar.tempoShape) { const ts = serializeShape(bar.tempoShape); if (ts) props.push(` tempo_shape: "${ts}"`); }
|
||||
|
||||
const lines = doc.split('\n');
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let replaced = false;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (lines[i] === '_meta:') {
|
||||
replaced = true;
|
||||
i++;
|
||||
while (i < lines.length && lines[i].startsWith(' ')) i++;
|
||||
if (props.length) { out.push('_meta:'); out.push(...props); }
|
||||
} else {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!replaced && props.length) {
|
||||
const idIdx = out.findIndex(l => /^_id:/.test(l));
|
||||
if (idIdx !== -1) out.splice(idIdx + 1, 0, '_meta:', ...props);
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export function patchScore(rawScoreText, instruments, bars = []) {
|
||||
const SEP = '\n---\n';
|
||||
const [header, ...barDocs] = rawScoreText.split(SEP);
|
||||
|
||||
const patchedHeader = patchInstrumentHeader(header, instruments);
|
||||
|
||||
if (!barDocs.length) return patchedHeader;
|
||||
|
||||
const barMap = {};
|
||||
for (const bar of bars) barMap[bar.id] = bar;
|
||||
|
||||
const patchedBarDocs = barDocs.map(doc => {
|
||||
const m = doc.match(/^_id:\s*(\S+)/m);
|
||||
if (!m) return doc;
|
||||
const bar = barMap[m[1]];
|
||||
if (!bar?.isDirty) return doc;
|
||||
return patchBarMeta(doc, bar);
|
||||
});
|
||||
|
||||
return [patchedHeader, ...patchedBarDocs].join(SEP);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user