Compare commits

...

2 Commits

Author SHA1 Message Date
minjaesong
937d3e27ed taut: help message panel 2026-05-06 21:48:11 +09:00
minjaesong
e64e335db3 resolving envelope ambiguity 2026-05-06 17:10:13 +09:00
11 changed files with 583 additions and 119 deletions

View File

@@ -146,11 +146,11 @@ Z:"UNIMPLEMENTED", // IT: MIDI macro
}
const panFxNames = {
0:"Set to",
1:"Slide L",
2:"Slide R",
1:"Slide R",
2:"Slide L",
3:"Fine slide",
30:"Fine slide L",
31:"Fine slide R",
30:"Fine slide R",
31:"Fine slide L",
999:"---",
}
const volFxNames = {
@@ -209,7 +209,7 @@ sym:[` \u00E0\u00E1`,` \u00E2\u00E3`,` \u00E4\u00E5`,` \u00E6\u00E7`,` \u00E8\u0
const volEffSym = [sym.volset, sym.volup, sym.voldn, sym.volfineup, sym.volfinedn]
const panEffSym = [sym.panset, sym.panle, sym.panri, sym.panfinele, sym.panfineri]
const panEffSym = [sym.panset, sym.panri, sym.panle, sym.panfineri, sym.panfinele]
const colNote = 239
const colInst = 114
@@ -521,7 +521,7 @@ function loadTaud(filePath, songIndex) {
ptns[i*2] = ((hi >> 4) << 8) | ((mi >> 4) << 4) | (lo >> 4)
ptns[i*2+1] = ((hi & 0xF) << 8) | ((mi & 0xF) << 4) | (lo & 0xF)
}
const instr = sys.peek(cueSheetPtr + c * CUE_SIZE + 30) & 0xFF
const instr = (sys.peek(cueSheetPtr + c * CUE_SIZE + 30) << 8) | sys.peek(cueSheetPtr + c * CUE_SIZE + 31)
cues[c] = { ptns, instr }
for (let v = 0; v < NUM_VOICES; v++) {
@@ -557,7 +557,7 @@ let VOCSIZE_TIMELINE_FULL = Math.floor((SCRW - 3) / COLSIZE_TIMELINE_FULL)
const ORDERS_CMD_X = 5
const ORDERS_VOICE_X = 9
const VOCSIZE_ORDERS = Math.floor((SCRW - 8) / 4)
const VOCSIZE_ORDERS = Math.floor((SCRW - 10) / 4)
const VIEW_TIMELINE = 0
const VIEW_CUES = 1
@@ -863,7 +863,9 @@ function drawControlHint() {
['n','Solo'],
['m','Mute'],
['sep'],
['tab','Panel']
['tab','Panel'],
['sep'],
['!','Help'],
// ['sep'],
// ['q','Quit'],
]
@@ -874,6 +876,8 @@ function drawControlHint() {
['sp','Edit'],
['sep'],
['tab','Panel'],
['sep'],
['!','Help'],
// ['sep'],
// ['q','Quit'],
]
@@ -885,6 +889,8 @@ function drawControlHint() {
['sp','Edit'],
['sep'],
['tab','Panel'],
['sep'],
['!','Help'],
// ['sep'],
// ['q','Quit'],
]
@@ -902,6 +908,8 @@ function drawControlHint() {
['sep'],
['=','KOff'],
['^','KCut'],
['sep'],
['!','Help'],
// ['sep'],
// ['Sp','ExitEdit'],
]
@@ -911,18 +919,22 @@ function drawControlHint() {
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Instrument'],
['sep'],
['sp','ExitEdit'],
['!','Help'],
// ['sep'],
// ['sp','ExitEdit'],
]
let hintElemEditVolEff = [
[`\u008428u\u008429u`,'Nav'],
[`pg\u008418u`,'Cue'],
['sep'],
['h','Set'],
['j','SlideDn'],
['k','SlideUp'],
['u','FineDn'],
['i','FineUp'],
['.','Set'],
['v','SlideUp'],
['^','SlideDn'],
['-','FineDn'],
['=','FineUp'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Val'],
['sep'],
['!','Help'],
// ['sep'],
// ['Sp','ExitEdit'],
]
@@ -930,11 +942,11 @@ function drawControlHint() {
[`\u008428u\u008429u`,'Nav'],
[`pg\u008418u`,'Cue'],
['sep'],
['h','Set'],
['j','SlideL'],
['k','SlideR'],
['u','FineL'],
['i','FineR'],
['.','Set'],
['<','SlideL'],
['>','SlideR'],
['-','FineL'],
['=','FineR'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Val'],
// ['sep'],
// ['Sp','ExitEdit'],
@@ -945,7 +957,9 @@ function drawControlHint() {
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,`FxSym`],
['sep'],
['sp','ExitEdit'],
['!','Help'],
// ['sep'],
// ['sp','ExitEdit'],
]
let hintElemEditFxVal = [
[`\u008428u\u008429u`,'Nav'],
@@ -953,10 +967,12 @@ function drawControlHint() {
['sep'],
[`0${sym.doubledot}9 A${sym.doubledot}F`,`FxVal`],
['sep'],
['sp','ExitEdit'],
['!','Help'],
// ['sep'],
// ['sp','ExitEdit'],
]
const hintElemExternal = [['Tab','Panel']]
const hintElemExternal = [['Tab','Panel'],['sep'],['!','Help']]
let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemExternal, hintElemExternal, hintElemExternal, hintElemExternal]
let hintElemPat = [hintElemEditNoteValue, hintElemEditInstValue, hintElemEditVolEff, hintElemEditPanEff, hintElemEditFxSym, hintElemEditFxVal]
@@ -1383,7 +1399,7 @@ function drawOrdersHeader() {
con.color_pair(colVoiceHdr, 255)
print(' ')
con.color_pair(colVoiceHdr, ordersColCursor === 0 ? colHighlight : 255)
print('Cmd ')
print('Comand ')
for (let c = 0; c < VOCSIZE_ORDERS; c++) {
const v = ordersVoiceOff + c
con.color_pair(colVoiceHdr, ordersColCursor === v + 1 ? colHighlight : 255)
@@ -1416,9 +1432,9 @@ function drawOrdersContents(wo) {
// CMD column — crosshair highlight at (ordersCursor, col 0)
const cmdBack = (isSel && ordersColCursor === 0) ? colPlayback : back
con.color_pair(cue.instr ? colStatus : colSep, cmdBack)
print(cue.instr ? cue.instr.hex02() : '--')
print(cue.instr ? cueInstToStr(cue.instr) : '------')
con.color_pair(colBackPtn, back)
print(' ')
print(' ')
// Voice columns
for (let c = 0; c < VOCSIZE_ORDERS; c++) {
const v = ordersVoiceOff + c
@@ -1435,6 +1451,35 @@ function drawOrdersContents(wo) {
}
}
function cueInstToStr(inst) {
let foreword = (inst >>> 12) & 15
let preamble = (inst >>> 8) & 15
let arg12 = inst & 0xFFF
let arg8 = inst & 0xFF
let fallback = `?${inst.hex04()}?`
switch (foreword) {
case 0b1000:
return "BAK" + arg12.hex03()
case 0b1001:
return "FWD" + arg12.hex03()
case 0b1111:
return "JMP" + arg12.hex03()
case 0b0000:
switch (preamble) {
case 0b0010:
return "LEN " + arg8.dec02()
case 0b0001:
return arg8 ? ("FADE" + arg8.dec02()) : "HALT "
case 0b0000:
return "NO-OP "
default:
return fallback
}
default:
return fallback
}
}
function timelineInput(wo, event) {
const keysym = event[1]
const keyJustHit = (1 == event[2])
@@ -2404,6 +2449,93 @@ function clampOrdersHoriz() {
}
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// HELP POPUP
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
const HELP_POPUP_W = SCRW - 8
const HELP_POPUP_X = ((SCRW - HELP_POPUP_W) / 2 | 0) + 1
const HELP_POPUP_Y = 5
const HELP_POPUP_H = SCRH - HELP_POPUP_Y - 1
const HELP_CONTENT_X = HELP_POPUP_X + 2
const HELP_CONTENT_Y = HELP_POPUP_Y + 2
const HELP_CONTENT_W = HELP_POPUP_W - 6
const HELP_CONTENT_H = HELP_POPUP_H - 3
// Pre-typeset every panel's help text. taut_helpmsg.js reads HELPMSG_WIDTH for
// the wrap width and stores ready-to-print display strings into MSG_BY_TABS.
_G.TAUT.HELPMSG_WIDTH = HELP_CONTENT_W
_G.shell.execute("taut_helpmsg")
function openHelpPopup() {
const helpmsg = _G.TAUT.HELPMSG || {}
const lines = (helpmsg.MSG_BY_TABS && helpmsg.MSG_BY_TABS[currentPanel]) || ['']
const colText = helpmsg.COL_TEXT || colWHITE
const popup = new win.WindowObject(
HELP_POPUP_X, HELP_POPUP_Y, HELP_POPUP_W, HELP_POPUP_H,
()=>{}, ()=>{}, `Help: ${PANEL_NAMES[currentPanel]}`, popupDrawFrame
)
popup.isHighlighted = true
popup.titleBack = colPopupBack
let scroll = 0
const maxScroll = Math.max(0, lines.length - HELP_CONTENT_H)
const repaint = () => {
con.color_pair(230, colPopupBack)
popup.drawFrame()
// popupDrawFrame leaves the bottom row unpainted; fill it ourselves.
con.color_pair(colText, colPopupBack)
con.move(HELP_POPUP_Y + HELP_POPUP_H - 1, HELP_POPUP_X)
print(' '.repeat(HELP_POPUP_W))
for (let r = 0; r < HELP_CONTENT_H; r++) {
con.move(HELP_CONTENT_Y + r, HELP_CONTENT_X)
con.color_pair(colText, colPopupBack)
const line = lines[scroll + r]
print((line === undefined) ? ' '.repeat(HELP_CONTENT_W) : line)
}
// scroll indicator on the right inner edge
if (lines.length > HELP_CONTENT_H) {
const trackH = HELP_CONTENT_H
const indPos = (maxScroll === 0) ? 0 : ((scroll * (trackH - 1) / maxScroll) | 0)
for (let r = 0; r < trackH; r++) {
con.move(HELP_CONTENT_Y + r, HELP_POPUP_X + HELP_POPUP_W - 2)
con.color_pair(colPushBtnBack, colPopupBack)
print(r === indPos ? '\u00DB' : '\u00B3')
}
}
con.color_pair(colStatus, 255)
}
repaint()
let done = false
let eventJustReceived = true
while (!done) {
input.withEvent(ev => {
if (ev[0] !== 'key_down') return
//if (1 !== ev[2]) return // allow continuous scroll by key repeat
if (eventJustReceived) { eventJustReceived = false; return }
const ks = ev[1]
if (ks === '<ESC>' || ks === '!' || ks === 'q' || ks === '\n') { done = true }
else if (ks === '<UP>') { if (scroll > 0) { scroll -= 1; repaint() } }
else if (ks === '<DOWN>') { if (scroll < maxScroll) { scroll += 1; repaint() } }
else if (ks === '<PAGE_UP>') { scroll = Math.max(0, scroll - HELP_CONTENT_H); repaint() }
else if (ks === '<PAGE_DOWN>') { scroll = Math.min(maxScroll, scroll + HELP_CONTENT_H); repaint() }
else if (ks === '<HOME>') { scroll = 0; repaint() }
else if (ks === '<END>') { scroll = maxScroll; repaint() }
})
}
drawAll()
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// GOTO POPUP
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -2613,6 +2745,11 @@ while (!exitFlag) {
return
}
if (keyJustHit && keysym === '!') {
openHelpPopup()
return
}
panels[currentPanel].processInput(event)
})

View File

@@ -9,10 +9,11 @@ Tags:
<c> - centre the line. If the line spans multiple lines, centre each line
<r> - align right
<l> - align left
&microtone; - replace with the brand string
&bul; - replace with bullet (\u00847u)
<o> - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line
&microtone; - replace with the brand string (<col 211>Micro</col><col 239>tone</col>)
&bul; - replace with bullet (\u00F9)
&ddot; - replace with double-dot (\u008419u)
&mdot; - replace with BIGDOT (\u00F9)
&mdot; - replace with BIGDOT (\u00FA)
&updn; - up-down arrow (\u008418u)
&udlr; - four direction arrow (\u008428u\u008429u)
&keyoffsym; - pattern view key-off symbol (\u00A0\u00CD\u00CD\u00A1)
@@ -22,63 +23,319 @@ Tags:
default alignment: fully justified
*/
help.notation = `<c>CONTROL NOTATON</c>
let helpNotation = `<c>CONTROL NOTATON</c>
&microtone; shortcuts differentiate normal and shifted shortcuts.
&bul;a&ddot;z : alphabet without shift-in
&bul;A&ddot;Z : alphabet with shift-in
&bul;^ : control key`
&microtone; <O>shortcuts differentiate normal and shifted shortcuts.</O>
&bul;<b>a</b>&ddot;<b>z</b> : <O>alphabet without shift-in</O>
&bul;<b>A</b>&ddot;<b>Z</b> : <O>alphabet with shift-in</O>
&bul;<b>^q</b> : <O>hit 'q' with control key</O>
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>`
////////////////////////////////////////////////////////////////////////////////////////////////////
help.jam = `<c>NOTE JAMMING</c>
let helpJam = `<c>NOTE JAMMING</c>
Push keys to play or insert notes.
&nbsp;w&nbsp;e&nbsp;&nbsp;&nbsp;t&nbsp;y&nbsp;u&nbsp;i
&nbsp;w&nbsp;e&nbsp;&nbsp;&nbsp;t&nbsp;y&nbsp;u
a&nbsp;s&nbsp;d&nbsp;f&nbsp;g&nbsp;h&nbsp;j&nbsp;k`
////////////////////////////////////////////////////////////////////////////////////////////////////
help.common = `<c>COMMON CONTROLS</c>
let helpCommon = `<c>COMMON CONTROLS</c>
&bul;Y : play the entire song from the current cue
&bul;U : play the current cue then stop
&bul;I : play the current row
&bul;O : stop the playback
&bul;tab : switch forward a tab
&bul;TAB : switch backward a tab
&bul;q : close &microtone;`
&bul;<b>!</b> : <O>show this help message</O>
&bul;<b>Y</b> : <O>play the entire song from the current cue</O>
&bul;<b>U</b> : <O>play the current cue then stop</O>
&bul;<b>I</b> : <O>play the current row</O>
&bul;<b>O</b> : <O>stop the playback</O>
&bul;<b>tab</b> : <O>switch forward a tab</O>
&bul;<b>TAB</b> : <O>switch backward a tab</O>
&bul;<b>q</b> : <O>close &microtone;</O>`
////////////////////////////////////////////////////////////////////////////////////////////////////
help.timeline = `<c>TIMELINE VIEW</c>
let helpTimeline = `<c>TIMELINE VIEW</c>
Timeline has two distinct modes: view and edit mode. Two modes are toggled using the space bar.
<b>View mode</b>
&bul;Note jamming : plays the note
&bul;&udlr; : move the viewing cursor by voices and rows
&bul;pg&updn; : go to previous/next cue
&bul;W&mdot;E&mdot;R : toggle timeline view mode. W-most detailed, R-most abridged
&bul;n : toggle soloing of the selected voice
&bul;m : toggle muting of the selected voice
<b>VIEW MODE</b>
&bul;Note jamming : <O>plays the note</O>
&bul;<b>&udlr;</b> : <O>move the viewing cursor by voices and rows</O>
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggle timeline view mode. W-most detailed, R-most abridged</O>
&bul;<b>n</b> : <O>toggle soloing of the selected voice</O>
&bul;<b>m</b> : <O>toggle muting of the selected voice</O>
<b>Edit mode</b>
&bul;Note jammping : (note column) inserts the note
&bul;{&mdot;} : (note column) lower/raise a note by one octave (or period)
&bul;[&mdot;] : (note column) lower/raise a note by one unit
&bul;= : (note column) insert a key-off &keyoffsym;
&bul;^ : (note column) insert a note-cut &notecutsym;
&bul;. : remove a symbol on the selected column
&bul;bksp : delete one character on the selected column
&bul;0&ddot;9 a&ddot;f : inserts a (hexa)decimal number
&bul;^&mdot;v : (volume column) slide up/down
&bul;<&mdot;> : (panning column) slide left/right
&bul;-&mdot;= : (vol/pan col) fine slide down/up
&bul;&udlr; : move the viewing cursor by columns and rows
&bul;pg&updn; : go to previous/next cue`
<b>EDIT MODE</b>
&bul;Note jamming : <O>(note column) inserts the note</O>
&bul;<b>{</b>&mdot;<b>}</b> : <O>(note column) lower/raise a note by one octave (or period)</O>
&bul;<b>[</b>&mdot;<b>]</b> : <O>(note column) lower/raise a note by one unit</O>
&bul;<b>=</b> : <O>(note column) insert a key-off &keyoffsym;</O>
&bul;<b>^</b> : <O>(note column) insert a note-cut &notecutsym;</O>
&bul;<b>.</b> : <O>remove a symbol on the selected column</O>
&bul;<b>bksp</b> : <O>delete one character on the selected column</O>
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>f</b> : <O>inserts a (hexa)decimal number</O>
&bul;<b>^</b>&mdot;<b>v</b> : <O>(volume column) slide up/down</O>
&bul;<b>&lt;</b>&mdot;<b>&gt;</b>: <O>(panning column) slide left/right</O>
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
&bul;<b>&udlr;</b> : <O>move the viewing cursor by columns and rows</O>
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>`
////////////////////////////////////////////////////////////////////////////////////////////////////
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
// assemble help text pieces to complete help message
const SCRW = con.getmaxyx()[1]
// Display-command palette. taut.js's popup uses (HELP_COL_TEXT on background) as the
// default colour pair, so embedded `\x1B[38;5;Nm` codes switch foreground only.
const HELP_COL_TEXT = 239 // popup body default (== colWHITE)
const HELP_COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
const HELP_COL_BRAND = 211 // first half of "Microtone"
const HELP_COL_BRAND_DIM = 239 // second half of "Microtone"
const fgEsc = (n) => `\x1B[38;5;${n}m`
const ESC_DEFAULT = fgEsc(HELP_COL_TEXT)
const ESC_EMPH = fgEsc(HELP_COL_EMPH)
const MICROTONE = `${fgEsc(HELP_COL_BRAND)}Micro${fgEsc(HELP_COL_BRAND_DIM)}tone${ESC_DEFAULT}`
// Replace &xxx; entities with their final printable representations.
function expandEntities(s) {
return s
.replaceAll('&microtone;', MICROTONE)
.replaceAll('&bul;', '\u00F9')
.replaceAll('&ddot;', '\u008419u')
.replaceAll('&mdot;', '\u00FA')
.replaceAll('&updn;', '\u008418u')
.replaceAll('&udlr;', '\u008428u\u008429u')
.replaceAll('&keyoffsym;', '\u00A0\u00CD\u00CD\u00A1')
.replaceAll('&notecutsym;', '\u00A4\u00A4\u00A4\u00A4')
.replaceAll('&nbsp;', '\u007F')
.replaceAll('&shy;', '')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
}
// Tokenise a (post-entity-expansion) line. Returns an array of:
// {type:'word', text:String, w:int} - non-breakable run of visible chars (may carry ANSI escapes)
// {type:'sp'} - a single soft space (eligible for break/expansion)
// {type:'anchor', open:Boolean} - <o>/</o> markers (zero width)
//
// Width accounting:
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
// - TSVM unicode escapes (`„..u`) : 1 visible char
// - non-breaking space ( ) : 1 visible char (consumed as part of a word)
// - soft hyphen (­) : dropped (not implemented as a break point)
// - everything else : 1 visible char
function tokenise(line) {
const tokens = []
let buf = ''
let bufW = 0
let i = 0
const flushWord = () => {
if (buf.length > 0) {
tokens.push({type: 'word', text: buf, w: bufW})
buf = ''
bufW = 0
}
}
while (i < line.length) {
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
if (line.slice(i, i + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
const head3 = line.slice(i, i + 3).toLowerCase()
const head4 = line.slice(i, i + 4).toLowerCase()
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
if (head4 === '</o>') { flushWord(); tokens.push({type: 'anchor', open: false}); i += 4; continue }
const c = line[i]
const cc = line.charCodeAt(i)
if (cc === 0x1B) {
// pre-existing ANSI escape - copy verbatim, zero visible width
const m = line.indexOf('m', i)
const end = (m < 0) ? line.length : m + 1
buf += line.slice(i, end)
i = end
}
else if (cc === 0x84) {
// TSVM „<digits>u escape - copy verbatim, one visible char
const u = line.indexOf('u', i)
const end = (u < 0) ? line.length : u + 1
buf += line.slice(i, end)
bufW += 1
i = end
}
else if (c === ' ') {
flushWord()
tokens.push({type: 'sp'})
i += 1
}
else if (cc === 0x00AD) {
// soft hyphen: drop (no break-point handling for now)
i += 1
}
else {
buf += c
bufW += 1
i += 1
}
}
flushWord()
return tokens
}
// Build wrapped lines from a token stream then format each one according to alignment.
// Returns an array of strings, each exactly `width` visible chars wide (padded with
// trailing spaces) so the caller can blit them without further math.
function wrapAndAlign(tokens, width, alignment) {
const lines = [] // each: {tokens, indent, contentW}
let curTokens = []
let curW = 0
let curIndent = 0
let nextIndent = 0 // indent the *next* flushed line should use
const flushLine = () => {
// strip trailing soft spaces
while (curTokens.length > 0 && curTokens[curTokens.length - 1].type === 'sp') {
curTokens.pop()
curW -= 1
}
lines.push({tokens: curTokens, indent: curIndent, contentW: curW})
curTokens = []
curW = 0
curIndent = nextIndent
}
for (const tok of tokens) {
if (tok.type === 'anchor') {
// anchor opens at the current visible column (accounting for indent)
if (tok.open) nextIndent = curIndent + curW
else nextIndent = 0
continue
}
if (tok.type === 'sp') {
// ignore leading soft spaces on a fresh line
if (curW === 0) continue
// hard wrap if the line is already at the right edge
if (curIndent + curW + 1 > width) { flushLine(); continue }
curTokens.push(tok)
curW += 1
continue
}
// word
const tw = tok.w
if (curIndent + curW + tw > width) {
flushLine()
// word too wide for the wrapped line: emit it on its own row (possibly clipped by terminal)
if (curIndent + tw > width) {
curTokens.push(tok)
curW += tw
flushLine()
continue
}
}
curTokens.push(tok)
curW += tw
}
if (curTokens.length > 0 || lines.length === 0) flushLine()
return lines.map((line, i) => formatLine(line, width, alignment, i === lines.length - 1))
}
function formatLine(line, totalWidth, alignment, isLast) {
if (line.tokens.length === 0) return ' '.repeat(totalWidth)
const indent = ' '.repeat(line.indent)
const remaining = totalWidth - line.indent - line.contentW
const pad = (n) => (n > 0) ? ' '.repeat(n) : ''
const flatText = () => line.tokens.map(t => (t.type === 'sp') ? ' ' : t.text).join('')
if (alignment === 'c') {
const left = remaining >> 1
return indent + pad(left) + flatText() + pad(remaining - left)
}
if (alignment === 'r') return indent + pad(remaining) + flatText()
if (alignment === 'l') return indent + flatText() + pad(remaining)
// justified: only expand spaces when there's slack and we're not on the
// last (or single) wrapped line
if (isLast || remaining <= 0) return indent + flatText() + pad(remaining)
const spaceCount = line.tokens.reduce((n, t) => n + (t.type === 'sp' ? 1 : 0), 0)
if (spaceCount === 0) return indent + flatText() + pad(remaining)
const baseExtra = (remaining / spaceCount) | 0
let leftover = remaining - baseExtra * spaceCount
let out = indent
for (const tok of line.tokens) {
if (tok.type === 'sp') {
const extra = baseExtra + (leftover > 0 ? 1 : 0)
if (leftover > 0) leftover -= 1
out += ' '.repeat(1 + extra)
} else {
out += tok.text
}
}
return out
}
// Process a single source line: peel a leading <c>/<r>/<l> alignment tag (if present),
// strip its matching close tag, then tokenise + wrap.
function typesetSourceLine(line, width) {
if (line.length === 0) return [' '.repeat(width)]
let alignment = 'j' // justified default
const startMatch = line.match(/^<([crl])>/i)
if (startMatch) {
alignment = startMatch[1].toLowerCase()
line = line.slice(startMatch[0].length)
const closeRe = new RegExp(`</${alignment}>$`, 'i')
line = line.replace(closeRe, '')
}
const tokens = tokenise(line)
return wrapAndAlign(tokens, width, alignment)
}
function typesetText(text, width) {
text = expandEntities(text)
const out = []
for (const srcLine of text.split('\n')) {
for (const outLine of typesetSourceLine(srcLine, width)) out.push(outLine)
}
return out
}
function typeset(text, customWidth) {
let typesetWidth = customWidth
if (typesetWidth === undefined) typesetWidth = _G.TAUT.HELPMSG_WIDTH
if (typesetWidth === undefined) {
const currentPosX = con.getyx()[1] // 1-indexed
typesetWidth = SCRW - currentPosX + 1
}
return typesetText(text, typesetWidth)
}
let helpMessages = [ // index: taut.js PANEL_NAMES
[helpJam, helpTimeline, helpCommon, helpNotation].join('\n\n'),
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
[helpCommon, helpNotation].join('\n\n'), // placeholder
]
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
help.typeset = typeset
help.COL_TEXT = HELP_COL_TEXT
help.COL_EMPH = HELP_COL_EMPH
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;

Binary file not shown.

View File

@@ -571,9 +571,10 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
'filter' — IT -32..+32 → Taud 0..255 (0x80 = unity cutoff)
Word layout (terranmon.txt:2049+ / 2114+):
LOOP word: 0b 0000_0sss_ssXcb_eeeee (X = 'p'/'m' for pan/pf, 0 for vol)
LOOP word: 0b 00P0_0sss_ssXcb_eeeee (X = 'p'/'m' for pan/pf, 0 for vol)
SUSTAIN word: 0b 0000_0sss_ss00b_eeeee
bits 12..8 = start index, bits 4..0 = end index
bit 13 = P (envelope present; gates pan/pf evaluation in the engine)
bit 7 = p (pan: use default pan) / m (pf: pitch=0/filter=1) / 0 (vol)
bit 6 = c (envelope carry — placed in the LOOP word)
bit 5 = b (enable that region)
@@ -638,7 +639,10 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
# directly. Bits: 5=b enable, 6=c carry, 7=p (pan default-pan flag) /
# m (pf filter mode); 12..8=start, 4..0=end. SUSTAIN word never carries
# c/p/m — those live in the LOOP word.
loop_word = 0
# P (bit 13) marks the envelope as present in source, regardless of LOOP/
# SUSTAIN enable. We reach this point only when the IT envelope flag bit 0
# is set (handled at function top), so P is unconditionally set here.
loop_word = 0x2000 # P: envelope present
if has_env_loop and 0 <= it_lpb < 25 and 0 <= it_lpe < 25:
loop_word |= 0x0020 # b: enable LOOP
loop_word |= (it_lpb & 0x1F) << 8
@@ -1133,7 +1137,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
# 256-byte instrument layout (terranmon.txt:2001+).
INST_STRIDE = 256
USE_ENV_BIT = 0x0020 # b — set whenever the engine should evaluate the envelope
USE_ENV_BIT = 0x0020 # b — LOOP wrap enable (legacy; engine still honours)
ENV_PRESENT_BIT = 0x2000 # P — envelope present in source (terranmon.txt byte 16/18/20 bit 5)
def _write_env(buf: bytearray, base: int, env_pts):
"""Write 25 (value, minifloat) pairs starting at `buf[base]`. Pads
@@ -1200,7 +1205,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
# When the source has neither loop nor sustain on the volume envelope
# the engine still needs the b flag so the single-point unit envelope
# is evaluated — synthesise USE_ENV_BIT into the LOOP word as a fallback.
vol_env_loop = idata.get('vol_env_loop', USE_ENV_BIT)
# The P bit is informational for vol but set for consistency.
vol_env_loop = idata.get('vol_env_loop', USE_ENV_BIT | ENV_PRESENT_BIT)
vol_env_sus = idata.get('vol_env_sus', 0)
pan_env_loop = idata.get('pan_env_loop', 0)
pan_env_sus = idata.get('pan_env_sus', 0)
@@ -1236,8 +1242,10 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
inst_bin[base + 22] = 0
# Force engine to use this single point — set the b bit on the LOOP
# word so the envelope is evaluated even though no wrap region exists.
# P is also set for consistency (vol-env presence is informational
# but converters mark it whenever they emit any node data).
cur_loop = struct.unpack_from('<H', inst_bin, base + 15)[0]
struct.pack_into('<H', inst_bin, base + 15, cur_loop | USE_ENV_BIT)
struct.pack_into('<H', inst_bin, base + 15, cur_loop | USE_ENV_BIT | ENV_PRESENT_BIT)
if pan_env:
_write_env(inst_bin, base + 71, pan_env)

View File

@@ -521,8 +521,10 @@ def build_sample_inst_bin(samples: list) -> tuple:
# IGV (byte 171) so the envelope must contribute a unit multiplier.
env_vol = 63
# MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates
# the unit envelope. Pan/PF stay disabled.
vol_env_loop = 0x0020 # b enable
# the unit envelope, plus P=1 (envelope present) for consistency with
# the new gate spec (terranmon.txt byte 16/18/20 bit 5). Pan/PF stay
# fully zero — the engine sees P=0 there and skips them.
vol_env_loop = 0x2020 # P (bit 13) | b (bit 5)
base = taud_idx * INST_STRIDE
struct.pack_into('<I', inst_bin, base + 0, ptr)

View File

@@ -207,9 +207,9 @@ def build_sample_inst_bin() -> bytes:
struct.pack_into('<H', inst_bin, base + 10, 0) # loop start
struct.pack_into('<H', inst_bin, base + 12, len(SQUARE_SAMPLE)) # loop end
inst_bin[base + 14] = 0x01 # forward loop
struct.pack_into('<H', inst_bin, base + 15, 0x0020) # vol-env enabled
struct.pack_into('<H', inst_bin, base + 17, 0) # pan-env flags
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags
struct.pack_into('<H', inst_bin, base + 15, 0x2020) # vol-env: P (bit 13) | b (bit 5)
struct.pack_into('<H', inst_bin, base + 17, 0) # pan-env flags (P=0 → mixer skips)
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags (P=0 → mixer skips)
inst_bin[base + 21] = 63 # vol env pt 0 = full
inst_bin[base + 22] = 0
inst_bin[base + 171] = 0xA0 # IGV

View File

@@ -499,8 +499,11 @@ def build_sample_inst_bin(instruments: list) -> tuple:
# Volume envelope first point is full-scale; per-sample level is carried
# by IGV (byte 171) so the envelope contributes a unit multiplier.
env_vol = 63
# Vol LOOP word: only b=1 (use envelope) — no actual loop / sustain.
vol_env_loop = 0x0020
# Vol LOOP word: P=1 (envelope present) | b=1 (use envelope) — no actual
# loop / sustain. P added 2026-05-06 alongside the pan/pf gate spec
# change (terranmon.txt byte 16/18/20 bit 5); informational for vol but
# set for consistency. Pan/PF stay zero so the engine sees P=0 there.
vol_env_loop = 0x2020
base = taud_idx * INST_STRIDE
struct.pack_into('<I', inst_bin, base + 0, ptr) # u32 sample pointer

View File

@@ -2036,6 +2036,36 @@ The b flag is the SOLE enable bit for each region; the historical 't'
present in this encoding — sustain vs loop is now a structural
distinction (different word at a different offset), not a flag bit.
Envelope PRESENCE — distinct from LOOP/SUSTAIN enable — is signalled by
the `P` bit at LOOP-word bit 13 (the high byte's bit 5; offsets 16/18/20
bit 5). Added 2026-05-06 to disambiguate two cases that the wrap-enable
bits cannot tell apart on their own:
P=0: the source had no envelope of this kind. Engine ignores the
node array entirely and the mixer skips envelope-driven output
for this voice (pan reads from channelPan only, cutoff/pitch
reads from sample defaults only). The 25 node slots may still
be left as default-fill garbage; nothing reads them.
P=1: envelope is defined. Engine evaluates the nodes every tick.
Wrap behaviour is independently controlled by LOOP.b and
SUSTAIN.b — when both are 0 the envelope walks once forward
and holds at its terminator (the IT idiom for envelope-driven
decay tails / shaped attacks).
The P bit was introduced to fix a gating ambiguity for pan and pitch/
filter envelopes: the engine could not distinguish "no envelope at all"
(treat as absent) from "envelope present but neither LOOP nor SUSTAIN
wrap is enabled" (evaluate and apply, just don't wrap). Volume envelope
evaluation has always been unconditional in the engine (a default
single-point envelope at value 63 is harmlessly held at unity), so
P_vol is currently informational only — converters should still set it
when the source defines a volume envelope, for consistency and to
support future per-voice gating.
P is the SOLE presence signal: converters MUST set P=1 whenever they
emit envelope nodes, regardless of whether the source enables LOOP or
SUSTAIN. Pre-2026-05-06 .taud files predate the P bit and will not have
their pan / pf envelopes evaluated by the current engine — re-convert
from source.
0 Uint32 Sample Pointer
4 Uint16 Sample length
6 Uint16 Sampling rate at C4 (note number 0x5000)
@@ -2061,16 +2091,20 @@ distinction (different word at a different offset), not a flag bit.
the engine deactivates the voice (player/sndmix.c:493-498). Without this,
instruments with stored fadeout=0 + envelope ending at 0 would silently
hold their voices forever.
0b 000_sssss_0cb_eeeee
0b 00P_sssss_0cb_eeeee
s (bits 12..8) : loop start index (0..24)
e (bits 4..0) : loop end index (0..24)
b (bit 5) : enable the LOOP wrap (0 = envelope walks once to its
terminator and holds; non-zero loops between s and e)
c (bit 6) : envelope carry (cross-trigger envelope position carry)
(bits 7, 13..15 reserved — set to 0)
P (bit 13) : envelope present in source (informational for vol —
engine evaluates vol env unconditionally; converters
should set P=1 when emitting nodes for consistency
with pan/pf envelopes, see file-header preamble)
(bits 7, 14..15 reserved — set to 0)
17 Bit16 Panning envelope LOOP word
* Always-active wrap region for the pan envelope.
0b 000_sssss_pcb_eeeee
0b 00P_sssss_pcb_eeeee
s (bits 12..8) : loop start index
e (bits 4..0) : loop end index
b (bit 5) : enable the LOOP
@@ -2079,16 +2113,28 @@ distinction (different word at a different offset), not a flag bit.
Independent of LOOP enable; the engine reads this bit
from the LOOP word as the canonical home for envelope-
level meta flags.
(bits 13..15 reserved)
P (bit 13) : envelope present in source. Gates whether the mixer
applies envelope-driven pan at all. P=0 ⇒ mixer uses
channelPan only and the node array is ignored. P=1 ⇒
evaluate every tick, even when both LOOP.b and SUSTAIN.b
are 0 (envelope walks once and holds — IT pan-env
flag=0x01 idiom).
(bits 14..15 reserved)
19 Bit16 Pitch/Filter envelope LOOP word
* Always-active wrap region for the pitch/filter envelope.
0b 000_sssss_mcb_eeeee
0b 00P_sssss_mcb_eeeee
s (bits 12..8) : loop start index
e (bits 4..0) : loop end index
b (bit 5) : enable the LOOP
c (bit 6) : envelope carry
m (bit 7) : mode — 0 = pitch envelope, 1 = filter envelope
(bits 13..15 reserved)
P (bit 13) : envelope present in source. Same semantics as the
pan envelope's P bit: gates whether the mixer applies
envelope-driven pitch / cutoff at all. P=0 ⇒ no
envelope contribution (sample plays at its own pitch /
default cutoff). P=1 ⇒ evaluate every tick regardless
of LOOP.b / SUSTAIN.b.
(bits 14..15 reserved)
21 Bit16x25 Volume envelopes
Byte 1: Volume (00..3F)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
@@ -2270,25 +2316,17 @@ TODO:
skipped because vEnvActive required either b bit. Now evaluation
is gated only by voice.volEnvOn (matches CHN_VOLENV in Schism).
See byte 15 spec for the LOOP word.
[ ] Same gate fix needed for pan and pitch/filter envelopes? Currently
advanceEnvelope/advancePfEnvelope still require LOOP-b OR SUSTAIN-b
before evaluating, AND the same condition feeds voice.hasPanEnv /
voice.hasPfEnv which the mixer uses to decide whether to apply
envelope-driven pan / cutoff at all. The simple "drop the gate"
treatment that worked for vol env doesn't transfer cleanly: an
absent pan/pf envelope (FT2 default, no env at all) needs to look
different from an enabled-no-wrap envelope so the mixer can ignore
the absent case. Options:
(a) Distinguish via a new format bit (e.g. byte 15/17/19 bit 7
for vol/pan, but bit 7 of pf already carries 'm' filter mode).
(b) Content-based detection at note trigger: envelope is "present"
if any node has non-default value or non-zero offset.
(c) Make the converters write a dedicated "envelope present"
sentinel (e.g. start>end in the LOOP word) that the engine
recognises as evaluate-but-don't-wrap.
Until decided, IT pan/pf envelopes with flags=0x01 will not animate
between rows. Workaround: enable IT's envelope loop or sustain bit
in source so the converter sets the LOOP/SUSTAIN b bit.
[x] Same gate fix needed for pan and pitch/filter envelopes.
Resolution (2026-05-06): added P (envelope present) bit at LOOP-word
bit 13 (offsets 16/18/20 bit 5) for all three envelopes. Engine
gates pan/pf envelope evaluation on P alone; converters set P=1
whenever they emit envelope nodes, regardless of LOOP/SUSTAIN
enable, so an enabled-no-wrap envelope (IT pan-env flag=0x01)
animates correctly. Mixer's hasPanEnv/hasPfEnv read the same gate,
so absent envelopes still bypass envelope-driven output. Pre-
2026-05-06 .taud files predate the P bit and need re-conversion
for pan/pf envelopes to play. See byte 15/17/19 spec for the LOOP
word bit layout.
[ ] implement extended tone mode (MONOTONE compat)
[ ] pattern loops stops working after processed once (test with slumberjack.xm)
[ ] milkytracker-style volume ramping (on sample-end only)
@@ -2420,8 +2458,8 @@ Play Head Flags
1001xxxx yyyyyyyy (FWD000) - Skip forward 0bxxxxyyyyyyyy patterns
1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy
00000010 00xxxxxx (LEN 00) - Pattern length for this cue (0..63), where 0: 1 row, 63: 64 rows (decoded by AudioAdapter as of 2026-05-05; emitted by xm2taud / it2taud for non-multiple-of-64 source patterns)
00000001 00000000 - Halt (HALT )
00000001 00111111 - Fadeout (FADOUT) - Gradually decrease global volume such that at row 63 it reaches zero
00000001 00000000 - Halt (HALT ) - Play the full length of the pattern then stop the playback
00000001 00xxxxxx - Fadeout (FADOUT) - Gradually decrease global volume such that at row 0bxxxxxx it reaches zero, then stop the playback
00000000 - No operation
65536..131071 RW: PCM Sample buffer

View File

@@ -1241,6 +1241,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
}
}
// Envelope-present test (terranmon.txt byte 15/17/19, P bit at LOOP word bit 13).
// The P bit is the sole presence signal — converters set it whenever they emit
// envelope nodes. Pre-2026-05-06 .taud files without P will not have pan/pf
// envelopes evaluated; re-convert from source.
private inline fun envPresent(loopWord: Int): Boolean = ((loopWord ushr 13) and 1) != 0
// Reusable per-envelope wrap-range scratch (avoid per-tick allocation).
private val volWrap = IntArray(2)
private val panWrap = IntArray(2)
@@ -1305,10 +1311,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
}
}
// Pan envelope (only when active for this instrument)
// Pan envelope. Presence is decided once per trigger and stored on the voice
// (voice.hasPanEnv is keyed on LOOP.P — see triggerNote). Like the volume
// envelope above, evaluation is no longer gated by the wrap-enable bits: an
// envelope marked "present but no wrap" still walks forward, matching the IT
// idiom (pan-env flag=0x01) and Schism player/sndmix.c:470-502.
if (!voice.hasPanEnv || !voice.panEnvOn) return
val pEnvActive = (((inst.panEnvLoop ushr 5) and 1) or ((inst.panEnvSustainWord ushr 5) and 1)) != 0
if (!pEnvActive) return
resolveEnvWrap(inst.panEnvLoop, inst.panEnvSustainWord, voice.keyOff, panWrap)
val pStart = panWrap[0]
val pEnd = panWrap[1]
@@ -1348,10 +1356,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* as advanceEnvelope. Result is stored in `voice.envPfValue` (0.0..1.0; 0.5 = unity).
*/
private fun advancePfEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
// Same gate semantics as the pan envelope above: presence (voice.hasPfEnv) is
// latched at trigger time from LOOP.P; evaluation is unconditional once
// present, so an enabled-no-wrap envelope animates.
if (!voice.hasPfEnv || !voice.pfEnvOn) return
val maxIdx = 24
val pEnvActive = (((inst.pfEnvLoop ushr 5) and 1) or ((inst.pfEnvSustainWord ushr 5) and 1)) != 0
if (!pEnvActive) return
resolveEnvWrap(inst.pfEnvLoop, inst.pfEnvSustainWord, voice.keyOff, pfWrap)
val pSusStart = pfWrap[0]
val pSusEnd = pfWrap[1]
@@ -1623,10 +1632,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPanIndex = 0
voice.envPanTimeSec = 0.0
voice.envPan = inst.panEnvelopes[0].value / 255.0
// Pan envelope is active when EITHER the LOOP word's b bit OR the SUSTAIN word's b bit is set.
voice.hasPanEnv = (((inst.panEnvLoop ushr 5) and 1) or ((inst.panEnvSustainWord ushr 5) and 1)) != 0
// Envelope-present gate (added 2026-05-06). Driven by the P bit at LOOP-word
// bit 13 (high byte's bit 5; offsets 16/18/20 bit 5), set by converters
// whenever they emit envelope nodes. See terranmon.txt at byte 15/17/19 for
// the bit layout and the file-header preamble for the presence-vs-wrap
// distinction.
voice.hasPanEnv = envPresent(inst.panEnvLoop)
// Pitch/filter envelope state.
voice.hasPfEnv = (((inst.pfEnvLoop ushr 5) and 1) or ((inst.pfEnvSustainWord ushr 5) and 1)) != 0
voice.hasPfEnv = envPresent(inst.pfEnvLoop)
// The pf 'm' mode bit (pitch=0, filter=1) lives in the LOOP word at bit 7.
voice.envPfIsFilter = (inst.pfEnvLoop ushr 7) and 1 != 0
voice.envPfIndex = 0

View File

@@ -819,7 +819,11 @@ def _xm_envelope_to_taud(env_pts: list, num_pts: int, env_type: int,
# LOOP word (offsets 15/17/19): b=enable, bits 12..8=start, 4..0=end.
# SUSTAIN word (offsets 189/191/193): same bit layout; FT2 single-point
# sustain is encoded with start == end (engine wraps that index → itself).
loop_word = 0x0020 # b: use envelope (vol always; even with no loop the engine evaluates it)
# P (bit 13) marks the envelope as present in source — this branch is only
# reached when XM_ENV_ON is set, so P is unconditionally 1 here. P gates
# whether the engine evaluates pan envelope at all (terranmon.txt byte
# 16/18/20 bit 5); for vol it is informational.
loop_word = 0x2020 # P (bit 13) | b (bit 5)
if has_loop:
loop_word |= (loop_start & 0x1F) << 8
loop_word |= (loop_end & 0x1F)
@@ -931,7 +935,8 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
s.loop_end = min(s.loop_end, n)
pos += n
USE_ENV_BIT = 0x0020 # b: engine should evaluate the envelope
USE_ENV_BIT = 0x0020 # b: engine should evaluate the envelope (LOOP wrap enable)
ENV_PRESENT_BIT = 0x2000 # P: envelope present in source (terranmon.txt byte 16/18/20 bit 5)
INST_STRIDE = 256
def _write_env(buf: bytearray, base: int, env_pts, pad_value: int) -> None:
@@ -962,13 +967,14 @@ def build_sample_inst_bin_xm(proxies: list) -> tuple:
# Resolve envelope LOOP / SUSTAIN words from the proxy. When XM has no
# envelope, fall back to a single-point unit envelope (vol LOOP word
# b=1 only) and rely on IGV for level.
# b=1 plus P=1 for consistency) and rely on IGV for level. Pan stays
# zero so the engine sees P=0 there and skips envelope-driven pan.
if s.vol_env_pts is not None:
vol_env_loop = s.vol_env_loop_word
vol_env_sus = s.vol_env_sus_word
vol_env = s.vol_env_pts
else:
vol_env_loop = USE_ENV_BIT
vol_env_loop = USE_ENV_BIT | ENV_PRESENT_BIT
vol_env_sus = 0
vol_env = None
if s.pan_env_pts is not None: