mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
5459 lines
234 KiB
JavaScript
5459 lines
234 KiB
JavaScript
/**
|
||
* Microtone. formerly known as TSVM Audio Device Tracker. (taut)
|
||
*
|
||
* Created by minjaesong on 2026-04-20
|
||
*/
|
||
|
||
const win = require("wintex")
|
||
const font = require("font")
|
||
const taud = require("taud")
|
||
const keys = require("keysym")
|
||
const gl = require("gl")
|
||
|
||
const BUILD_DATE = "260424"
|
||
const TRACKER_SIGNATURE = "TsvmTaut"+BUILD_DATE // 14-byte string
|
||
|
||
const MIDDOT = "\u00FA"
|
||
const BIGDOT = "\u00F9"
|
||
const BULLET = "\u00847u"
|
||
const DOTHORZ = "\u00B4\u00B5"
|
||
const VERT = 0xDA
|
||
|
||
// global var for the app
|
||
_G.TAUT = {};
|
||
_G.TAUT.UI = {};
|
||
_G.TAUT.UI.NEXTPANEL = undefined;
|
||
|
||
const sym = {
|
||
/* accidentals */
|
||
accnull:"\u00A2\u00A3",
|
||
demisharp:"\u0080\u0081",
|
||
sharp:"\u0082\u0083",
|
||
sesquisharp:"\u0084132u\u0085", // refrain from using (not visible on CRT); 0x84 is used as a escape sequence for arbitrary unicode character in TSVM
|
||
doublesharp:"\u0086\u0087",
|
||
triplesharp:"\u0088\u0089",
|
||
quadsharp:"\u008A\u008B",
|
||
demiflat:"\u008C\u008D",
|
||
flat:"\u008E\u008F",
|
||
sesquiflat:"\u0090\u0091",
|
||
doubleflat:"\u0092\u0093",
|
||
tripleflat:"\u0094\u0095",
|
||
quadflat:"\u0096\u0097", // refrain from using (not visible on CRT)
|
||
|
||
csharp:"\u0098",
|
||
cflat:"\u0098",
|
||
cdemisharp:"\u00A7",
|
||
cdemiflat:"\u00A8",
|
||
uptick:"\u009A",
|
||
dntick:"\u009B",
|
||
doubleuptick:"\u009C",
|
||
doubledntick:"\u009D",
|
||
|
||
|
||
/* special notes */
|
||
keyoff:"\u00A0\u00B1\u00B1\u00A1",
|
||
notecut:"\u00A4\u00A4\u00A4\u00A4",
|
||
|
||
/* special effects */
|
||
volset:'',//MIDDOT,
|
||
volup:"\u008430u",
|
||
voldn:"\u008431u",
|
||
volfineup:"+",
|
||
volfinedn:"-",
|
||
|
||
panset:'',//MIDDOT,
|
||
panle:"\u008417u",
|
||
panri:"\u008416u",
|
||
panfinele:"\u008427u",
|
||
panfineri:"\u008426u",
|
||
|
||
/* Fx/Vx/Px */
|
||
fx:'\u00F8',
|
||
px:'\u00AC',
|
||
vx:'\u00AD',
|
||
|
||
/* transport control */
|
||
playall:'\u00E1',
|
||
playcue:'\u00E2',
|
||
playrow:'\u00E3',
|
||
stop:'\u00E4',
|
||
|
||
/* miscellaneous */
|
||
unticked:"\u00AE",
|
||
ticked:"\u00AF",
|
||
middot:MIDDOT,
|
||
doubledot:"\u008419u",
|
||
statusstop:"\u008420u\u008421u",
|
||
statusplay:"\u008422u\u008423u",
|
||
playhead:"\u00E0",
|
||
|
||
leftshade:'\u00B0',
|
||
rightshade:'\u00B2',
|
||
}
|
||
|
||
const fxNames = {
|
||
'0':"-- ",
|
||
'1':"Mixer config ",
|
||
'2':"UNIMPLEMENTED",
|
||
'3':"UNIMPLEMENTED",
|
||
'4':"UNIMPLEMENTED",
|
||
'5':"UNIMPLEMENTED",
|
||
'6':"UNIMPLEMENTED",
|
||
'7':"Pattern Ditto",
|
||
'8':"Bitcrusher ",
|
||
'9':"Overdrive ",
|
||
A:"Tick speed ",
|
||
B:"Jump to order",
|
||
C:"Break pattern",
|
||
D:"Volume slide ",
|
||
E:"Pitch down ",
|
||
F:"Pitch up ",
|
||
G:"Portamento ",
|
||
H:"Vibrato ",
|
||
I:"Tremor ",
|
||
J:"Arpeggio ",
|
||
K:"Vibrafade ",
|
||
L:"Portafade ",
|
||
M:"Channel vol ",
|
||
N:"Chan.volslide",
|
||
O:"Sample offset",
|
||
P:"Chan.panslide",
|
||
Q:"Retrigger ",
|
||
R:"Tremolo ",
|
||
S:"Special ",
|
||
S0:"Amiga Filter ",
|
||
S1:"Gliss. ctrl ",
|
||
S2:"Sample tune ",
|
||
S3:"Vibrato LFO ",
|
||
S4:"Tremolo LFO ",
|
||
S5:"Panbrello LFO",
|
||
S6:"Fine delay ",
|
||
S7:"Note action ",
|
||
S8:"Channel pan ", // Taud: 8-bit channel panning
|
||
S9:"UNIMPLEMENTED", // IT: Sound control
|
||
SA:"UNIMPLEMENTED", // ST3: Stereo control. IT: Sample offset high twobyte (not applicable because Taud has 64k limit)
|
||
SB:"Pattern loop ",
|
||
SC:"Note cut ",
|
||
SD:"Note delay ",
|
||
SE:"Pattern delay",
|
||
SF:"Funk repeat ",
|
||
T:"Tempo ",
|
||
U:"Fine vibrato ",
|
||
V:"Global volume",
|
||
W:"G.Vol Slide ",
|
||
X:"UNIMPLEMENTED", // IT: 8-bit channel panning. Use S 80xx instead
|
||
Y:"Panbrello ",
|
||
Z:"UNIMPLEMENTED", // IT: MIDI macro
|
||
}
|
||
const panFxNames = {
|
||
0:"Set to",
|
||
1:"Slide R",
|
||
2:"Slide L",
|
||
3:"Fine slide",
|
||
30:"Fine slide R",
|
||
31:"Fine slide L",
|
||
999:"---",
|
||
}
|
||
const volFxNames = {
|
||
0:"Set to",
|
||
1:"Slide UP",
|
||
2:"Slide DN",
|
||
3:"Fine slide",
|
||
30:"Fine slide DN",
|
||
31:"Fine slide UP",
|
||
999:"---",
|
||
}
|
||
|
||
const pitchTablePresets = {
|
||
// index: pitch table number to be recorded on .taudproj file
|
||
// t: type of the tuning. M - Macrotonal, m - microtonal, d - 12-tone
|
||
|
||
0:{index:0,name:"Raw format",table:[],interval:0x1000,t:'',sym:[]}, // when null is specified, hex numbers will be displayed instead
|
||
/* Xenharmonic, equal temperament */
|
||
10:{index:10,name:"Octave only",table:[0x0],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`]},
|
||
20:{index:20,name:"2-TET",table:[0x0,0x800],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`F${sym.sharp}`]},
|
||
30:{index:30,name:"3-TET",table:[0x0,0x555,0xAAB],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`E${sym.accnull}`,`G${sym.sharp}`]},
|
||
40:{index:40,name:"4-TET",table:[0x0,0x400,0x800,0xC00],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`D${sym.sharp}`,`F${sym.sharp}`,`A${sym.accnull}`]},
|
||
50:{index:50,name:"5-TET",table:[0x0,0x333,0x666,0x99A,0xCCD],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`D${sym.accnull}`,`E${sym.accnull}`,`G${sym.accnull}`,`A${sym.accnull}`]},
|
||
60:{index:60,name:"6-TET",table:[0x0,0x2AB,0x555,0x800,0xAAB,0xD55],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`D${sym.accnull}`,`E${sym.accnull}`,`F${sym.sharp}`,`G${sym.sharp}`,`A${sym.sharp}`]},
|
||
70:{index:70,name:"7-TET",table:[0x0,0x249,0x492,0x6DB,0x925,0xB6E,0xDB7],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`D${sym.accnull}`,`E${sym.accnull}`,`F${sym.accnull}`,`G${sym.accnull}`,`A${sym.accnull}`,`B${sym.accnull}`]},
|
||
80:{index:80,name:"8-TET",table:[0x0,0x200,0x400,0x600,0x800,0xA00,0xC00,0xE00],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`D${sym.accnull}`,`E${sym.accnull}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.sharp}`,`A${sym.accnull}`,`B${sym.accnull}`]},
|
||
90:{index:90,name:"9-TET",table:[0x0,0x1C7,0x38E,0x555,0x71C,0x8E4,0xAAB,0xC72,0xE39],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`D${sym.accnull}`,`E${sym.accnull}`,`E${sym.sharp}`,`F${sym.accnull}`,`G${sym.accnull}`,`A${sym.accnull}`,`B${sym.accnull}`,`B${sym.sharp}`]},
|
||
100:{index:100,name:"10-TET",table:[0x0,0x19A,0x333,0x4CD,0x666,0x800,0x99A,0xB33,0xCCD,0xE66],interval:0x1000,t:'M',
|
||
sym:[`C${sym.accnull}`,`D${sym.flat}`,`D${sym.accnull}`,`E${sym.flat}`,`E${sym.accnull}`,`E${sym.sharp}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.accnull}`,`A${sym.sharp}`]},
|
||
150:{index:150,name:"15-TET",table:[0x0,0x111,0x222,0x333,0x444,0x555,0x666,0x777,0x889,0x99A,0xAAB,0xBBC,0xCCD,0xDDE,0xEEF],interval:0x1000,t:'m',
|
||
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym.flat}`,`E${sym.accnull}`,`E${sym.sharp}`,`F${sym.sharp}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.flat}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.flat}`,`B${sym.accnull}`]},
|
||
160:{index:160,name:"16-TET",table:[0x0,0x100,0x200,0x300,0x400,0x500,0x600,0x700,0x800,0x900,0xA00,0xB00,0xC00,0xD00,0xE00,0xF00],interval:0x1000,t:'m',
|
||
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym.accnull}`,`E${sym.sharp}`,`F${sym.flat}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.accnull}`,`B${sym.sharp}`,`C${sym.flat}`]},
|
||
170:{index:170,name:"17-TET",table:[0x0,0xF1,0x1E2,0x2D3,0x3C4,0x4B5,0x5A6,0x697,0x788,0x878,0x969,0xA5A,0xB4B,0xC3C,0xD2D,0xE1E,0xF0F],interval:0x1000,t:'m',
|
||
sym:[`C${sym.accnull}`,`D${sym.flat}`,`C${sym.sharp}`,`D${sym.accnull}`,`E${sym.flat}`,`D${sym.sharp}`,`E${sym.accnull}`,`F${sym.accnull}`,`G${sym.flat}`,`F${sym.sharp}`,`G${sym.accnull}`,`A${sym.flat}`,`G${sym.sharp}`,`A${sym.accnull}`,`B${sym.flat}`,`A${sym.sharp}`,`B${sym.accnull}`]},
|
||
190:{index:190,name:"19-TET",table:[0x0,0xD8,0x1AF,0x287,0x35E,0x436,0x50D,0x5E5,0x6BD,0x794,0x86C,0x943,0xA1B,0xAF3,0xBCA,0xCA2,0xD79,0xE51,0xF28],interval:0x1000,t:'m',
|
||
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.flat}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym.flat}`,`E${sym.accnull}`,`E${sym.sharp}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.flat}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.flat}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.flat}`,`B${sym.accnull}`,`B${sym.sharp}`]},
|
||
220:{index:220,name:"22-TET",table:[0x0,0xBA,0x174,0x22F,0x2E9,0x3A3,0x45D,0x517,0x5D1,0x68C,0x746,0x800,0x8BA,0x974,0xA2F,0xAE9,0xBA3,0xC5D,0xD17,0xDD1,0xE8C,0xF46],interval:0x1000,t:'m',
|
||
sym:[`C${sym.accnull}`,`C${sym.demisharp}`,`C${sym.sharp}`,`D${sym.demiflat}`,`D${sym.accnull}`,`D${sym.demisharp}`,`D${sym.sharp}`,`E${sym.demiflat}`,`E${sym.accnull}`,`F${sym.accnull}`,`F${sym.demisharp}`,`F${sym.sharp}`,`G${sym.demiflat}`,`G${sym.accnull}`,`G${sym.demisharp}`,`G${sym.sharp}`,`A${sym.demiflat}`,`A${sym.accnull}`,`A${sym.demisharp}`,`A${sym.sharp}`,`B${sym.demiflat}`,`B${sym.accnull}`]},
|
||
240:{index:240,name:"24-TET",table:[0x0,0xAB,0x155,0x200,0x2AB,0x355,0x400,0x4AB,0x555,0x600,0x6AB,0x755,0x800,0x8AB,0x955,0xA00,0xAAB,0xB55,0xC00,0xCAB,0xD55,0xE00,0xEAB,0xF55],interval:0x1000,t:'m',
|
||
sym:[`C${sym.accnull}`,`C${sym.demisharp}`,`C${sym.sharp}`,`D${sym.demiflat}`,`D${sym.accnull}`,`D${sym.demisharp}`,`D${sym.sharp}`,`E${sym.demiflat}`,`E${sym.accnull}`,`E${sym.demisharp}`,`F${sym.accnull}`,`F${sym.demisharp}`,`F${sym.sharp}`,`G${sym.demiflat}`,`G${sym.accnull}`,`G${sym.demisharp}`,`G${sym.sharp}`,`A${sym.demiflat}`,`A${sym.accnull}`,`A${sym.demisharp}`,`A${sym.sharp}`,`B${sym.demiflat}`,`B${sym.accnull}`,`B${sym.demisharp}`]},
|
||
310:{index:310,name:"31-TET",table:[0x0,0x84,0x108,0x18C,0x211,0x295,0x319,0x39D,0x421,0x4A5,0x529,0x5AD,0x632,0x6B6,0x73A,0x7BE,0x842,0x8C6,0x94A,0x9CE,0xA53,0xAD7,0xB5B,0xBDF,0xC63,0xCE7,0xD6B,0xDEF,0xE74,0xEF8,0xF7C],interval:0x1000,t:'m',
|
||
sym:[`C${sym.accnull}`,`C${sym.demisharp}`,`C${sym.sharp}`,`D${sym.flat}`,`D${sym.demiflat}`,`D${sym.accnull}`,`D${sym.demisharp}`,`D${sym.sharp}`,`E${sym.flat}`,`E${sym.demiflat}`,`E${sym.accnull}`,`E${sym.demisharp}`,`F${sym.demiflat}`,`F${sym.accnull}`,`F${sym.demisharp}`,`F${sym.sharp}`,`G${sym.flat}`,`G${sym.demiflat}`,`G${sym.accnull}`,`G${sym.demisharp}`,`G${sym.sharp}`,`A${sym.flat}`,`A${sym.demiflat}`,`A${sym.accnull}`,`A${sym.demisharp}`,`A${sym.sharp}`,`B${sym.flat}`,`B${sym.demiflat}`,`B${sym.accnull}`,`B${sym.demisharp}`,`C${sym.demiflat}`]},
|
||
410:{index:410,name:"41-TET (Kite)",table:[0x0,0x64,0xC8,0x12C,0x190,0x1F4,0x257,0x2BB,0x31F,0x383,0x3E7,0x44B,0x4AF,0x513,0x577,0x5DB,0x63E,0x6A2,0x706,0x76A,0x7CE,0x832,0x896,0x8FA,0x95E,0x9C2,0xA25,0xA89,0xAED,0xB51,0xBB5,0xC19,0xC7D,0xCE1,0xD45,0xDA9,0xE0C,0xE70,0xED4,0xF38,0xF9C],interval:0x1000,t:'m',
|
||
sym:[`${BIGDOT}C-`,`${sym.uptick}C-`,`${sym.doubledntick}C${sym.csharp}`,`${sym.dntick}C${sym.csharp}`,`${BIGDOT}C${sym.csharp}`,`${sym.uptick}C${sym.csharp}`,`${sym.dntick}D-`,`${BIGDOT}D-`,`${sym.uptick}D-`,`${sym.doubledntick}D${sym.csharp}`,`${sym.dntick}D${sym.csharp}`,`${BIGDOT}D${sym.csharp}`,`${sym.uptick}D${sym.csharp}`,`${sym.dntick}E-`,`${BIGDOT}E-`,`${sym.uptick}E-`,`${sym.doubleuptick}E-`,`${BIGDOT}F-`,`${sym.uptick}F-`,`${sym.doubledntick}F${sym.csharp}`,`${sym.dntick}F${sym.csharp}`,`${BIGDOT}F${sym.csharp}`,`${sym.uptick}F${sym.csharp}`,`${sym.dntick}G-`,`${BIGDOT}G-`,`${sym.uptick}G-`,`${sym.doubledntick}G${sym.csharp}`,`${sym.dntick}G${sym.csharp}`,`${BIGDOT}G${sym.csharp}`,`${sym.uptick}G${sym.csharp}`,`${sym.dntick}A-`,`${BIGDOT}A-`,`${sym.uptick}A-`,`${sym.doubledntick}A${sym.csharp}`,`${sym.dntick}A${sym.csharp}`,`${BIGDOT}A${sym.csharp}`,`${sym.uptick}A${sym.csharp}`,`${sym.dntick}B-`,`${BIGDOT}B-`,`${sym.uptick}B-`,`${sym.doubleuptick}B-`]},
|
||
530:{index:530,name:"53-TET (Kite)",table:[0x0,0x4D,0x9B,0xE8,0x135,0x182,0x1D0,0x21D,0x26A,0x2B8,0x305,0x352,0x39F,0x3ED,0x43A,0x487,0x4D5,0x522,0x56F,0x5BC,0x60A,0x657,0x6A4,0x6F2,0x73F,0x78C,0x7D9,0x827,0x874,0x8C1,0x90E,0x95C,0x9A9,0x9F6,0xA44,0xA91,0xADE,0xB2B,0xB79,0xBC6,0xC13,0xC61,0xCAE,0xCFB,0xD48,0xD96,0xDE3,0xE30,0xE7E,0xECB,0xF18,0xF65,0xFB3],interval:0x1000,t:'m',
|
||
sym:[`${BIGDOT}C-`,`${sym.uptick}C-`,`${sym.doubleuptick}C-`,`${sym.doubledntick}C${sym.csharp}`,`${sym.dntick}C${sym.csharp}`,`${BIGDOT}C${sym.csharp}`,`${sym.uptick}C${sym.csharp}`,`${sym.doubledntick}D-`,`${sym.dntick}D-`,`${BIGDOT}D-`,`${sym.uptick}D-`,`${sym.doubleuptick}D-`,`${sym.doubledntick}D${sym.csharp}`,`${sym.dntick}D${sym.csharp}`,`${BIGDOT}D${sym.csharp}`,`${sym.uptick}D${sym.csharp}`,`${sym.doubledntick}E-`,`${sym.dntick}E-`,`${BIGDOT}E-`,`${sym.uptick}E-`,`${sym.doubleuptick}E-`,`${sym.dntick}F-`,`${BIGDOT}F-`,`${sym.uptick}F-`,`${sym.doubleuptick}F-`,`${sym.doubledntick}F${sym.csharp}`,`${sym.dntick}F${sym.csharp}`,`${BIGDOT}F${sym.csharp}`,`${sym.uptick}F${sym.csharp}`,`${sym.doubledntick}G-`,`${sym.dntick}G-`,`${BIGDOT}G-`,`${sym.uptick}G-`,`${sym.doubleuptick}G-`,`${sym.doubledntick}G${sym.csharp}`,`${sym.dntick}G${sym.csharp}`,`${BIGDOT}G${sym.csharp}`,`${sym.uptick}G${sym.csharp}`,`${sym.doubledntick}A-`,`${sym.dntick}A-`,`${BIGDOT}A-`,`${sym.uptick}A-`,`${sym.doubleuptick}A-`,`${sym.doubledntick}A${sym.csharp}`,`${sym.dntick}A${sym.csharp}`,`${BIGDOT}A${sym.csharp}`,`${sym.uptick}A${sym.csharp}`,`${sym.doubledntick}B-`,`${sym.dntick}B-`,`${BIGDOT}B-`,`${sym.uptick}B-`,`${sym.doubleuptick}B-`,`${sym.dntick}C-`]},
|
||
531:{index:531,name:"53-TET (Pythagorean)",table:[0x0,0x4D,0x9B,0xE8,0x135,0x182,0x1D0,0x21D,0x26A,0x2B8,0x305,0x352,0x39F,0x3ED,0x43A,0x487,0x4D5,0x522,0x56F,0x5BC,0x60A,0x657,0x6A4,0x6F2,0x73F,0x78C,0x7D9,0x827,0x874,0x8C1,0x90E,0x95C,0x9A9,0x9F6,0xA44,0xA91,0xADE,0xB2B,0xB79,0xBC6,0xC13,0xC61,0xCAE,0xCFB,0xD48,0xD96,0xDE3,0xE30,0xE7E,0xECB,0xF18,0xF65,0xFB3],interval:0x1000,t:'m',
|
||
sym:[`C${sym.accnull}`,`B${sym.sharp}`,`A${sym.triplesharp}`,`E${sym.tripleflat}`,`D${sym.flat}`,`C${sym.sharp}`,`B${sym.doublesharp}`,`F${sym.tripleflat}`,`E${sym.doubleflat}`,`D${sym.accnull}`,`C${sym.doublesharp}`,`B${sym.triplesharp}`,`F${sym.doubleflat}`,`E${sym.flat}`,`D${sym.sharp}`,`C${sym.triplesharp}`,`G${sym.tripleflat}`,`F${sym.flat}`,`E${sym.accnull}`,`D${sym.doublesharp}`,`C${sym.quadsharp}`,`G${sym.doubleflat}`,`F${sym.accnull}`,`E${sym.sharp}`,`D${sym.triplesharp}`,`A${sym.tripleflat}`,`G${sym.flat}`,`F${sym.sharp}`,`E${sym.doublesharp}`,`D${sym.quadsharp}`,`A${sym.doubleflat}`,`G${sym.accnull}`,`F${sym.doublesharp}`,`E${sym.triplesharp}`,`B${sym.tripleflat}`,`A${sym.flat}`,`G${sym.sharp}`,`F${sym.triplesharp}`,`C${sym.tripleflat}`,`B${sym.doubleflat}`,`A${sym.accnull}`,`G${sym.doublesharp}`,`F${sym.quadsharp}`,`C${sym.doubleflat}`,`B${sym.flat}`,`A${sym.sharp}`,`G${sym.triplesharp}`,`D${sym.tripleflat}`,`C${sym.flat}`,`B${sym.accnull}`,`A${sym.doublesharp}`,`G${sym.quadsharp}`,`D${sym.doubleflat}`]},
|
||
960:{index:960,name:"96-TET (Kite)",table:[0x0,0x2B,0x55,0x80,0xAB,0xD5,0x100,0x12B,0x155,0x180,0x1AB,0x1D5,0x200,0x22B,0x255,0x280,0x2AB,0x2D5,0x300,0x32B,0x355,0x380,0x3AB,0x3D5,0x400,0x42B,0x455,0x480,0x4AB,0x4D5,0x500,0x52B,0x555,0x580,0x5AB,0x5D5,0x600,0x62B,0x655,0x680,0x6AB,0x6D5,0x700,0x72B,0x755,0x780,0x7AB,0x7D5,0x800,0x82B,0x855,0x880,0x8AB,0x8D5,0x900,0x92B,0x955,0x980,0x9AB,0x9D5,0xA00,0xA2B,0xA55,0xA80,0xAAB,0xAD5,0xB00,0xB2B,0xB55,0xB80,0xBAB,0xBD5,0xC00,0xC2B,0xC55,0xC80,0xCAB,0xCD5,0xD00,0xD2B,0xD55,0xD80,0xDAB,0xDD5,0xE00,0xE2B,0xE55,0xE80,0xEAB,0xED5,0xF00,0xF2B,0xF55,0xF80,0xFAB,0xFD5],interval:0x1000,t:'m',
|
||
sym:[`${BIGDOT}C-`,`${sym.uptick}C-`,`${sym.doubleuptick}C-`,`${sym.dntick}C${sym.cdemisharp}`,`${BIGDOT}C${sym.cdemisharp}`,`${sym.uptick}C${sym.cdemisharp}`,`${sym.doubleuptick}C${sym.cdemisharp}`,`${sym.dntick}C${sym.csharp}`,`${BIGDOT}C${sym.csharp}`,`${sym.uptick}C${sym.csharp}`,`${sym.doubleuptick}C${sym.csharp}`,`${sym.dntick}D${sym.cdemiflat}`,`${BIGDOT}D${sym.cdemiflat}`,`${sym.uptick}D${sym.cdemiflat}`,`${sym.doubleuptick}D${sym.cdemiflat}`,`${sym.dntick}D-`,`${BIGDOT}D-`,`${sym.uptick}D-`,`${sym.doubleuptick}D-`,`${sym.dntick}D${sym.cdemisharp}`,`${BIGDOT}D${sym.cdemisharp}`,`${sym.uptick}D${sym.cdemisharp}`,`${sym.doubleuptick}D${sym.cdemisharp}`,`${sym.dntick}D${sym.csharp}`,`${BIGDOT}D${sym.csharp}`,`${sym.uptick}D${sym.csharp}`,`${sym.doubleuptick}D${sym.csharp}`,`${sym.dntick}E${sym.cdemiflat}`,`${BIGDOT}E${sym.cdemiflat}`,`${sym.uptick}E${sym.cdemiflat}`,`${sym.doubleuptick}E${sym.cdemiflat}`,`${sym.dntick}E-`,`${BIGDOT}E-`,`${sym.uptick}E-`,`${sym.doubleuptick}E-`,`${sym.dntick}E${sym.cdemisharp}`,`${BIGDOT}E${sym.cdemisharp}`,`${sym.uptick}E${sym.cdemisharp}`,`${sym.doubleuptick}E${sym.cdemisharp}`,`${sym.dntick}F-`,`${BIGDOT}F-`,`${sym.uptick}F-`,`${sym.doubleuptick}F-`,`${sym.dntick}F${sym.cdemisharp}`,`${BIGDOT}F${sym.cdemisharp}`,`${sym.uptick}F${sym.cdemisharp}`,`${sym.doubleuptick}F${sym.cdemisharp}`,`${sym.dntick}F${sym.csharp}`,`${BIGDOT}F${sym.csharp}`,`${sym.uptick}F${sym.csharp}`,`${sym.doubleuptick}F${sym.csharp}`,`${sym.dntick}G${sym.cdemiflat}`,`${BIGDOT}G${sym.cdemiflat}`,`${sym.uptick}G${sym.cdemiflat}`,`${sym.doubleuptick}G${sym.cdemiflat}`,`${sym.dntick}G-`,`${BIGDOT}G-`,`${sym.uptick}G-`,`${sym.doubleuptick}G-`,`${sym.dntick}G${sym.cdemisharp}`,`${BIGDOT}G${sym.cdemisharp}`,`${sym.uptick}G${sym.cdemisharp}`,`${sym.doubleuptick}G${sym.cdemisharp}`,`${sym.dntick}G${sym.csharp}`,`${BIGDOT}G${sym.csharp}`,`${sym.uptick}G${sym.csharp}`,`${sym.doubleuptick}G${sym.csharp}`,`${sym.dntick}A${sym.cdemiflat}`,`${BIGDOT}A${sym.cdemiflat}`,`${sym.uptick}A${sym.cdemiflat}`,`${sym.doubleuptick}A${sym.cdemiflat}`,`${sym.dntick}A-`,`${BIGDOT}A-`,`${sym.uptick}A-`,`${sym.doubleuptick}A-`,`${sym.dntick}A${sym.cdemisharp}`,`${BIGDOT}A${sym.cdemisharp}`,`${sym.uptick}A${sym.cdemisharp}`,`${sym.doubleuptick}A${sym.cdemisharp}`,`${sym.dntick}A${sym.csharp}`,`${BIGDOT}A${sym.csharp}`,`${sym.uptick}A${sym.csharp}`,`${sym.doubleuptick}A${sym.csharp}`,`${sym.dntick}B${sym.cdemiflat}`,`${BIGDOT}B${sym.cdemiflat}`,`${sym.uptick}B${sym.cdemiflat}`,`${sym.doubleuptick}B${sym.cdemiflat}`,`${sym.dntick}B-`,`${BIGDOT}B-`,`${sym.uptick}B-`,`${sym.doubleuptick}B-`,`${sym.dntick}B${sym.cdemisharp}`,`${BIGDOT}B${sym.cdemisharp}`,`${sym.uptick}B${sym.cdemisharp}`,`${sym.doubleuptick}B${sym.cdemisharp}`,`${sym.dntick}C-`]},
|
||
/* 12-TET variations */
|
||
120:{index:120,name:"12-TET",table:[0x0,0x155,0x2AB,0x400,0x555,0x6AB,0x800,0x955,0xAAB,0xC00,0xD55,0xEAB],interval:0x1000,t:'d',
|
||
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym.accnull}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.accnull}`]},
|
||
10121:{index:10121,name:"Pythagorean dim. 5th",table:[0x0,0x134,0x2B8,0x3EC,0x570,0x6A4,0x7D8,0x95C,0xA90,0xC14,0xD48,0xECC],interval:0x1000,t:'d',
|
||
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym.accnull}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.accnull}`]},
|
||
10122:{index:10122,name:"Pythagorean aug. 4th",table:[0x0,0x134,0x2B8,0x3EC,0x570,0x6A4,0x828,0x95C,0xA90,0xC14,0xD48,0xECC],interval:0x1000,t:'d',
|
||
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`D${sym.sharp}`,`E${sym.accnull}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.accnull}`,`G${sym.sharp}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.accnull}`]},
|
||
10123:{index:10123,name:"\u00FC\u00FD\u00FE (shi'er lu)", table:[0x0,0x184,0x2B8,0x43C,0x570,0x6F4,0x828,0x95C,0xAE0,0xC14,0xD98,0xECC],interval:0x1000,t:'d',
|
||
sym:[` \u00E0\u00E1`,` \u00E2\u00E3`,` \u00E4\u00E5`,` \u00E6\u00E7`,` \u00E8\u00E9`,` \u00EA\u00EB`,` \u00EC\u00ED`,` \u00EE\u00EF`,` \u00F0\u00F1`,` \u00F2\u00F3`,` \u00F4\u00F5`,` \u00F6\u00F7`]},
|
||
/* non-octave */
|
||
35130:{index:35130,name:"Equal-Tempered Bohlen-Pierce",table:[0x0,0x1F3,0x3E7,0x5DA,0x7CE,0x9C1,0xBB4,0xDA8,0xF9B,0x118E,0x1382,0x1575,0x1769],interval:0x195C,t:'M',
|
||
sym:[`C${sym.accnull}`,`C${sym.sharp}`,`D${sym.accnull}`,`E${sym.accnull}`,`F${sym.accnull}`,`F${sym.sharp}`,`G${sym.accnull}`,`H${sym.accnull}`,`H${sym.sharp}`,`J${sym.accnull}`,`A${sym.accnull}`,`A${sym.sharp}`,`B${sym.accnull}`]},
|
||
|
||
|
||
}
|
||
|
||
// check pitchTablePresets integrity
|
||
function checkPitchTablePresetsIntegrity() {
|
||
const seenIndices = {}
|
||
for (const key in pitchTablePresets) {
|
||
const preset = pitchTablePresets[key]
|
||
const keyNum = +key
|
||
if (preset == null) throw Error(`pitchTablePresets[${key}] is null/undefined`)
|
||
if (typeof preset.index !== 'number') throw Error(`pitchTablePresets[${key}].index is not a number`)
|
||
if (preset.index !== keyNum) throw Error(`pitchTablePresets[${key}].index (${preset.index}) does not match its key (${key})`)
|
||
if (seenIndices[preset.index]) throw Error(`duplicate index ${preset.index} in pitchTablePresets`)
|
||
seenIndices[preset.index] = true
|
||
if (typeof preset.name !== 'string') throw Error(`pitchTablePresets[${key}].name is not a string`)
|
||
if (!Array.isArray(preset.table)) throw Error(`pitchTablePresets[${key}].table is not an array`)
|
||
if (!Array.isArray(preset.sym)) throw Error(`pitchTablePresets[${key}].sym is not an array`)
|
||
if (preset.table.length !== preset.sym.length) throw Error(`pitchTablePresets[${key}] (${preset.name}): table.length (${preset.table.length}) != sym.length (${preset.sym.length})`)
|
||
for (let i = 0; i < preset.table.length; i++) {
|
||
const v = preset.table[i]
|
||
if (typeof v !== 'number' || !Number.isFinite(v)) throw Error(`pitchTablePresets[${key}] (${preset.name}): table[${i}] is not a finite number`)
|
||
if (i > 0 && v <= preset.table[i - 1]) throw Error(`pitchTablePresets[${key}] (${preset.name}): table is not strictly ascending at index ${i} (0x${preset.table[i-1].toString(16)} -> 0x${v.toString(16)})`)
|
||
}
|
||
for (let i = 0; i < preset.sym.length; i++) {
|
||
if (typeof preset.sym[i] !== 'string') throw Error(`pitchTablePresets[${key}] (${preset.name}): sym[${i}] is not a string`)
|
||
}
|
||
}
|
||
}
|
||
checkPitchTablePresetsIntegrity()
|
||
|
||
const volEffSym = [sym.volset, sym.volup, sym.voldn, sym.volfineup, sym.volfinedn]
|
||
const panEffSym = [sym.panset, sym.panri, sym.panle, sym.panfineri, sym.panfinele]
|
||
|
||
const colNote = 239
|
||
const colInst = 114
|
||
const colVol = 155
|
||
const colPan = 219
|
||
const colEffOp = 220
|
||
const colEffArg = 231
|
||
const colBackPtn = 255
|
||
|
||
const PITCH_PRESET_IDX_DEFAULT = 120
|
||
let PITCH_PRESET_IDX = PITCH_PRESET_IDX_DEFAULT // TODO read from the Project Data section of the .taud
|
||
let beatDivPrimary = 4 // TODO read from the Project Data section of the .taud
|
||
let beatDivSecondary = 16
|
||
let hasUnsavedChanges = false
|
||
let patternsOutOfSync = false // in-memory song.patterns has edits not yet pushed to the audio adapter
|
||
|
||
// Pitch encoding: a 16-bit absolute value with Middle C anchored at 0x5000.
|
||
// For octave systems (interval == 0x1000) the value decomposes naturally as
|
||
// (octave << 12) | pitchInOctave. For non-octave systems the "period" (e.g.
|
||
// the BP tritave at 0x195C) does not align with 4-bit boundaries; the period
|
||
// index and offset must be computed by integer-divmod against the interval,
|
||
// using ANCHOR_NOTE / ANCHOR_PERIOD as the fixed reference point.
|
||
const ANCHOR_NOTE = 0x5000
|
||
const ANCHOR_PERIOD = 5
|
||
function decomposeNote(note, interval) {
|
||
const delta = note - ANCHOR_NOTE
|
||
const k = Math.floor(delta / interval)
|
||
return [ANCHOR_PERIOD + k, delta - k * interval]
|
||
}
|
||
function composeNote(periodIdx, offset, interval) {
|
||
return ANCHOR_NOTE + (periodIdx - ANCHOR_PERIOD) * interval + offset
|
||
}
|
||
|
||
// pitchSymLut[offsetInPeriod] = [symString, periodOffset]
|
||
// periodOffset is 1 when offsetInPeriod is closer to the next period's root
|
||
// (one `interval` above) than to any table entry — i.e. the note should wrap
|
||
// up to the first entry of the next period.
|
||
// Call rebuildPitchLut() whenever PITCH_PRESET_IDX changes; the LUT is sized
|
||
// to the preset's interval so non-octave tunings (e.g. BP at 0x195C) work.
|
||
let pitchSymLut = new Array(0x1000)
|
||
|
||
function rebuildPitchLut() {
|
||
const preset = pitchTablePresets[PITCH_PRESET_IDX]
|
||
if (!preset || preset.table.length === 0) return
|
||
const table = preset.table
|
||
const syms = preset.sym
|
||
const interval = preset.interval
|
||
if (pitchSymLut.length !== interval) pitchSymLut = new Array(interval)
|
||
for (let p = 0; p < interval; p++) {
|
||
let best = 0, bestDist = interval
|
||
for (let i = 0; i < table.length; i++) {
|
||
const d = Math.abs(p - table[i])
|
||
if (d < bestDist) { bestDist = d; best = i }
|
||
}
|
||
// Distance to the next period's root (one interval up) vs nearest table entry.
|
||
if ((interval - p) < bestDist) {
|
||
pitchSymLut[p] = [syms[0], 1]
|
||
} else {
|
||
pitchSymLut[p] = [syms[best], 0]
|
||
}
|
||
}
|
||
}
|
||
rebuildPitchLut()
|
||
|
||
// Tonal-tension function used by the 'cadence' retune method. Implements
|
||
// the tonal-distance term D_tonic from cadential_motion.md §3-§4 by locating
|
||
// each pitch in fifth-circle space relative to `tonic`. The abstract 3:2
|
||
// fifth (0x95A in 0x1000-per-octave units, ≈ 702 cents) is used as the
|
||
// fifth-circle generator, which is tuning-agnostic — the same landscape
|
||
// applies whether the candidate sits in 5-TET, 12-TET, 22-TET, etc.
|
||
//
|
||
// For each integer k in [-6, 6], target_k = (k * 0x95A) mod 0x1000 is the
|
||
// k-th fifth-stack position above the tonic (in pitch-class space). Tension
|
||
// = |k|*0x100 + |d - target_k|_cyclic, so well-tuned fifth-circle positions
|
||
// get low values: tonic 0, P5/P4 ≈ 0x105, M2/m7 ≈ 0x209, M6/m3 ≈ 0x30E,
|
||
// M3/m6 ≈ 0x413, M7/m2 ≈ 0x517, tritone ≈ 0x61C. Pitches that don't sit on
|
||
// any fifth-stack position degrade gracefully via the residual term.
|
||
//
|
||
// The k=0 path is gated to a narrow tonic neighbourhood (TONIC_TOL ≈ 30c).
|
||
// Otherwise a leading tone would score as "very close to tonic in pitch-
|
||
// class space" and pick up an artificially low tension via k=0, masking the
|
||
// real musical fact that it's at fifth-circle distance 5 from tonic and
|
||
// hence highly tense (cf. Krumhansl's tonal hierarchy: B is the least
|
||
// stable diatonic note in C, despite sitting a semitone below C).
|
||
function _cadTension(p, tonic, interval) {
|
||
const FIFTH_PC = 0x95A
|
||
const TONIC_TOL = 0x40
|
||
const half = interval >>> 1
|
||
const d = ((p - tonic) % interval + interval) % interval
|
||
const cyclic = (d <= half) ? d : (interval - d)
|
||
let bestT = (cyclic <= TONIC_TOL) ? cyclic : Infinity
|
||
for (let k = -6; k <= 6; k++) {
|
||
if (k === 0) continue
|
||
const target = ((k * FIFTH_PC) % interval + interval) % interval
|
||
let dist = Math.abs(d - target)
|
||
if (dist > half) dist = interval - dist
|
||
const candT = Math.abs(k) * 0x100 + dist
|
||
if (candT < bestT) bestT = candT
|
||
}
|
||
return bestT
|
||
}
|
||
|
||
// Just-intonation reference ratios (in 0x1000-per-octave units) and pull
|
||
// weights used as the harmonic attractor field A(P) for the 'harmonic'
|
||
// retune method (see cadence_aware_nearest_harmonic.md §4A). Lower weight
|
||
// = simpler ratio = stronger pull. Cost of a candidate is the minimum
|
||
// weight*distance across all references.
|
||
const _HARM_REFS = [
|
||
[0, 1.0], // 1:1 unison / 2:1 octave
|
||
[0x1D2, 4.0], // 9:8 major tone
|
||
[0x435, 3.0], // 6:5 minor third
|
||
[0x527, 3.0], // 5:4 major third
|
||
[0x6A4, 2.0], // 4:3 perfect fourth
|
||
[0x95B, 2.0], // 3:2 perfect fifth
|
||
[0xAB7, 3.0], // 8:5 minor sixth
|
||
[0xBCB, 3.0], // 5:3 major sixth
|
||
[0xD3D, 4.0], // 9:5 minor seventh
|
||
]
|
||
function _harmonicCost(p, tonic, interval) {
|
||
const half = interval >>> 1
|
||
const d = ((p - tonic) % interval + interval) % interval
|
||
let best = Infinity
|
||
for (let i = 0; i < _HARM_REFS.length; i++) {
|
||
const ref = _HARM_REFS[i]
|
||
let dist = Math.abs(d - ref[0])
|
||
if (dist > half) dist = interval - dist
|
||
const cost = ref[1] * dist
|
||
if (cost < best) best = cost
|
||
}
|
||
return best
|
||
}
|
||
|
||
// Remap every note in every pattern of the current song to `newIdx`'s pitch
|
||
// table, then switch PITCH_PRESET_IDX. Special note values (empty/cut/keyoff)
|
||
// are left alone.
|
||
//
|
||
// Four mapping methods are supported:
|
||
// 'pitch' (nearest-note) — each note's lower 12 bits snap to the closest
|
||
// entry in the new table. Pitches closer to the next octave's root
|
||
// (0x1000) than to any table entry wrap up by one octave (mirrors
|
||
// rebuildPitchLut's octaveOffset logic).
|
||
// 'delta' (nearest-delta) — per pattern, the first non-empty note uses the
|
||
// nearest-pitch rule; each subsequent note is chosen so that the
|
||
// interval from the previously mapped note is closest to the interval
|
||
// between the corresponding original notes. Candidates are drawn from
|
||
// the table across adjacent octaves so the mapping can cross octave
|
||
// boundaries naturally.
|
||
// 'cadence' (nearest-cadence) — per pattern, the first non-empty note's
|
||
// pitch class is taken as the tonic and the first note uses the
|
||
// nearest-pitch rule. Each subsequent note is chosen so that the
|
||
// change in tonal tension (see _cadTension) from the previously
|
||
// mapped note matches the change in the original sequence, with raw
|
||
// pitch displacement as a tiebreaker. This preserves cadential
|
||
// trajectories — V→I-style descents stay V→I-style — rather than
|
||
// absolute pitch positions or raw intervals, mirroring the framing in
|
||
// cadential_motion.md §2 (motion along -∇T) and §9 (trajectories
|
||
// carry cadentiality better than coordinates).
|
||
// 'harmonic' (cadence-aware nearest-harmonic) — implements
|
||
// P_n = P_{n-1} + Q(Δ_n) + λ_n A(P_n) from
|
||
// cadence_aware_nearest_harmonic.md §1. Per pattern, the first
|
||
// non-empty note's pitch class is taken as the tonic. Each subsequent
|
||
// note is scored as pitchErr + λ_n * harmonicCost where λ_n
|
||
// = 1 − exp(−(duration−1)/4), with duration measured in rows until
|
||
// the next event in the (still-original) row sequence. Short notes
|
||
// get λ ≈ 0 and behave like nearest-delta — "freedom during travel"
|
||
// (§10) — while sustained / pattern-end notes approach λ → 1 and lock
|
||
// onto the JI attractor field — "precision during landing".
|
||
function retuneAllPatterns(newIdx, method) {
|
||
if (method !== 'delta' && method !== 'cadence' && method !== 'harmonic') method = 'pitch'
|
||
const newPreset = pitchTablePresets[newIdx]
|
||
if (!newPreset) return
|
||
const srcPreset = pitchTablePresets[PITCH_PRESET_IDX]
|
||
const newTable = newPreset.table
|
||
const newInterval = newPreset.interval
|
||
// Tension/harmonic shapes are read out of the SOURCE tuning's modular
|
||
// space — they describe the composition the user wrote, not the snap
|
||
// grid we're mapping onto. For octave→octave retunes this collapses to
|
||
// the original behaviour (both intervals are 0x1000).
|
||
const srcInterval = srcPreset.interval || 0x1000
|
||
|
||
// Yield candidate absolute pitches in the new tuning whose period root
|
||
// lies within ±1 period of `absRef`. Includes the next period's root
|
||
// itself so a target that lands just past the top entry can snap up.
|
||
const forEachCandidate = (absRef, fn) => {
|
||
const baseK = Math.floor((absRef - ANCHOR_NOTE) / newInterval)
|
||
for (let dK = -1; dK <= 1; dK++) {
|
||
const root = ANCHOR_NOTE + (baseK + dK) * newInterval
|
||
for (let i = 0; i < newTable.length; i++) {
|
||
const cand = root + newTable[i]
|
||
if (cand >= 0 && cand <= 0xFFFF) fn(cand)
|
||
}
|
||
const nextRoot = root + newInterval
|
||
if (nextRoot >= 0 && nextRoot <= 0xFFFF) fn(nextRoot)
|
||
}
|
||
}
|
||
|
||
if (newTable.length > 0) {
|
||
for (let p = 0; p < song.numPats; p++) {
|
||
const ptn = song.patterns[p]
|
||
let prevOrigAbs = -1
|
||
let prevMappedAbs = 0
|
||
let tonic = 0
|
||
if (method === 'cadence' || method === 'harmonic') {
|
||
for (let row = 0; row < ROWS_PER_PAT; row++) {
|
||
const off = 8 * row
|
||
const note = ptn[off] | (ptn[off+1] << 8)
|
||
if (note === 0x0000 || note === 0x0001 || note === 0x0002 || (note >= 0x0010 && note <= 0x001F)) continue
|
||
// Use the full absolute pitch as tonic; the modular ops
|
||
// in _cadTension / _harmonicCost normalise it.
|
||
tonic = note
|
||
break
|
||
}
|
||
}
|
||
for (let row = 0; row < ROWS_PER_PAT; row++) {
|
||
const off = 8 * row
|
||
const note = ptn[off] | (ptn[off+1] << 8)
|
||
if (note === 0x0000 || note === 0x0001 || note === 0x0002 || (note >= 0x0010 && note <= 0x001F)) continue
|
||
const origAbs = note
|
||
let newAbs
|
||
if ((method === 'delta' || method === 'cadence' || method === 'harmonic') && prevOrigAbs >= 0) {
|
||
const targetAbs = prevMappedAbs + (origAbs - prevOrigAbs)
|
||
let targetDeltaT = 0, tMappedPrev = 0, lambda = 0
|
||
if (method === 'cadence') {
|
||
targetDeltaT = _cadTension(origAbs, tonic, srcInterval) - _cadTension(prevOrigAbs, tonic, srcInterval)
|
||
tMappedPrev = _cadTension(prevMappedAbs, tonic, srcInterval)
|
||
} else if (method === 'harmonic') {
|
||
let duration = 1
|
||
for (let r = row + 1; r < ROWS_PER_PAT; r++) {
|
||
const noff = 8 * r
|
||
const n = ptn[noff] | (ptn[noff+1] << 8)
|
||
if (n !== 0x0001) break
|
||
duration++
|
||
}
|
||
lambda = 1 - Math.exp(-(duration - 1) / 4)
|
||
}
|
||
let bestAbs = 0, bestScore = Infinity
|
||
forEachCandidate(targetAbs, (cand) => {
|
||
const pitchErr = Math.abs(cand - targetAbs)
|
||
let score = pitchErr
|
||
if (method === 'cadence') {
|
||
const candDeltaT = _cadTension(cand, tonic, srcInterval) - tMappedPrev
|
||
score = Math.abs(candDeltaT - targetDeltaT) * 2 + pitchErr
|
||
} else if (method === 'harmonic') {
|
||
score = pitchErr + lambda * _harmonicCost(cand, tonic, srcInterval)
|
||
}
|
||
if (score < bestScore) { bestScore = score; bestAbs = cand }
|
||
})
|
||
newAbs = bestAbs
|
||
} else {
|
||
// Nearest-pitch: snap source absolute pitch to the closest
|
||
// entry in the new tuning's snap grid.
|
||
let bestAbs = 0, bestDist = Infinity
|
||
forEachCandidate(origAbs, (cand) => {
|
||
const d = Math.abs(cand - origAbs)
|
||
if (d < bestDist) { bestDist = d; bestAbs = cand }
|
||
})
|
||
newAbs = bestAbs
|
||
}
|
||
if (newAbs < 0) newAbs = 0
|
||
if (newAbs > 0xFFFF) newAbs = 0xFFFF
|
||
const newNote = newAbs & 0xFFFF
|
||
ptn[off] = newNote & 0xFF
|
||
ptn[off+1] = (newNote >>> 8) & 0xFF
|
||
prevOrigAbs = origAbs
|
||
prevMappedAbs = newAbs
|
||
}
|
||
}
|
||
hasUnsavedChanges = true
|
||
patternsOutOfSync = true
|
||
}
|
||
PITCH_PRESET_IDX = newIdx
|
||
rebuildPitchLut()
|
||
}
|
||
|
||
Number.prototype.hex02 = function() {
|
||
return this.toString(16).toUpperCase().padStart(2,'0')
|
||
}
|
||
Number.prototype.hex03 = function() {
|
||
return this.toString(16).toUpperCase().padStart(3,'0')
|
||
}
|
||
Number.prototype.hex04 = function() {
|
||
return this.toString(16).toUpperCase().padStart(4,'0')
|
||
}
|
||
Number.prototype.hexD2 = function() {
|
||
return this.toString(16).toUpperCase().padStart(2, sym.middot)
|
||
}
|
||
Number.prototype.hex1 = function() {
|
||
return this.toString(16).toUpperCase()
|
||
}
|
||
Number.prototype.dec02 = function() {
|
||
return this.toString(10).toUpperCase().padStart(2,'0')
|
||
}
|
||
Number.prototype.decD2 = function() {
|
||
return this.toString(10).toUpperCase().padStart(2, sym.middot)
|
||
}
|
||
|
||
|
||
function noteToStr(note) {
|
||
if (note === 0x0000) return sym.middot.repeat(4)
|
||
if (note === 0x0001) return sym.keyoff
|
||
if (note === 0x0002) return sym.notecut
|
||
if (note >= 0x0010 && note <= 0x001F) return ('Int' + (note & 0xF).toString(16).toUpperCase()).padEnd(4)
|
||
const preset = pitchTablePresets[PITCH_PRESET_IDX]
|
||
if (preset.table.length === 0) return note.hex04()
|
||
const [period, offset] = decomposeNote(note, preset.interval)
|
||
const [s, o] = pitchSymLut[offset]
|
||
return s + (period - 1 + o).toString(16) // period 10 -> 'a'
|
||
}
|
||
|
||
/**
|
||
* Builds the coloured string fragments for a single row of pattern data.
|
||
*/
|
||
function buildRowCell(ptnDat, row) {
|
||
const off = 8 * row
|
||
|
||
const note = ptnDat[off] | (ptnDat[off+1] << 8)
|
||
const inst = ptnDat[off+2]
|
||
const voleff = ptnDat[off+3]
|
||
const voleffarg = voleff & 63
|
||
const paneff = ptnDat[off+4]
|
||
const paneffarg = paneff & 63
|
||
const effop = ptnDat[off+5]
|
||
const effarg = ptnDat[off+6] | (ptnDat[off+7] << 8)
|
||
|
||
const sNote = noteToStr(note)
|
||
|
||
let sInst = inst.hexD2()
|
||
if (inst == 0) sInst = sym.middot.repeat(2)
|
||
|
||
let sVolEff = volEffSym[voleff >>> 6]
|
||
let sVolArg = voleffarg.hexD2()
|
||
if (voleff === 0xC0) {
|
||
sVolEff = ''
|
||
sVolArg = sym.middot.repeat(2)
|
||
}
|
||
else if (voleff >>> 6 == 1 || voleff >>> 6 == 2) {
|
||
sVolArg = (voleffarg & 15).hex1()
|
||
}
|
||
else if (voleff >>> 6 == 3) {
|
||
if (voleffarg == 0) {
|
||
sVolEff = sym.middot
|
||
sVolArg = sym.middot
|
||
}
|
||
else if (voleffarg >= 32) {
|
||
sVolEff = volEffSym[3]
|
||
sVolArg = (voleffarg & 15).hex1()
|
||
}
|
||
else {
|
||
sVolEff = volEffSym[4]
|
||
sVolArg = (voleffarg & 15).hex1()
|
||
}
|
||
}
|
||
|
||
let sPanEff = panEffSym[paneff >>> 6]
|
||
let sPanArg = paneffarg.hexD2()
|
||
if (paneff === 0xC0) {
|
||
sPanEff = ''
|
||
sPanArg = sym.middot.repeat(2)
|
||
}
|
||
else if (paneff >>> 6 == 1 || paneff >>> 6 == 2) {
|
||
sPanArg = (paneffarg & 15).hex1()
|
||
}
|
||
else if (paneff >>> 6 == 3) {
|
||
if (paneffarg == 0) {
|
||
sPanEff = sym.middot
|
||
sPanArg = sym.middot
|
||
}
|
||
else if (paneffarg >= 32) {
|
||
sPanEff = panEffSym[4]
|
||
sPanArg = (paneffarg & 15).hex1()
|
||
}
|
||
else {
|
||
sPanEff = panEffSym[3]
|
||
sPanArg = (paneffarg & 15).hex1()
|
||
}
|
||
}
|
||
|
||
let sEffOp = (effop > 0) ? effop.toString(36).toUpperCase()[0] : sym.middot
|
||
let sEffArg = effarg.hex04()
|
||
if (effop === 0 && effarg === 0) {
|
||
sEffOp = sym.middot
|
||
sEffArg = sym.middot.repeat(4)
|
||
}
|
||
|
||
return { sNote, sInst, sVolEff, sVolArg, sPanEff, sPanArg, sEffOp, sEffArg,
|
||
_note: note, _effop: effop, _effarg: effarg, _voleff: voleff, _paneff: paneff }
|
||
}
|
||
|
||
const EMPTY_CELL = {
|
||
sNote: sym.middot.repeat(4),
|
||
sInst: sym.middot.repeat(2),
|
||
sVolEff: '',
|
||
sVolArg: sym.middot.repeat(2),
|
||
sPanEff: '',
|
||
sPanArg: sym.middot.repeat(2),
|
||
sEffOp: sym.middot,
|
||
sEffArg: sym.middot.repeat(4),
|
||
_note: 0x0000, _effop: 0, _effarg: 0, _voleff: 0, _paneff: 0
|
||
}
|
||
|
||
function drawCellAt(y, x, cell, back) {
|
||
con.move(y, x)
|
||
con.color_pair(colNote, back); print(cell.sNote)
|
||
con.color_pair(colInst, back); print(cell.sInst)
|
||
con.color_pair(colVol, back); print(cell.sVolEff)
|
||
con.color_pair(colVol, back); print(cell.sVolArg)
|
||
con.color_pair(colPan, back); print(cell.sPanEff)
|
||
con.color_pair(colPan, back); print(cell.sPanArg)
|
||
con.color_pair(colEffOp, back); print(cell.sEffOp)
|
||
con.color_pair(colEffArg, back); print(cell.sEffArg)
|
||
}
|
||
|
||
// Styles: -1 = spaced (dddd ii vv pp effff, 19 chars)
|
||
// 0 = compact/current (15 chars)
|
||
// 1 = non-NOP preference note/fx + vol/pan (7 chars: 5+2, letters start on border)
|
||
// 2 = non-NOP preference note/fx only (5 chars, letters start on border)
|
||
function drawCellAtStyled(y, x, cell, back, style) {
|
||
if (style === 0) { drawCellAt(y, x, cell, back); return }
|
||
if (style === -1) {
|
||
con.move(y, x)
|
||
con.color_pair(colNote, back); print(cell.sNote)
|
||
con.color_pair(colBackPtn, back); print(' ')
|
||
con.color_pair(colInst, back); print(cell.sInst)
|
||
con.color_pair(colBackPtn, back); print(' ')
|
||
con.color_pair(colVol, back); print(cell.sVolEff); print(cell.sVolArg)
|
||
con.color_pair(colBackPtn, back); print(' ')
|
||
con.color_pair(colPan, back); print(cell.sPanEff); print(cell.sPanArg)
|
||
con.color_pair(colBackPtn, back); print(' ')
|
||
con.color_pair(colEffOp, back); print(cell.sEffOp)
|
||
con.color_pair(colEffArg, back); print(cell.sEffArg)
|
||
return
|
||
}
|
||
// Styles 1 and 2: note-or-fx field (5 chars) starts on the border column [+ vol-or-pan (2 chars)]
|
||
const noteEmpty = (cell._note === 0x0000)
|
||
const fxEmpty = (cell._effop === 0 && cell._effarg === 0)
|
||
const volEmpty = (cell._voleff === 0)
|
||
const panEmpty = (cell._paneff === 0)
|
||
con.move(y, x)
|
||
if (!noteEmpty) {
|
||
con.color_pair(colBackPtn, back); print(' ')
|
||
con.color_pair(colNote, back); print(cell.sNote)
|
||
} else if (!fxEmpty) {
|
||
con.color_pair(colEffOp, back); print(cell.sEffOp)
|
||
con.color_pair(colEffArg, back); print(cell.sEffArg)
|
||
} else {
|
||
con.color_pair(colNote, back); print(sym.middot.repeat(5))
|
||
}
|
||
if (style === 1) {
|
||
//con.color_pair(colBackPtn, back); print(' ')
|
||
if (!volEmpty) {
|
||
con.color_pair(colVol, back); print(cell.sVolEff); print(cell.sVolArg)
|
||
} else if (!panEmpty) {
|
||
con.color_pair(colPan, back); print(cell.sPanEff); print(cell.sPanArg)
|
||
} else {
|
||
con.color_pair(colVol, back); print(sym.middot.repeat(2))
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// .TAUD FILE LOADER
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]
|
||
const TAUD_HEADER_SIZE = 32
|
||
const TAUD_SONG_ENTRY = 32
|
||
const PATTERN_SIZE = 512
|
||
const ROWS_PER_PAT = 64
|
||
const NUM_CUES = 1024
|
||
const CUE_SIZE = 32
|
||
const NUM_VOICES = 20
|
||
const CUE_EMPTY = 0xFFF
|
||
|
||
function _peekU32LE(ptr, off) {
|
||
return ((sys.peek(ptr+off) & 0xFF) ) |
|
||
((sys.peek(ptr+off+1) & 0xFF) << 8 ) |
|
||
((sys.peek(ptr+off+2) & 0xFF) << 16 ) |
|
||
((sys.peek(ptr+off+3) & 0xFF) * 0x1000000)
|
||
}
|
||
|
||
function loadTaud(filePath, songIndex) {
|
||
const fh = files.open(filePath)
|
||
if (!fh.exists) throw Error(`taut: file not exists: ${filePath}`)
|
||
const fileSize = fh.size
|
||
const ptr = sys.malloc(fileSize)
|
||
fh.pread(ptr, fileSize, 0)
|
||
fh.close()
|
||
|
||
for (let i = 0; i < 8; i++) {
|
||
if ((sys.peek(ptr + i) & 0xFF) !== TAUD_MAGIC[i]) {
|
||
sys.free(ptr)
|
||
throw Error(`taut: bad magic byte at ${i}`)
|
||
}
|
||
}
|
||
|
||
const version = sys.peek(ptr + 8) & 0xFF
|
||
const numSongs = sys.peek(ptr + 9) & 0xFF
|
||
const compSize = _peekU32LE(ptr, 10)
|
||
|
||
if (songIndex < 0 || songIndex >= numSongs) {
|
||
sys.free(ptr)
|
||
throw Error(`taut: song index ${songIndex} out of range (numSongs=${numSongs})`)
|
||
}
|
||
|
||
const songTableOff = TAUD_HEADER_SIZE + compSize
|
||
const entryOff = songTableOff + songIndex * TAUD_SONG_ENTRY
|
||
|
||
const songOff = _peekU32LE(ptr, entryOff)
|
||
const numVoices = sys.peek(ptr + entryOff + 4) & 0xFF
|
||
const numPats = (sys.peek(ptr + entryOff + 5) & 0xFF) |
|
||
((sys.peek(ptr + entryOff + 6) & 0xFF) << 8)
|
||
const bpmStored = sys.peek(ptr + entryOff + 7) & 0xFF
|
||
const tickRate = sys.peek(ptr + entryOff + 8) & 0xFF
|
||
const patBinCompSize = _peekU32LE(ptr, entryOff + 18)
|
||
const cueSheetCompSize = _peekU32LE(ptr, entryOff + 22)
|
||
|
||
// Decompress pattern bin
|
||
const patBinSize = numPats * PATTERN_SIZE
|
||
const patBinPtr = sys.malloc(patBinSize)
|
||
gzip.decompFromTo(ptr + songOff, patBinCompSize, patBinPtr)
|
||
|
||
const patterns = new Array(numPats)
|
||
for (let p = 0; p < numPats; p++) {
|
||
const ptn = new Uint8Array(PATTERN_SIZE)
|
||
for (let k = 0; k < PATTERN_SIZE; k++) {
|
||
ptn[k] = sys.peek(patBinPtr + p * PATTERN_SIZE + k) & 0xFF
|
||
}
|
||
patterns[p] = ptn
|
||
}
|
||
sys.free(patBinPtr)
|
||
|
||
// Decompress cue sheet
|
||
const cueSheetSize = NUM_CUES * CUE_SIZE
|
||
const cueSheetPtr = sys.malloc(cueSheetSize)
|
||
gzip.decompFromTo(ptr + songOff + patBinCompSize, cueSheetCompSize, cueSheetPtr)
|
||
|
||
const cues = new Array(NUM_CUES)
|
||
let lastActiveCue = -1
|
||
for (let c = 0; c < NUM_CUES; c++) {
|
||
const ptns = new Array(NUM_VOICES)
|
||
for (let i = 0; i < 10; i++) {
|
||
const lo = sys.peek(cueSheetPtr + c * CUE_SIZE + i) & 0xFF
|
||
const mi = sys.peek(cueSheetPtr + c * CUE_SIZE + 10 + i) & 0xFF
|
||
const hi = sys.peek(cueSheetPtr + c * CUE_SIZE + 20 + i) & 0xFF
|
||
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) << 8) | sys.peek(cueSheetPtr + c * CUE_SIZE + 31)
|
||
cues[c] = { ptns, instr }
|
||
|
||
for (let v = 0; v < NUM_VOICES; v++) {
|
||
if (ptns[v] !== CUE_EMPTY) { lastActiveCue = c; break }
|
||
}
|
||
}
|
||
sys.free(cueSheetPtr)
|
||
|
||
sys.free(ptr)
|
||
|
||
return {
|
||
filePath, songIndex, version, numSongs, numVoices, numPats,
|
||
bpm: bpmStored + 25, tickRate,
|
||
patterns, cues, lastActiveCue
|
||
}
|
||
}
|
||
|
||
// Read header + song-table + (optional) sMet from a .taud and return a per-song
|
||
// metadata list. Does NOT load patterns / cues / samples — that's loadTaud's job.
|
||
// Returned shape:
|
||
// { numSongs, projectName, songs: [
|
||
// { index, numVoices, numPats, bpm, tickRate, songGlobalVolume,
|
||
// songMixingVolume, mixerflags, name, composer, copyright } ] }
|
||
function loadTaudSongList(filePath) {
|
||
const fh = files.open(filePath)
|
||
if (!fh.exists) throw Error(`taut: file not exists: ${filePath}`)
|
||
const fileSize = fh.size
|
||
const ptr = sys.malloc(fileSize)
|
||
fh.pread(ptr, fileSize, 0)
|
||
fh.close()
|
||
|
||
for (let i = 0; i < 8; i++) {
|
||
if ((sys.peek(ptr + i) & 0xFF) !== TAUD_MAGIC[i]) {
|
||
sys.free(ptr)
|
||
throw Error(`taut: bad magic byte at ${i}`)
|
||
}
|
||
}
|
||
|
||
const numSongs = sys.peek(ptr + 9) & 0xFF
|
||
const compSize = _peekU32LE(ptr, 10)
|
||
const projOff = _peekU32LE(ptr, 14)
|
||
const songTableOff = TAUD_HEADER_SIZE + compSize
|
||
|
||
const songs = new Array(numSongs)
|
||
for (let i = 0; i < numSongs; i++) {
|
||
const entryOff = songTableOff + i * TAUD_SONG_ENTRY
|
||
songs[i] = {
|
||
index: i,
|
||
numVoices: sys.peek(ptr + entryOff + 4) & 0xFF,
|
||
numPats: (sys.peek(ptr + entryOff + 5) & 0xFF) |
|
||
((sys.peek(ptr + entryOff + 6) & 0xFF) << 8),
|
||
bpm: ((sys.peek(ptr + entryOff + 7) & 0xFF) + 25),
|
||
tickRate: sys.peek(ptr + entryOff + 8) & 0xFF,
|
||
mixerflags: sys.peek(ptr + entryOff + 15) & 0xFF,
|
||
songGlobalVolume: sys.peek(ptr + entryOff + 16) & 0xFF,
|
||
songMixingVolume: sys.peek(ptr + entryOff + 17) & 0xFF,
|
||
name: '',
|
||
composer: '',
|
||
copyright: '',
|
||
pitchPresetIdx: null,
|
||
}
|
||
}
|
||
|
||
let projectName = ''
|
||
// 0x1E-separated UTF-8 strings; slot 0 is always present (typically empty)
|
||
// because converters write a leading separator. Read all entries that exist.
|
||
const instNames = []
|
||
const sampleNames = []
|
||
|
||
function parseNameTable(payloadStart, secLen) {
|
||
const out = []
|
||
let s = ''
|
||
for (let k = 0; k < secLen; k++) {
|
||
const b = sys.peek(ptr + payloadStart + k) & 0xFF
|
||
if (b === 0x1E) { out.push(s); s = '' }
|
||
else { s += String.fromCharCode(b) }
|
||
}
|
||
out.push(s)
|
||
return out
|
||
}
|
||
|
||
// Parse Project Data section (\x1ETaudPrJ) for song names / project name.
|
||
// See terranmon.txt "Project Data" / "sMet" for the format.
|
||
if (projOff !== 0 && projOff + 16 <= fileSize) {
|
||
const projMagic = [0x1E,0x54,0x61,0x75,0x64,0x50,0x72,0x4A] // \x1ETaudPrJ
|
||
let magicOK = true
|
||
for (let i = 0; i < 8; i++) {
|
||
if ((sys.peek(ptr + projOff + i) & 0xFF) !== projMagic[i]) { magicOK = false; break }
|
||
}
|
||
if (magicOK) {
|
||
let p = projOff + 16 // skip magic(8) + reserved(8)
|
||
while (p + 8 <= fileSize) {
|
||
const fc0 = sys.peek(ptr + p) & 0xFF
|
||
const fc1 = sys.peek(ptr + p + 1) & 0xFF
|
||
const fc2 = sys.peek(ptr + p + 2) & 0xFF
|
||
const fc3 = sys.peek(ptr + p + 3) & 0xFF
|
||
const secLen = _peekU32LE(ptr, p + 4)
|
||
const payloadStart = p + 8
|
||
if (payloadStart + secLen > fileSize) break
|
||
|
||
// 'PNam' = 0x50,0x4E,0x61,0x6D
|
||
if (fc0 === 0x50 && fc1 === 0x4E && fc2 === 0x61 && fc3 === 0x6D) {
|
||
let s = ''
|
||
for (let k = 0; k < secLen; k++) {
|
||
const b = sys.peek(ptr + payloadStart + k) & 0xFF
|
||
if (b === 0) break
|
||
s += String.fromCharCode(b)
|
||
}
|
||
projectName = s
|
||
}
|
||
// 'INam' = 0x49,0x4E,0x61,0x6D
|
||
else if (fc0 === 0x49 && fc1 === 0x4E && fc2 === 0x61 && fc3 === 0x6D) {
|
||
const names = parseNameTable(payloadStart, secLen)
|
||
for (let k = 0; k < names.length; k++) instNames[k] = names[k]
|
||
}
|
||
// 'SNam' = 0x53,0x4E,0x61,0x6D
|
||
else if (fc0 === 0x53 && fc1 === 0x4E && fc2 === 0x61 && fc3 === 0x6D) {
|
||
const names = parseNameTable(payloadStart, secLen)
|
||
for (let k = 0; k < names.length; k++) sampleNames[k] = names[k]
|
||
}
|
||
// 'sMet' = 0x73,0x4D,0x65,0x74
|
||
else if (fc0 === 0x73 && fc1 === 0x4D && fc2 === 0x65 && fc3 === 0x74) {
|
||
let q = payloadStart
|
||
const qEnd = payloadStart + secLen
|
||
while (q + 5 <= qEnd) {
|
||
const idx = sys.peek(ptr + q) & 0xFF
|
||
const subLen = _peekU32LE(ptr, q + 1)
|
||
const subStart = q + 5
|
||
if (subStart + subLen > qEnd) break
|
||
// payload: notation(u16) + beat_pri(u8) + beat_sec(u8) + name\0 + composer\0 + copyright\0
|
||
const notation = (sys.peek(ptr + subStart) & 0xFF) |
|
||
((sys.peek(ptr + subStart + 1) & 0xFF) << 8)
|
||
let r = subStart + 4 // skip notation(2) + pri(1) + sec(1)
|
||
const strs = []
|
||
while (strs.length < 3 && r < subStart + subLen) {
|
||
let s = ''
|
||
while (r < subStart + subLen) {
|
||
const b = sys.peek(ptr + r) & 0xFF; r++
|
||
if (b === 0) break
|
||
s += String.fromCharCode(b)
|
||
}
|
||
strs.push(s)
|
||
}
|
||
if (idx < numSongs) {
|
||
songs[idx].pitchPresetIdx = notation
|
||
if (strs[0] !== undefined) songs[idx].name = strs[0]
|
||
if (strs[1] !== undefined) songs[idx].composer = strs[1]
|
||
if (strs[2] !== undefined) songs[idx].copyright = strs[2]
|
||
}
|
||
q = subStart + subLen
|
||
}
|
||
}
|
||
|
||
p = payloadStart + secLen
|
||
}
|
||
}
|
||
}
|
||
|
||
sys.free(ptr)
|
||
return { numSongs, projectName, songs, instNames, sampleNames }
|
||
}
|
||
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// GUI DEFINITION
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
const [SCRH, SCRW] = con.getmaxyx()
|
||
const [SCRPW, SCRPH] = graphics.getPixelDimension()
|
||
const CELL_PW = (SCRPW / SCRW) | 0 // px per character column
|
||
const CELL_PH = (SCRPH / SCRH) | 0 // px per character row
|
||
const PTNVIEW_OFFSET_X = 3
|
||
const PTNVIEW_OFFSET_Y = 5
|
||
const PTNVIEW_HEIGHT = SCRH - PTNVIEW_OFFSET_Y
|
||
|
||
const TIMELINE_COLSIZES = [15, 7, 5]
|
||
let timelineRowStyle = 0
|
||
let COLSIZE_TIMELINE_FULL = TIMELINE_COLSIZES[0]
|
||
let VOCSIZE_TIMELINE_FULL = Math.floor((SCRW - 3) / COLSIZE_TIMELINE_FULL)
|
||
|
||
const ORDERS_CMD_X = 5
|
||
const ORDERS_VOICE_X = 12 // 1-indexed col where voice columns begin
|
||
const ORDERS_VOICE_COL_W = 4
|
||
const VOCSIZE_ORDERS = Math.floor((SCRW - (ORDERS_VOICE_X - 1)) / ORDERS_VOICE_COL_W)
|
||
|
||
const VIEW_TIMELINE = 0
|
||
const VIEW_CUES = 1
|
||
const VIEW_PATTERN_DETAILS = 2
|
||
const VIEW_SAMPLES = 3
|
||
const VIEW_INSTRMNT = 4
|
||
const VIEW_PROJECT = 5
|
||
const VIEW_FILE = 6
|
||
|
||
const colPlayback = 86
|
||
const colHighlight = 41
|
||
const colColumnSep = 6
|
||
const colRowNum = 250
|
||
const colRowNumEmph1 = 225
|
||
const colRowNumEmph2 = 155
|
||
const colStatus = 253
|
||
const colVoiceHdr = 230
|
||
const colVoiceHdrMuted = 249
|
||
const colVoiceHdrMutedCursorUp = 180
|
||
const colSep = 252
|
||
const colPushBtnBack = 143
|
||
const colTabBarBack = 187
|
||
const colTabBarBack2 = 136
|
||
const colTabBarOrn = 136
|
||
const colBrand = 211
|
||
const colPopupBack = 244
|
||
const colTabActive = 239
|
||
const colTabInactive = 45
|
||
|
||
// protip: avoid using colour zero
|
||
const colWHITE = 239
|
||
const colBLACK = 240
|
||
|
||
// Voice-header playback meters (volume bar grows from centre out; pan bar = centre tick + dot).
|
||
// Pixels are drawn beneath text — only the glyph foregrounds occlude the bars, so the bars sit
|
||
// on rows 0 and (cellH - 1) where the 7×14 glyph has the least foreground.
|
||
const METER_VOL_COL = colVol
|
||
const METER_PAN_COL = 214
|
||
const METER_VOL_TICK_COL = 127
|
||
const METER_PAN_TICK_COL = 198
|
||
const METER_BAR_PAD = 0 // px gap from cell edges (each side)
|
||
const METER_TRANSPARENT = 255
|
||
|
||
let separatorStyle = 0
|
||
|
||
const PATEDITOR_LIST_X = 1
|
||
const PATEDITOR_SEP1_X = 5
|
||
const PATEDITOR_GRID_X = 7
|
||
const PATEDITOR_CELL_X = 10
|
||
const PATEDITOR_SEP2_X = 30
|
||
const PATEDITOR_DETAIL_X = 32
|
||
|
||
const PLAYMODE_NONE = 0
|
||
const PLAYMODE_SONG = 1
|
||
const PLAYMODE_CUE = 2
|
||
const PLAYMODE_ROW = 3
|
||
|
||
function fillLine(y, c, back) {
|
||
con.color_pair(c, back)
|
||
for (let x = 1; x <= SCRW; x++) {
|
||
con.move(y, x); con.addch(32)
|
||
}
|
||
}
|
||
|
||
const TAB_GAP = 3
|
||
const PANEL_NAMES = ['Timeline', 'Cues', 'Patterns', 'Samples', 'Instrmnt', 'Project', 'File']
|
||
|
||
function drawAlwaysOnElems() {
|
||
drawStatusBar()
|
||
drawTabBar()
|
||
}
|
||
|
||
const transportControlReverse = [PLAYMODE_NONE, PLAYMODE_ROW, PLAYMODE_CUE, PLAYMODE_SONG]
|
||
const transportControlSymbol = [sym.stop, sym.playrow, sym.playcue, sym.playall]
|
||
const transportControlColour = [160,20,20,20]
|
||
const transportControlHint = ["O","I","U","Y"]
|
||
let transportControlOldPos = 3 // index for transportControlReverse
|
||
function drawStatusBar() {
|
||
fillLine(1, colWHITE, 255)
|
||
fillLine(2, colWHITE, 255)
|
||
|
||
const sCueIdx = cueIdx.hex03()
|
||
const sCueMax = (song.lastActiveCue < 0 ? 0 : song.lastActiveCue).hex03()
|
||
const vMax = song.numVoices.dec02()
|
||
const vHi = Math.min(voiceOff + VOCSIZE_TIMELINE_FULL, song.numVoices).dec02()
|
||
const vLow = (voiceOff+1).dec02()
|
||
const songPath = song.filePath
|
||
const sRow = cursorRow.dec02()
|
||
const sBPM = ''+audio.getBPM(PLAYHEAD)
|
||
const sSpd = ''+audio.getTickRate(PLAYHEAD)
|
||
|
||
// transport control and its control hints
|
||
let transportControlNewPos = transportControlOldPos
|
||
transportControlReverse.forEach((thisMode, j) => {
|
||
let active = (playbackMode == thisMode)
|
||
|
||
if (active)
|
||
con.color_pair(transportControlColour[j], 255)
|
||
else
|
||
con.color_pair(colWHITE, 255)
|
||
|
||
con.move(1, SCRW - 5*(j+1) + 1 + 2)
|
||
print(transportControlSymbol[j])
|
||
|
||
if (active)
|
||
con.color_pair(transportControlColour[j], 255)
|
||
else
|
||
con.color_pair(235, 255)
|
||
|
||
con.move(2, SCRW - 5*(j+1) + 1 + 2)
|
||
print(transportControlHint[j])
|
||
|
||
if (active) transportControlNewPos = j;
|
||
})
|
||
|
||
// draw tob bar background
|
||
gl.drawTexPattern(buttonTexture, 0, 0, SCRPW, 28)
|
||
graphics.plotPixel(0, 0, 255)
|
||
graphics.plotPixel(0, 1, 254)
|
||
graphics.plotPixel(SCRPW-1, 0, 255)
|
||
graphics.plotPixel(SCRPW-1, 1, 254)
|
||
// update pos tracking
|
||
transportControlOldPos = transportControlNewPos
|
||
|
||
|
||
// current audio device status
|
||
// play/stop sym
|
||
con.color_pair(colWHITE, 255)
|
||
con.move(1,1)
|
||
print(`P${PLAYHEAD+1}`)
|
||
con.move(2,1)
|
||
print((playbackMode == PLAYMODE_NONE) ? sym.statusstop : sym.statusplay)
|
||
|
||
// beat indicator
|
||
let beatCursorRow = cursorRow
|
||
while (beatCursorRow >= beatDivSecondary) { beatCursorRow -= beatDivSecondary }
|
||
let beatInd = (playbackMode != PLAYMODE_NONE && beatCursorRow % beatDivPrimary < (beatDivPrimary >>> 1)) ?
|
||
((beatCursorRow % beatDivSecondary < (beatDivPrimary >>> 1)) ? '\u00846u' : '\u00847u') :
|
||
''
|
||
|
||
// cue row
|
||
con.move(1,4)
|
||
con.color_pair(colWHITE, 255); print(`Cue `)
|
||
con.color_pair(20, 255); print(`${sCueIdx}`)
|
||
// con.color_pair(colWHITE, 255); print(`/`)
|
||
// con.color_pair(20, 255); print(`${sCueMax}`)
|
||
con.color_pair(colWHITE, 255); print(` Row `)
|
||
con.color_pair(130, 255); print(`${sRow}${beatInd}`)
|
||
|
||
// bpm spd
|
||
con.move(2,4)
|
||
con.color_pair(colWHITE, 255); print(`BPM `)
|
||
con.color_pair(161, 255); print(`${sBPM}`)
|
||
con.color_pair(colWHITE, 255); print(` Tick `)
|
||
con.color_pair(235, 255); print(`${sSpd}`)
|
||
|
||
// app title
|
||
gl.drawTexImageOver(logoTexture, (SCRPW-logoTexture.width) >>> 1, 7)
|
||
|
||
}
|
||
|
||
function drawTabBar() {
|
||
con.color_pair(colTabBarOrn, colTabBarBack)
|
||
con.move(3,1)
|
||
print(`\u00FB`.repeat(SCRW))
|
||
|
||
const XOFF = 2
|
||
const YOFF = 3
|
||
|
||
con.move(YOFF, XOFF)
|
||
for (let i = 0; i < PANEL_NAMES.length; i++) {
|
||
if (i > 0) con.curs_right(TAB_GAP);
|
||
let tabName = PANEL_NAMES[i]
|
||
|
||
let colFore = (currentPanel === i) ? colTabActive : colTabInactive
|
||
let colBack = (currentPanel === i) ? colTabBarBack2 : colTabBarBack
|
||
let colFore2 = (currentPanel === i) ? colTabBarBack2 : colTabBarBack
|
||
let colBack2 = (currentPanel === i) ? colTabBarBack : colTabBarBack
|
||
let spcL = (currentPanel === i) ? sym.leftshade : ' '
|
||
let spcR = (currentPanel === i) ? sym.rightshade : ' '
|
||
|
||
con.color_pair(colFore2, colBack2); print(spcL)
|
||
con.color_pair(colFore, colBack); print(tabName)
|
||
con.color_pair(colFore2, colBack2); print(spcR)
|
||
}
|
||
|
||
|
||
con.color_pair(colStatus, 255)
|
||
}
|
||
|
||
/**
|
||
* @param style 0: condensed timeline, 1: vertical bars between voices
|
||
*/
|
||
function drawSeparators(style) {
|
||
if (style == 1) {
|
||
con.color_pair(colSep, 255)
|
||
for (let c = 0; c < VOCSIZE_TIMELINE_FULL - 1; c++) {
|
||
for (let y = PTNVIEW_OFFSET_Y - 1; y < PTNVIEW_HEIGHT; y++) {
|
||
con.move(y, PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * (c+1) - 1)
|
||
con.prnch(VERT)
|
||
}
|
||
}
|
||
}
|
||
else {
|
||
// paint the first column of pattern view with colour
|
||
for (let x = PTNVIEW_OFFSET_X; x < SCRW - 3; x += COLSIZE_TIMELINE_FULL) {
|
||
for (let y = 0; y < PTNVIEW_HEIGHT+1; y++) {
|
||
let memOffset = (y+PTNVIEW_OFFSET_Y-2) * SCRW + (x-1)
|
||
let bgColOffset = GPU_MEM - TEXT_BACK_OFF - memOffset
|
||
let oldBgCol = sys.peek(bgColOffset)
|
||
if (oldBgCol == 255) {
|
||
sys.poke(bgColOffset, colColumnSep)
|
||
}
|
||
}
|
||
}
|
||
|
||
con.color_pair(colSep, 255)
|
||
}
|
||
}
|
||
|
||
const voiceHdrColByFlags = [colStatus, colVoiceHdr, colVoiceHdrMuted, colVoiceHdrMutedCursorUp] // default, cursorUp, muted, cursorUp+muted
|
||
|
||
function drawVoiceHeaders() {
|
||
fillLine(PTNVIEW_OFFSET_Y - 1, colStatus, 255)
|
||
const cue = song.cues[cueIdx]
|
||
for (let c = 0; c < VOCSIZE_TIMELINE_FULL; c++) {
|
||
const voice = voiceOff + c
|
||
const x = PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * c
|
||
con.move(PTNVIEW_OFFSET_Y - 1, x)
|
||
if (voice >= song.numVoices) {
|
||
con.color_pair(colStatus, 255)
|
||
print(` `.substring(0, COLSIZE_TIMELINE_FULL))
|
||
} else {
|
||
const isCursor = (voice === cursorVox)
|
||
const isMuted = voiceMutes[voice]
|
||
con.color_pair(voiceHdrColByFlags[isMuted*2 + isCursor], 255)
|
||
const ptnIdx = cue.ptns[voice]
|
||
const vlabel = `V${(voice+1).dec02()}`
|
||
const plabel = (ptnIdx === CUE_EMPTY) ? '---' : ptnIdx.hex03()
|
||
const label =
|
||
(timelineRowStyle == 0) ? ` ${vlabel} ptn ${plabel} ` :
|
||
(timelineRowStyle == 1) ? ` ${vlabel.substring(1)}:${plabel}` :
|
||
` ${vlabel}`
|
||
print((label + ' ').substring(0, COLSIZE_TIMELINE_FULL))
|
||
}
|
||
}
|
||
|
||
drawSeparators(separatorStyle)
|
||
// Voice headers were just repainted with bg=255 (transparent), so any meter pixels
|
||
// beneath them survived the redraw — but the cached per-slot state may still match,
|
||
// which would skip the redraw on the next updatePlayback. Force a redraw by clearing
|
||
// the cache; the next updatePlayback re-emits any active bars.
|
||
invalidateVoiceMeters()
|
||
}
|
||
|
||
// Per-slot cache of last-drawn meter state: { voice, vol, pan } or null when slot is clear.
|
||
// Indexed by slot index 0..VOCSIZE_TIMELINE_FULL-1 (never grows beyond 20 slots in practice).
|
||
const meterPrevSlot = new Array(20).fill(null)
|
||
const meterThickness = 2
|
||
|
||
function invalidateVoiceMeters() {
|
||
for (let i = 0; i < meterPrevSlot.length; i++) meterPrevSlot[i] = null
|
||
}
|
||
|
||
// Wipe the pixel strip used by the voice-header meters back to transparent (255).
|
||
// Called when leaving the Timeline panel or when playback stops.
|
||
function clearVoiceMeters() {
|
||
const yPan = (PTNVIEW_OFFSET_Y - 2) * CELL_PH
|
||
const yVol = (PTNVIEW_OFFSET_Y - 1) * CELL_PH - meterThickness
|
||
graphics.plotRect(0, yPan, SCRPW, meterThickness, METER_TRANSPARENT)
|
||
graphics.plotRect(0, yVol, SCRPW, meterThickness, METER_TRANSPARENT)
|
||
invalidateVoiceMeters()
|
||
}
|
||
|
||
/**
|
||
* Repaint the per-voice volume and pan indicators in the voice-header row.
|
||
* Volume: horizontal bar growing from the cell centre outward, length ∝ effective tracker
|
||
* volume (after envelopes, fadeout, vol-column/D/tremolo ramps, per-voice fader). Drawn on
|
||
* the bottom strip of the header row.
|
||
* Pan: horizontal bar stemming from the cell centre, signed length ∝ (pan-128)/128. Drawn
|
||
* on the top strip of the header row.
|
||
* Both strips get a centre tick drawn on top of the bar.
|
||
* Only redraws slots whose (voice, volPix, panPix) tuple has changed since the last call,
|
||
* so the work per frame stays bounded by actual movement.
|
||
*/
|
||
function drawVoiceMeters() {
|
||
if (playbackMode === PLAYMODE_NONE || currentPanel !== VIEW_TIMELINE) return
|
||
const yPan = (PTNVIEW_OFFSET_Y - 2) * CELL_PH // top edge of pan strip
|
||
const yVol = (PTNVIEW_OFFSET_Y - 1) * CELL_PH - meterThickness // top edge of vol strip
|
||
const slotPW = COLSIZE_TIMELINE_FULL * CELL_PW
|
||
// Skip the leftmost cell of every slot — it's a text-mode separator whose background
|
||
// colour paints on top of the framebuffer and would clip any meter pixels there.
|
||
const drawW = slotPW - CELL_PW
|
||
const halfW = (drawW >>> 1) - METER_BAR_PAD
|
||
const stripW = drawW - 2 * METER_BAR_PAD + 1
|
||
|
||
for (let c = 0; c < VOCSIZE_TIMELINE_FULL; c++) {
|
||
const voice = voiceOff + c
|
||
const slotX0 = (PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * c) * CELL_PW
|
||
const xCenter = slotX0 + (drawW >>> 1)
|
||
const xStrip = slotX0 + METER_BAR_PAD
|
||
const prev = meterPrevSlot[c]
|
||
|
||
if (voice >= song.numVoices) {
|
||
if (prev !== null) {
|
||
graphics.plotRect(xStrip, yPan, stripW, meterThickness, METER_TRANSPARENT)
|
||
graphics.plotRect(xStrip, yVol, stripW, meterThickness, METER_TRANSPARENT)
|
||
meterPrevSlot[c] = null
|
||
}
|
||
continue
|
||
}
|
||
|
||
const volRaw = audio.getVoiceEffectiveVolume(PLAYHEAD, voice) || 0
|
||
const panRaw = audio.getVoiceEffectivePan(PLAYHEAD, voice)
|
||
const volPix = Math.max(0, Math.min(halfW, Math.round(volRaw * halfW)))
|
||
// Pan range 0..255, centre 128 → map to ±halfW.
|
||
let panPix = Math.round((panRaw - 128) / 128 * halfW)
|
||
if (panPix < -halfW) panPix = -halfW
|
||
else if (panPix > halfW) panPix = halfW
|
||
|
||
if (prev !== null && prev.voice === voice && prev.vol === volPix && prev.pan === panPix) continue
|
||
|
||
// Clear both bar strips in this slot before redrawing.
|
||
graphics.plotRect(xStrip, yPan, stripW, meterThickness, METER_TRANSPARENT)
|
||
graphics.plotRect(xStrip, yVol, stripW, meterThickness, METER_TRANSPARENT)
|
||
// Volume bar (grows from centre out). Silent voices show no bar.
|
||
if (volPix > 0) {
|
||
graphics.plotRect(xCenter - volPix, yVol, 2 * volPix + 1, meterThickness, METER_VOL_COL)
|
||
}
|
||
// Pan bar (stems from centre, direction = sign of panPix). Centred pan shows no bar.
|
||
if (panPix !== 0) {
|
||
const px0 = (panPix > 0) ? xCenter : xCenter + panPix
|
||
graphics.plotRect(px0, yPan, Math.abs(panPix) + 1, meterThickness, METER_PAN_COL)
|
||
}
|
||
// Centre ticks, drawn on top of the bars.
|
||
graphics.plotRect(xCenter-1, yPan, 3, meterThickness, METER_PAN_TICK_COL)
|
||
graphics.plotRect(xCenter-1, yVol, 3, meterThickness, METER_VOL_TICK_COL)
|
||
|
||
meterPrevSlot[c] = { voice: voice, vol: volPix, pan: panPix }
|
||
}
|
||
}
|
||
|
||
// Sub-field layout for style-0 cells (shared by drawPatternRowAt and drawVoiceColumnAt)
|
||
const TL_FIELD_OFFSETS = [0, 4, 6, 8, 10, 11]
|
||
const TL_FIELD_FGS = [colNote, colInst, colVol, colPan, colEffOp, colEffArg]
|
||
|
||
function drawPatternRowAt(viewRow, style = timelineRowStyle) {
|
||
const actualRow = scrollRow + viewRow
|
||
const y = PTNVIEW_OFFSET_Y + viewRow
|
||
const highlight = (actualRow === cursorRow)
|
||
const back = highlight ? (playbackMode !== PLAYMODE_NONE ? colPlayback : colHighlight) : colBackPtn
|
||
const cue = song.cues[cueIdx]
|
||
|
||
con.color_pair(colRowNum, back)
|
||
if (actualRow < ROWS_PER_PAT) {
|
||
let actualRowForBeatCalc = actualRow
|
||
while (actualRowForBeatCalc >= beatDivSecondary) { actualRowForBeatCalc -= beatDivSecondary }
|
||
|
||
if (actualRowForBeatCalc % beatDivPrimary == 0) {con.color_pair(colRowNumEmph1, back)}
|
||
if (actualRowForBeatCalc % beatDivSecondary == 0) {con.color_pair(colRowNumEmph2, back)}
|
||
let rowstr = actualRow.dec02()
|
||
con.move(y, 1); con.prnch(rowstr.charCodeAt(0)); con.move(y, 2); con.prnch(rowstr.charCodeAt(1))
|
||
|
||
if (timelineRowStyle != 1) {
|
||
con.move(y, SCRW-2); con.prnch(rowstr.charCodeAt(0)); con.move(y, SCRW-1); con.prnch(rowstr.charCodeAt(1))
|
||
}
|
||
}
|
||
else {
|
||
print(' ')
|
||
}
|
||
// TODO scroll indicator on x=SCRW?
|
||
|
||
for (let c = 0; c < VOCSIZE_TIMELINE_FULL; c++) {
|
||
const voice = voiceOff + c
|
||
const x = PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * c
|
||
let cell = EMPTY_CELL
|
||
if (actualRow < ROWS_PER_PAT && voice < song.numVoices) {
|
||
const ptnIdx = cue.ptns[voice]
|
||
if (ptnIdx !== CUE_EMPTY && ptnIdx < song.numPats) {
|
||
cell = buildRowCell(song.patterns[ptnIdx], actualRow)
|
||
}
|
||
}
|
||
drawCellAtStyled(y, x, cell, back, style)
|
||
if (style === 0 && highlight && playbackMode === PLAYMODE_NONE && voice === cursorVox) {
|
||
const fieldStr = [cell.sNote, cell.sInst, cell.sVolEff+cell.sVolArg,
|
||
cell.sPanEff+cell.sPanArg, cell.sEffOp, cell.sEffArg][timelineColCursor]
|
||
con.move(y, x + TL_FIELD_OFFSETS[timelineColCursor])
|
||
con.color_pair(TL_FIELD_FGS[timelineColCursor], colPlayback)
|
||
print(fieldStr)
|
||
}
|
||
}
|
||
|
||
drawSeparators(separatorStyle)
|
||
}
|
||
|
||
function drawPatternView(style = timelineRowStyle) {
|
||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) drawPatternRowAt(vr, style)
|
||
}
|
||
|
||
function drawControlHint() {
|
||
let hintElemTimeline = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`pg\u008418u`,'Cue'],
|
||
['sep'],
|
||
['WER','View'],
|
||
['sep'],
|
||
['sp','Edit'],
|
||
['sep'],
|
||
['n','Solo'],
|
||
['m','Mute'],
|
||
['sep'],
|
||
['tab','Panel'],
|
||
['sep'],
|
||
['!','Help'],
|
||
// ['sep'],
|
||
// ['q','Quit'],
|
||
]
|
||
let hintElemOrders = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`ent`,'Go to cue'],
|
||
['sep'],
|
||
['sp','Edit'],
|
||
['sep'],
|
||
['tab','Panel'],
|
||
['sep'],
|
||
['!','Help'],
|
||
// ['sep'],
|
||
// ['q','Quit'],
|
||
]
|
||
|
||
let hintElemPatterns = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`pg\u008418u`,'Ptn'],
|
||
['sep'],
|
||
['sp','Edit'],
|
||
['sep'],
|
||
['tab','Panel'],
|
||
['sep'],
|
||
['!','Help'],
|
||
// ['sep'],
|
||
// ['q','Quit'],
|
||
]
|
||
|
||
let hintElemEditNoteValue = [ // only enabled in viewmode 'E' or in pattern editor
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`pg\u008418u`,'Cue'],
|
||
['sep'],
|
||
[`A${sym.doubledot}G`,'Note'],
|
||
[`0${sym.doubledot}9`,'Oct'],
|
||
['[]',`Tone\u008418u`],
|
||
['sep'],
|
||
['#',sym.sharp],
|
||
['@','Acc'],
|
||
['sep'],
|
||
['=','KOff'],
|
||
['^','KCut'],
|
||
['sep'],
|
||
['!','Help'],
|
||
// ['sep'],
|
||
// ['Sp','ExitEdit'],
|
||
]
|
||
let hintElemEditInstValue = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`pg\u008418u`,'Cue'],
|
||
['sep'],
|
||
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Instrument'],
|
||
['sep'],
|
||
['!','Help'],
|
||
// ['sep'],
|
||
// ['sp','ExitEdit'],
|
||
]
|
||
let hintElemEditVolEff = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`pg\u008418u`,'Cue'],
|
||
['sep'],
|
||
['.','Set'],
|
||
['v','SlideUp'],
|
||
['^','SlideDn'],
|
||
['-','FineDn'],
|
||
['=','FineUp'],
|
||
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Val'],
|
||
['sep'],
|
||
['!','Help'],
|
||
// ['sep'],
|
||
// ['Sp','ExitEdit'],
|
||
]
|
||
let hintElemEditPanEff = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`pg\u008418u`,'Cue'],
|
||
['sep'],
|
||
['.','Set'],
|
||
['<','SlideL'],
|
||
['>','SlideR'],
|
||
['-','FineL'],
|
||
['=','FineR'],
|
||
[`0${sym.doubledot}9 A${sym.doubledot}F`,'Val'],
|
||
// ['sep'],
|
||
// ['Sp','ExitEdit'],
|
||
]
|
||
let hintElemEditFxSym = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`pg\u008418u`,'Cue'],
|
||
['sep'],
|
||
[`0${sym.doubledot}9 A${sym.doubledot}F`,`FxSym`],
|
||
['sep'],
|
||
['!','Help'],
|
||
// ['sep'],
|
||
// ['sp','ExitEdit'],
|
||
]
|
||
let hintElemEditFxVal = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`pg\u008418u`,'Cue'],
|
||
['sep'],
|
||
[`0${sym.doubledot}9 A${sym.doubledot}F`,`FxVal`],
|
||
['sep'],
|
||
['!','Help'],
|
||
// ['sep'],
|
||
// ['sp','ExitEdit'],
|
||
]
|
||
|
||
const hintElemExternal = [['Tab','Panel'],['sep'],['!','Help']]
|
||
const hintElemProject = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
[`ent`,'Edit/Switch'],
|
||
['sep'],
|
||
['tab','Panel'],
|
||
['sep'],
|
||
['!','Help'],
|
||
]
|
||
const hintElemSamples = [
|
||
[`\u008428u\u008429u`,'Nav'],
|
||
['sep'],
|
||
['e','Edit'],
|
||
['ent','View inst'],
|
||
['sep'],
|
||
['tab','Panel'],
|
||
['sep'],
|
||
['!','Help'],
|
||
]
|
||
const hintElemInstruments = [
|
||
[`\u008426u\u008427u`,'Nav'],
|
||
[`\u008428u\u008429u`,'Tab'],
|
||
[`1${sym.doubledot}5`,'Jump tab'],
|
||
['sep'],
|
||
['e','Edit'],
|
||
['sep'],
|
||
['tab','Panel'],
|
||
['sep'],
|
||
['!','Help'],
|
||
]
|
||
let hintElems = [hintElemTimeline, hintElemOrders, hintElemPatterns, hintElemSamples, hintElemInstruments, hintElemProject, hintElemExternal]
|
||
let hintElemPat = [hintElemEditNoteValue, hintElemEditInstValue, hintElemEditVolEff, hintElemEditPanEff, hintElemEditFxSym, hintElemEditFxVal]
|
||
|
||
// erase current line
|
||
con.move(SCRH, 1)
|
||
print(' '.repeat(SCRW-1))
|
||
|
||
// start writing
|
||
con.move(SCRH, 1)
|
||
|
||
hintElems[currentPanel].forEach((pair, i, list) => {
|
||
con.color_pair(colStatus,255)
|
||
if (pair[0] == 'sep') {
|
||
print(` ${BIGDOT} `)
|
||
}
|
||
else {
|
||
if (i > 0 && list[i-1][0] != 'sep') print(' ');
|
||
con.color_pair(colVoiceHdr,255)
|
||
print(pair[0]+' ')
|
||
con.color_pair(colStatus,255)
|
||
print(pair[1])
|
||
}
|
||
})
|
||
}
|
||
|
||
function toggleMute(vox) {
|
||
voiceMutes[vox] = !voiceMutes[vox]
|
||
audio.setVoiceMute(PLAYHEAD, vox, voiceMutes[vox])
|
||
drawVoiceHeaders()
|
||
}
|
||
|
||
function toggleSolo(vox) {
|
||
let inSolo = true
|
||
for (let i = 0; i < song.numVoices; i++) {
|
||
if (i !== vox && !voiceMutes[i]) { inSolo = false; break }
|
||
}
|
||
if (inSolo) {
|
||
for (let i = 0; i < song.numVoices; i++) {
|
||
voiceMutes[i] = false
|
||
audio.setVoiceMute(PLAYHEAD, i, false)
|
||
}
|
||
} else {
|
||
for (let i = 0; i < song.numVoices; i++) {
|
||
const m = (i !== vox)
|
||
voiceMutes[i] = m
|
||
audio.setVoiceMute(PLAYHEAD, i, m)
|
||
}
|
||
}
|
||
drawVoiceHeaders()
|
||
}
|
||
|
||
function drawVoiceDetail(isVerticalLayout = false, ptn = null, activeRow = -1, cumState = null) {
|
||
// Resolve pattern data: null ptn uses timeline context (cursorVox / cursorRow)
|
||
let ptnDat
|
||
if (ptn === null) {
|
||
const cue = song.cues[cueIdx]
|
||
const ptnIdx = cue.ptns[cursorVox]
|
||
if (ptnIdx === CUE_EMPTY || ptnIdx >= song.numPats) return
|
||
const srcPtn = song.patterns[ptnIdx]
|
||
const row = (activeRow >= 0) ? activeRow : cursorRow
|
||
const off = 8 * row
|
||
ptnDat = srcPtn.slice(off, off + 8)
|
||
} else {
|
||
const row = (activeRow >= 0) ? activeRow : 0
|
||
const off = 8 * row
|
||
ptnDat = ptn.slice(off, off + 8)
|
||
}
|
||
|
||
const note = ptnDat[0] | (ptnDat[1] << 8)
|
||
const inst = ptnDat[2]
|
||
const voleff = ptnDat[3]
|
||
const voleffop = (voleff >>> 6) & 3
|
||
const voleffarg = voleff & 63
|
||
const paneff = ptnDat[4]
|
||
const paneffop = (paneff >>> 6) & 3
|
||
const paneffarg = paneff & 63
|
||
const effop = ptnDat[5]
|
||
const effarg = ptnDat[6] | (ptnDat[7] << 8)
|
||
|
||
let fx = effop > 0 ? effop.toString(36).toUpperCase() : '0'
|
||
if (fx === 'S') fx += (effarg >>> 12).hex1()
|
||
const fxName = fxNames[fx] || '? '
|
||
|
||
if (!isVerticalLayout) {
|
||
return
|
||
con.move(PTNVIEW_OFFSET_Y-2, 1)
|
||
print(`Pitch $${note.hex04()} Inst $${inst.hex02()} ${sym.vx} ${voleffop}.$${voleffarg.hex02()} ` +
|
||
`${sym.px} ${paneffop}.$${paneffarg.hex02()} ${sym.fx} ${fxName} $${effarg.hex04()}`)
|
||
} else {
|
||
const dx = PATEDITOR_DETAIL_X
|
||
const detailW = SCRW - dx + 1
|
||
|
||
let voleffop1 = (voleffop == 3) ? 30 + (voleffarg >>> 5) : voleffop
|
||
let paneffop1 = (paneffop == 3) ? 30 + (paneffarg >>> 5) : paneffop
|
||
let voleffarg1 = '$'+((voleffop == 3) ? voleffarg & 15 : voleffarg).hex02()
|
||
let paneffarg1 = '$'+((paneffop == 3) ? paneffarg & 15 : paneffarg).hex02()
|
||
if (voleff == 0xC0) { voleffop1 = 999; voleffarg1 = '' }
|
||
if (paneff == 0xC0) { paneffop1 = 999; paneffarg1 = '' }
|
||
|
||
// Two-column, two-section layout. Upper section: this row's cell fields,
|
||
// split L (Note/Inst/Vx/Px) / R (Fx/FxOp/FxArg). Lower section: cumulative
|
||
// engine state, packed in column-major order across both columns.
|
||
const colW = Math.floor(detailW / 2)
|
||
const col1X = dx
|
||
const col2X = dx + colW
|
||
const labelW = 6
|
||
const valW1 = colW - labelW - 2
|
||
const valW2 = (detailW - colW) - labelW - 2
|
||
|
||
const drawLine = (y, x, line, valWidth) => {
|
||
con.move(y, x)
|
||
con.color_pair(colStatus, 255)
|
||
print((line.label + ' ').substring(0, labelW) + ' ')
|
||
con.color_pair(line.fg, 255)
|
||
const v = (line.value + ' '.repeat(valWidth + 1))
|
||
print(v.substring(0, valWidth + 1))
|
||
}
|
||
const blankLine = (y, x, width) => {
|
||
con.move(y, x)
|
||
con.color_pair(colBackPtn, 255)
|
||
print(' '.repeat(width))
|
||
}
|
||
|
||
const upperLeft = [
|
||
{ label: 'Note ', value: `${noteToStr(note)} ($${note.hex04()})`, fg: colNote },
|
||
{ label: 'Inst ', value: inst === 0 ? '---' : ('$'+inst.hex02()), fg: colInst },
|
||
{ label: 'Vx ', value: `${volFxNames[voleffop1]} ${voleffarg1}`, fg: colVol },
|
||
{ label: 'Px ', value: `${panFxNames[paneffop1]} ${paneffarg1}`, fg: colPan },
|
||
]
|
||
const upperRight = [
|
||
{ label: 'Fx ', value: fxName.trimEnd(), fg: colEffOp },
|
||
{ label: 'FxOp ', value: fx, fg: colEffOp },
|
||
{ label: 'FxArg', value: `$${effarg.hex04()}`, fg: colEffArg },
|
||
]
|
||
const upperHeight = Math.max(upperLeft.length, upperRight.length)
|
||
|
||
for (let i = 0; i < upperHeight; i++) {
|
||
const y = PTNVIEW_OFFSET_Y + i
|
||
if (i < upperLeft.length) drawLine(y, col1X, upperLeft[i], valW1)
|
||
else blankLine(y, col1X, colW)
|
||
if (i < upperRight.length) drawLine(y, col2X, upperRight[i], valW2)
|
||
else blankLine(y, col2X, detailW - colW)
|
||
}
|
||
|
||
// Section divider
|
||
const sepY = PTNVIEW_OFFSET_Y + upperHeight
|
||
con.move(sepY, dx)
|
||
con.color_pair(colSep, 255)
|
||
print(DOTHORZ.repeat(detailW >>> 1))
|
||
if (detailW % 2 == 1) print(DOTHORZ[0])
|
||
|
||
// Lower section: cumulative state.
|
||
const lowerY0 = sepY + 1
|
||
const lowerH = PTNVIEW_HEIGHT - upperHeight - 1
|
||
let cumLines = []
|
||
if (cumState !== null && lowerH > 0) {
|
||
const _apo = Math.abs(cumState.pitchOff)
|
||
const _psgn = cumState.pitchOff > 0 ? '+' : cumState.pitchOff < 0 ? '-' : ' '
|
||
const _absN = (cumState.lastNote !== 0x0000 && cumState.pitchOff !== 0)
|
||
? noteToStr(Math.max(0x20, Math.min(0xFFFF, cumState.lastNote + cumState.pitchOff))) + ' '
|
||
: ''
|
||
const _clipNm = ['clamp','fold','wrap','wrap'][cumState.clipMode]
|
||
const _bcStr = (cumState.bitcrushDepth === 0 && cumState.bitcrushSkip === 0)
|
||
? 'off'
|
||
: `d${cumState.bitcrushDepth.toString(16).toUpperCase()}/s$${cumState.bitcrushSkip.hex02()}`
|
||
const _odStr = (cumState.overdriveAmp === 0) ? 'off' : `$${cumState.overdriveAmp.hex02()}`
|
||
|
||
cumLines = [
|
||
{ label: 'L.Note', value: noteToStr(cumState.lastNote), fg: colNote },
|
||
{ label: 'L.Inst', value: cumState.lastInst === 0 ? '---' : ('$'+cumState.lastInst.hex02()), fg: colInst },
|
||
{ label: 'Vol ', value: `$${cumState.volAbs.hex02()}`, fg: colVol },
|
||
{ label: 'Pan ', value: `$${cumState.panAbs.hex02()}`, fg: colPan },
|
||
{ label: 'Pitch ', value: `${_absN}(${_psgn}$${_apo.hex04()})`, fg: colNote },
|
||
{ label: 'BPM ', value: `${cumState.bpm}`, fg: colStatus },
|
||
{ label: 'Spd ', value: `${cumState.speed}`, fg: colStatus },
|
||
{ label: 'GVol ', value: `$${cumState.globalVol.hex02()}`, fg: colStatus },
|
||
{ label: `E${MIDDOT}F `, value: `$${cumState.memEF.hex04()}`, fg: colEffArg },
|
||
{ label: 'G ', value: `$${cumState.memG.hex04()}`, fg: colEffArg },
|
||
{ label: `H${MIDDOT}U `, value: `$${cumState.memHU.speed.hex02()}/$${cumState.memHU.depth.hex02()}`, fg: colEffArg },
|
||
{ label: 'R ', value: `$${cumState.memR.speed.hex02()}/$${cumState.memR.depth.hex02()}`, fg: colEffArg },
|
||
{ label: 'Y ', value: `$${cumState.memY.speed.hex02()}/$${cumState.memY.depth.hex02()}`, fg: colEffArg },
|
||
{ label: 'D ', value: `$${cumState.memD.hex04()}`, fg: colEffArg },
|
||
{ label: 'I ', value: `$${cumState.memI.hex04()}`, fg: colEffArg },
|
||
{ label: 'J ', value: `$${cumState.memJ.hex04()}`, fg: colEffArg },
|
||
{ label: 'O ', value: `$${cumState.memO.hex04()}`, fg: colEffArg },
|
||
{ label: 'Q ', value: `$${cumState.memQ.hex04()}`, fg: colEffArg },
|
||
{ label: 'Tslid ', value: `$${cumState.memTSlide.hex02()}`, fg: colEffArg },
|
||
{ label: 'W ', value: `$${cumState.memW.hex04()}`, fg: colEffArg },
|
||
{ label: 'BCrsh ', value: _bcStr, fg: colEffArg },
|
||
{ label: 'OvDrv ', value: _odStr, fg: colEffArg },
|
||
{ label: 'Clip ', value: _clipNm, fg: colEffArg },
|
||
]
|
||
}
|
||
|
||
// Column-major fill: cap per-column height to lowerH, drop overflow.
|
||
const perCol = Math.min(lowerH, Math.ceil(cumLines.length / 2))
|
||
const totShow = Math.min(cumLines.length, perCol * 2)
|
||
for (let i = 0; i < perCol; i++) {
|
||
const yL = lowerY0 + i
|
||
const idxL = i
|
||
const idxR = perCol + i
|
||
if (idxL < totShow) drawLine(yL, col1X, cumLines[idxL], valW1)
|
||
else blankLine(yL, col1X, colW)
|
||
if (idxR < totShow) drawLine(yL, col2X, cumLines[idxR], valW2)
|
||
else blankLine(yL, col2X, detailW - colW)
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawAll() {
|
||
con.clear()
|
||
drawAlwaysOnElems()
|
||
drawControlHint()
|
||
redrawPanel()
|
||
con.move(1, 1)
|
||
}
|
||
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// FAST SCROLL — shifts the pattern area in text VRAM so we only redraw newly exposed rows
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
// Graphics adapter text-area layout (see GraphicsAdapter.kt):
|
||
// foreground-colour plane: offset 2
|
||
// background-colour plane: offset 2 + 2560
|
||
// character plane: offset 2 + 2560 + 2560 = 5122
|
||
// Each plane is indexed as y * SCRW + x. Peripheral byte k lives at gpuMem - k.
|
||
const GPU_MEM = graphics.getGpuMemBase() - (250880+4+12+1008+2046)
|
||
const TEXT_FORE_OFF = 2
|
||
const TEXT_BACK_OFF = 2 + 2560
|
||
const TEXT_CHAR_OFF = 2 + 2560 + 2560
|
||
const TEXT_PLANES = [TEXT_CHAR_OFF, TEXT_BACK_OFF, TEXT_FORE_OFF]
|
||
|
||
// One scratch strip, reused across shifts
|
||
const SCRATCH_PTR = sys.malloc(SCRW * PTNVIEW_HEIGHT)
|
||
|
||
// Horizontal salvage
|
||
let SALVAGE_HORIZ_LEN = (VOCSIZE_TIMELINE_FULL - 1) * COLSIZE_TIMELINE_FULL
|
||
|
||
/**
|
||
* Shift the pattern-view rows by `dy` lines (positive = down, negative = up)
|
||
* using bulk peri→main→peri memcpy for speed. Does not touch status bar,
|
||
* voice headers, or anything outside the pattern viewport.
|
||
*/
|
||
function shiftPatternArea(dy) {
|
||
if (dy === 0) return
|
||
const absDy = (dy < 0) ? -dy : dy
|
||
if (absDy >= PTNVIEW_HEIGHT) return // nothing to salvage, caller should full-redraw
|
||
|
||
const srcTopY = (dy > 0) ? PTNVIEW_OFFSET_Y : (PTNVIEW_OFFSET_Y + absDy)
|
||
const dstTopY = (dy > 0) ? (PTNVIEW_OFFSET_Y + absDy) : PTNVIEW_OFFSET_Y
|
||
const stripBytes = (PTNVIEW_HEIGHT - absDy) * SCRW
|
||
|
||
for (let p = 0; p < 3; p++) {
|
||
const chanOff = TEXT_PLANES[p]
|
||
const srcAddr = GPU_MEM - chanOff - (srcTopY - 1) * SCRW
|
||
const dstAddr = GPU_MEM - chanOff - (dstTopY - 1) * SCRW
|
||
sys.memcpy(srcAddr, SCRATCH_PTR, stripBytes)
|
||
sys.memcpy(SCRATCH_PTR, dstAddr, stripBytes)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Shift the voice columns left (dVoice > 0) or right (dVoice < 0) by one column
|
||
* using per-row peri→main→peri memcpy. Only the pattern-view rows are touched;
|
||
* voice headers and status bar must be redrawn by the caller.
|
||
*/
|
||
function shiftPatternAreaHorizontal(dVoice) {
|
||
// Column of the first char to copy (1-indexed); dest is COLSIZE_TIMELINE_FULL chars earlier/later.
|
||
const srcX = PTNVIEW_OFFSET_X + (dVoice > 0 ? COLSIZE_TIMELINE_FULL : 0)
|
||
const dstX = PTNVIEW_OFFSET_X + (dVoice > 0 ? 0 : COLSIZE_TIMELINE_FULL)
|
||
const srcOff = srcX - 1 // 0-indexed offset from column 1 for address arithmetic
|
||
const dstOff = dstX - 1
|
||
|
||
for (let p = 0; p < 3; p++) {
|
||
const chanOff = TEXT_PLANES[p]
|
||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
|
||
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, SALVAGE_HORIZ_LEN)
|
||
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, SALVAGE_HORIZ_LEN)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Redraw every row of one voice column (slot 0..VOCSIZE_TIMELINE_FULL-1) after a horizontal shift.
|
||
* Also redraws separators for the whole row so any separator at the exposed boundary
|
||
* (which the VRAM shift left correct) is confirmed visually consistent.
|
||
*/
|
||
function drawVoiceColumnAt(slot) {
|
||
const voice = voiceOff + slot
|
||
const x = PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * slot
|
||
const cue = song.cues[cueIdx]
|
||
const ptnIdx = (voice < song.numVoices) ? cue.ptns[voice] : CUE_EMPTY
|
||
|
||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||
const actualRow = scrollRow + vr
|
||
const y = PTNVIEW_OFFSET_Y + vr
|
||
const highlight = (actualRow === cursorRow)
|
||
const back = highlight ? (playbackMode !== PLAYMODE_NONE ? colPlayback : colHighlight) : colBackPtn
|
||
|
||
let cell = EMPTY_CELL
|
||
if (actualRow < ROWS_PER_PAT && voice < song.numVoices &&
|
||
ptnIdx !== CUE_EMPTY && ptnIdx < song.numPats) {
|
||
cell = buildRowCell(song.patterns[ptnIdx], actualRow)
|
||
}
|
||
drawCellAtStyled(y, x, cell, back, timelineRowStyle)
|
||
if (timelineRowStyle === 0 && highlight && playbackMode === PLAYMODE_NONE && voice === cursorVox) {
|
||
const fieldStr = [cell.sNote, cell.sInst, cell.sVolEff+cell.sVolArg,
|
||
cell.sPanEff+cell.sPanArg, cell.sEffOp, cell.sEffArg][timelineColCursor]
|
||
con.move(y, x + TL_FIELD_OFFSETS[timelineColCursor])
|
||
con.color_pair(TL_FIELD_FGS[timelineColCursor], colPlayback)
|
||
print(fieldStr)
|
||
}
|
||
}
|
||
}
|
||
|
||
function setTimelineRowStyle(style) {
|
||
timelineRowStyle = style
|
||
COLSIZE_TIMELINE_FULL = TIMELINE_COLSIZES[style]
|
||
VOCSIZE_TIMELINE_FULL = Math.floor((SCRW - 3) / COLSIZE_TIMELINE_FULL)
|
||
SALVAGE_HORIZ_LEN = (VOCSIZE_TIMELINE_FULL - 1) * COLSIZE_TIMELINE_FULL
|
||
// Slot widths and per-slot voice mapping are about to change; wipe meter pixels so the
|
||
// narrower/wider layout doesn't leave stale bar fragments from the old slot widths.
|
||
clearVoiceMeters()
|
||
clampVoice()
|
||
drawAll()
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// APPLICATION STUB
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
con.curs_set(0)
|
||
graphics.setBackground(0x23,0x39,0x58)
|
||
//graphics.setBackground(0x12,0x32,0x5f)
|
||
graphics.setGraphicsMode(0)
|
||
|
||
let currentPanel = VIEW_TIMELINE
|
||
let cueIdx = 0
|
||
let cursorRow = 0
|
||
let scrollRow = 0
|
||
let voiceOff = 0
|
||
let cursorVox = 0
|
||
let timelineColCursor = 0 // sub-field within cursorVox (0=note,1=inst,2=vol,3=pan,4=fxop,5=fxarg)
|
||
let ordersCursor = 0
|
||
let ordersScroll = 0
|
||
let ordersColCursor = 0 // 0=Cmd, 1..numVoices=voice columns
|
||
let ordersVoiceOff = 0 // horizontal scroll for voice columns
|
||
let patternIdx = 0
|
||
let patternListScroll = 0
|
||
let patternGridRow = 0
|
||
let patternGridScroll = 0
|
||
let patternGridCol = 0
|
||
let simState = null
|
||
let simStateKey = ''
|
||
|
||
if (exec_args[1] === undefined) {
|
||
println(`Usage: ${exec_args[0]} path_to.taud`)
|
||
return 1
|
||
}
|
||
|
||
const fullPathObj = _G.shell.resolvePathInput(exec_args[1])
|
||
if (fullPathObj === undefined) {
|
||
println(`taut: cannot resolve path: ${exec_args[1]}`)
|
||
return 1
|
||
}
|
||
|
||
const logofile = files.open("A:"+_TVDOS.variables.DOSDIR+"/bin/tauthdr.r8")
|
||
const logoBytes = logofile.bread(); logofile.close()
|
||
const logoTexture = new gl.Texture(92, 14, logoBytes)
|
||
const buttonfile = files.open("A:"+_TVDOS.variables.DOSDIR+"/bin/tautbtn.r8")
|
||
const buttonBytes = buttonfile.bread(); buttonfile.close()
|
||
const buttonTexture = new gl.Texture(2, 28, buttonBytes)
|
||
//const buttonNullfile = files.open("A:"+_TVDOS.variables.DOSDIR+"/bin/tautbtn0.r8")
|
||
//const buttonNullBytes = buttonNullfile.bread(); buttonNullfile.close()
|
||
//const buttonNullTexture = new gl.Texture(35, 28, buttonNullBytes)
|
||
|
||
font.setLowRom("A:"+_TVDOS.variables.DOSDIR+"/bin/tautfont_low.chr")
|
||
font.setHighRom("A:"+_TVDOS.variables.DOSDIR+"/bin/tautfont_high.chr")
|
||
const songsMeta = loadTaudSongList(fullPathObj.full)
|
||
let currentSongIndex = 0
|
||
// Unified cursor: 0..PROJ_META_ROWS_COUNT-1 = editable meta rows (Flags / GVol / MVol);
|
||
// >= PROJ_META_ROWS_COUNT = song list, songIdx = projectCursor - PROJ_META_ROWS_COUNT
|
||
let projectCursor = 0
|
||
const PROJ_META_ROWS_COUNT = 3
|
||
const PROJ_META_FLAGS = 0
|
||
const PROJ_META_GVOL = 1
|
||
const PROJ_META_MVOL = 2
|
||
let song = loadTaud(fullPathObj.full, currentSongIndex)
|
||
|
||
const voiceMutes = new Array(NUM_VOICES).fill(false)
|
||
let timelineMuteSnapshot = null
|
||
|
||
function resetAudioDevice() {
|
||
audio.resetParams(PLAYHEAD)
|
||
audio.purgeQueue(PLAYHEAD)
|
||
audio.stop(PLAYHEAD)
|
||
}
|
||
|
||
function applyMuteTransition(toPanel) {
|
||
if (toPanel === VIEW_PATTERN_DETAILS) {
|
||
timelineMuteSnapshot = voiceMutes.slice()
|
||
if (voiceMutes[0]) {
|
||
voiceMutes[0] = false
|
||
audio.setVoiceMute(PLAYHEAD, 0, false)
|
||
}
|
||
} else if (toPanel === VIEW_TIMELINE && timelineMuteSnapshot !== null) {
|
||
for (let i = 0; i < song.numVoices; i++) {
|
||
voiceMutes[i] = timelineMuteSnapshot[i]
|
||
audio.setVoiceMute(PLAYHEAD, i, voiceMutes[i])
|
||
}
|
||
timelineMuteSnapshot = null
|
||
}
|
||
}
|
||
|
||
// Switch the active song within the currently-open multi-song .taud file.
|
||
// Re-uploads patterns+cues (and the shared sample/inst bin) to the audio
|
||
// adapter, reloads song metadata, and resets per-song UI / playback state.
|
||
function switchSong(newIndex) {
|
||
if (newIndex < 0 || newIndex >= songsMeta.numSongs) return
|
||
if (newIndex === currentSongIndex) return
|
||
|
||
stopPlayback()
|
||
resetAudioDevice()
|
||
|
||
currentSongIndex = newIndex
|
||
song = loadTaud(fullPathObj.full, newIndex)
|
||
refreshSamplesCache()
|
||
|
||
const newPitchIdx = songsMeta.songs[newIndex].pitchPresetIdx
|
||
PITCH_PRESET_IDX = (newPitchIdx != null && pitchTablePresets[newPitchIdx])
|
||
? newPitchIdx
|
||
: PITCH_PRESET_IDX_DEFAULT
|
||
rebuildPitchLut()
|
||
|
||
taud.uploadTaudFile(fullPathObj.full, newIndex, PLAYHEAD)
|
||
patternsOutOfSync = false
|
||
audio.setMasterVolume(PLAYHEAD, 255)
|
||
audio.setMasterPan(PLAYHEAD, 128)
|
||
initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
|
||
initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD)
|
||
initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD)
|
||
|
||
// Reset per-song UI state
|
||
cueIdx = 0; cursorRow = 0; scrollRow = 0; voiceOff = 0; cursorVox = 0
|
||
timelineColCursor = 0
|
||
ordersCursor = 0; ordersScroll = 0; ordersColCursor = 0; ordersVoiceOff = 0
|
||
patternIdx = 0; patternListScroll = 0
|
||
patternGridRow = 0; patternGridScroll = 0; patternGridCol = 0
|
||
simState = null; simStateKey = ''
|
||
|
||
for (let i = 0; i < NUM_VOICES; i++) {
|
||
voiceMutes[i] = false
|
||
audio.setVoiceMute(PLAYHEAD, i, false)
|
||
}
|
||
timelineMuteSnapshot = null
|
||
|
||
pbCue = 0; pbRow = 0
|
||
previewActive = false
|
||
|
||
clampCursor(); clampVoice(); clampCue(); clampOrdersHoriz(); clampPatternIdx(); clampPatternGrid()
|
||
drawAll()
|
||
}
|
||
|
||
function redrawFull() { drawAll() }
|
||
|
||
function redrawPanel() {
|
||
panels[currentPanel].drawContents()
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// PANELS
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
function drawTimelineContents(wo) {
|
||
drawVoiceHeaders()
|
||
drawPatternView()
|
||
drawSeparators(separatorStyle)
|
||
drawVoiceDetail()
|
||
}
|
||
|
||
function drawOrdersHeader() {
|
||
fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255)
|
||
con.move(PTNVIEW_OFFSET_Y - 1, 1)
|
||
con.color_pair(colVoiceHdr, 255)
|
||
print(' ')
|
||
con.color_pair(colVoiceHdr, ordersColCursor === 0 ? colHighlight : 255)
|
||
print('Comand ')
|
||
for (let c = 0; c < VOCSIZE_ORDERS; c++) {
|
||
const v = ordersVoiceOff + c
|
||
con.color_pair(colVoiceHdr, ordersColCursor === v + 1 ? colHighlight : 255)
|
||
print(v < song.numVoices ? `V${(v+1).dec02()} ` : ' ')
|
||
}
|
||
}
|
||
|
||
function drawOrdersRowAt(ci) {
|
||
const vr = ci - ordersScroll
|
||
if (vr < 0 || vr >= PTNVIEW_HEIGHT) return
|
||
const y = PTNVIEW_OFFSET_Y + vr
|
||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||
const isSel = (ci === ordersCursor)
|
||
const isCur = playbackMode !== PLAYMODE_NONE && ci === cueIdx
|
||
const back = isSel ? (playbackMode !== PLAYMODE_NONE ? colPlayback : colHighlight)
|
||
: (isCur ? colPlayback : colBackPtn)
|
||
|
||
con.move(y, 1)
|
||
if (ci > maxCue) {
|
||
con.color_pair(colBackPtn, colBackPtn)
|
||
print(' '.repeat(SCRW - 1))
|
||
return
|
||
}
|
||
|
||
const cue = song.cues[ci]
|
||
con.color_pair(ci % 4 === 0 ? colRowNumEmph1 : colRowNum, back)
|
||
print(ci.hex03())
|
||
con.color_pair(colBackPtn, back)
|
||
print(' ')
|
||
// 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 ? cueInstToStr(cue.instr) : '------')
|
||
con.color_pair(colBackPtn, back)
|
||
print(' ')
|
||
// Voice columns
|
||
for (let c = 0; c < VOCSIZE_ORDERS; c++) {
|
||
const v = ordersVoiceOff + c
|
||
const ptn = v < song.numVoices ? cue.ptns[v] : CUE_EMPTY
|
||
const vBack = (isSel && ordersColCursor === v + 1) ? colPlayback : back
|
||
con.color_pair(ptn === CUE_EMPTY ? colSep : colStatus, vBack)
|
||
print(ptn === CUE_EMPTY ? '---' : ptn.hex03())
|
||
con.color_pair(colBackPtn, back)
|
||
print(' ')
|
||
}
|
||
const endX = ORDERS_VOICE_X + VOCSIZE_ORDERS * ORDERS_VOICE_COL_W
|
||
if (endX <= SCRW) { con.color_pair(colBackPtn, back); print(' '.repeat(SCRW - endX)) }
|
||
}
|
||
|
||
function drawOrdersContents(wo) {
|
||
drawOrdersHeader()
|
||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) drawOrdersRowAt(ordersScroll + vr)
|
||
}
|
||
|
||
// Redraw all rows of one voice column slot (0..VOCSIZE_ORDERS-1).
|
||
function drawOrdersVoiceColumnAt(slot) {
|
||
const v = ordersVoiceOff + slot
|
||
const x = ORDERS_VOICE_X + slot * ORDERS_VOICE_COL_W
|
||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||
|
||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||
const ci = ordersScroll + vr
|
||
const y = PTNVIEW_OFFSET_Y + vr
|
||
|
||
if (ci > maxCue) {
|
||
con.move(y, x)
|
||
con.color_pair(colBackPtn, colBackPtn)
|
||
print(' ')
|
||
continue
|
||
}
|
||
const isSel = (ci === ordersCursor)
|
||
const isCur = playbackMode !== PLAYMODE_NONE && ci === cueIdx
|
||
const back = isSel ? (playbackMode !== PLAYMODE_NONE ? colPlayback : colHighlight)
|
||
: (isCur ? colPlayback : colBackPtn)
|
||
const cue = song.cues[ci]
|
||
const ptn = v < song.numVoices ? cue.ptns[v] : CUE_EMPTY
|
||
const vBack = (isSel && ordersColCursor === v + 1) ? colPlayback : back
|
||
|
||
con.move(y, x)
|
||
con.color_pair(ptn === CUE_EMPTY ? colSep : colStatus, vBack)
|
||
print(ptn === CUE_EMPTY ? '---' : ptn.hex03())
|
||
con.color_pair(colBackPtn, back)
|
||
print(' ')
|
||
}
|
||
}
|
||
|
||
// Memory-shift the voice-column area horizontally by `dVoice` voice columns.
|
||
// Positive = scroll left (new column exposed on right); negative = scroll right.
|
||
// Touches body rows only; the header and Cmd column are untouched.
|
||
function shiftOrdersAreaHorizontal(dVoice) {
|
||
if (dVoice === 0) return
|
||
const absD = (dVoice < 0) ? -dVoice : dVoice
|
||
if (absD >= VOCSIZE_ORDERS) return // nothing to salvage
|
||
|
||
const stripWidth = (VOCSIZE_ORDERS - absD) * ORDERS_VOICE_COL_W
|
||
const srcX = ORDERS_VOICE_X + (dVoice > 0 ? absD * ORDERS_VOICE_COL_W : 0)
|
||
const dstX = ORDERS_VOICE_X + (dVoice > 0 ? 0 : absD * ORDERS_VOICE_COL_W)
|
||
const srcOff = srcX - 1
|
||
const dstOff = dstX - 1
|
||
|
||
for (let p = 0; p < 3; p++) {
|
||
const chanOff = TEXT_PLANES[p]
|
||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
|
||
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, stripWidth)
|
||
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, stripWidth)
|
||
}
|
||
}
|
||
}
|
||
|
||
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])
|
||
const shiftDown = (event.includes(59) || event.includes(60))
|
||
const moveDelta = shiftDown ? 4 : 1
|
||
|
||
if (keyJustHit && shiftDown && event.includes(keys.W)) { setTimelineRowStyle(0); return }
|
||
if (keyJustHit && shiftDown && event.includes(keys.E)) { setTimelineRowStyle(1); return }
|
||
if (keyJustHit && shiftDown && event.includes(keys.R)) { setTimelineRowStyle(2); return }
|
||
|
||
if (keyJustHit && (keysym === '[' || keysym === ']')) { nudgeTickRate(keysym === '[' ? -1 : 1); return }
|
||
|
||
if (playbackMode !== PLAYMODE_NONE) {
|
||
if (keyJustHit && shiftDown && event.includes(keys.Y) || keysym === " ") { stopPlayback(); redrawPanel(); drawAlwaysOnElems() }
|
||
else if (keysym === "<LEFT>" || keysym === "<RIGHT>") {
|
||
const dir = (keysym === "<LEFT>") ? -1 : 1
|
||
const oldVoiceOff = voiceOff
|
||
cursorVox += dir * moveDelta
|
||
timelineColCursor = 0
|
||
clampVoice()
|
||
const dVoice = voiceOff - oldVoiceOff
|
||
if (dVoice !== 0) { shiftPatternAreaHorizontal(dVoice); drawVoiceColumnAt(dVoice > 0 ? VOCSIZE_TIMELINE_FULL - 1 : 0) }
|
||
drawVoiceHeaders(); drawSeparators(separatorStyle); drawAlwaysOnElems(); drawVoiceDetail()
|
||
}
|
||
else if (keyJustHit && !shiftDown && event.includes(keys.M)) { toggleMute(cursorVox) }
|
||
else if (keyJustHit && !shiftDown && event.includes(keys.N)) { toggleSolo(cursorVox) }
|
||
return
|
||
}
|
||
|
||
if (keyJustHit && shiftDown && event.includes(keys.Y)) { startPlaySong(); redrawPanel(); return }
|
||
if (keyJustHit && shiftDown && event.includes(keys.U)) { startPlayCue(); redrawPanel(); return }
|
||
if ( shiftDown && event.includes(keys.I)) { startPlayRow(); drawPatternRowAt(cursorRow - scrollRow); return }
|
||
if (keyJustHit && shiftDown && event.includes(keys.O) || keysym === " ") { stopPlayback(); drawAlwaysOnElems(); return }
|
||
|
||
const oldCursor = cursorRow
|
||
const oldScroll = scrollRow
|
||
let rowMove = false
|
||
let fullRedraw = false
|
||
|
||
if (keysym === "<LEFT>" || keysym === "<RIGHT>") {
|
||
const dir = (keysym === "<LEFT>") ? -1 : 1
|
||
const oldVoiceOff = voiceOff
|
||
const prevVox = cursorVox
|
||
let triedCross = false
|
||
if (shiftDown || timelineRowStyle > 0) {
|
||
cursorVox += dir * moveDelta
|
||
timelineColCursor = dir > 0 ? 0 : 5
|
||
} else {
|
||
timelineColCursor += dir
|
||
if (timelineColCursor < 0) { timelineColCursor = 5; cursorVox--; triedCross = true }
|
||
else if (timelineColCursor > 5) { timelineColCursor = 0; cursorVox++; triedCross = true }
|
||
}
|
||
clampVoice()
|
||
if (triedCross && cursorVox === prevVox) timelineColCursor = dir < 0 ? 0 : 5
|
||
const dVoice = voiceOff - oldVoiceOff
|
||
if (dVoice !== 0) { shiftPatternAreaHorizontal(dVoice); drawVoiceColumnAt(dVoice > 0 ? VOCSIZE_TIMELINE_FULL - 1 : 0) }
|
||
drawVoiceHeaders(); drawSeparators(separatorStyle); drawAlwaysOnElems(); drawVoiceDetail()
|
||
drawPatternRowAt(cursorRow - scrollRow)
|
||
return
|
||
}
|
||
|
||
if (keyJustHit && !shiftDown && event.includes(keys.M)) { toggleMute(cursorVox); return }
|
||
if (keyJustHit && !shiftDown && event.includes(keys.N)) { toggleSolo(cursorVox); return }
|
||
|
||
if (keysym === "<UP>") { cursorRow -= moveDelta; rowMove = true }
|
||
else if (keysym === "<DOWN>") { cursorRow += moveDelta; rowMove = true }
|
||
else if (keysym === "<HOME>") { cursorRow = 0; rowMove = true }
|
||
else if (keysym === "<END>") { cursorRow = ROWS_PER_PAT-1; rowMove = true }
|
||
else if (keysym === "<PAGE_UP>") { cueIdx -= moveDelta; fullRedraw = true }
|
||
else if (keysym === "<PAGE_DOWN>") { cueIdx += moveDelta; fullRedraw = true }
|
||
else return
|
||
|
||
clampCursor(); clampVoice(); clampCue()
|
||
|
||
if (fullRedraw) { drawAll(); return }
|
||
if (!rowMove || cursorRow === oldCursor) return
|
||
|
||
const dScroll = scrollRow - oldScroll
|
||
if (dScroll === 0) {
|
||
drawPatternRowAt(oldCursor - scrollRow)
|
||
drawPatternRowAt(cursorRow - scrollRow)
|
||
} else if (Math.abs(dScroll) >= PTNVIEW_HEIGHT) {
|
||
drawPatternView()
|
||
} else {
|
||
shiftPatternArea(-dScroll)
|
||
if (dScroll > 0) { for (let i = 0; i < dScroll; i++) drawPatternRowAt(PTNVIEW_HEIGHT - 1 - i) }
|
||
else { for (let i = 0; i < -dScroll; i++) drawPatternRowAt(i) }
|
||
if (oldCursor >= scrollRow && oldCursor < scrollRow + PTNVIEW_HEIGHT) drawPatternRowAt(oldCursor - scrollRow)
|
||
drawPatternRowAt(cursorRow - scrollRow)
|
||
}
|
||
drawSeparators(separatorStyle); drawAlwaysOnElems(); drawVoiceDetail()
|
||
}
|
||
|
||
function ordersInput(wo, event) {
|
||
const keysym = event[1]
|
||
const keyJustHit = (1 == event[2])
|
||
const shiftDown = (event.includes(59) || event.includes(60))
|
||
const moveDelta = shiftDown ? 4 : 1
|
||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||
|
||
if (playbackMode !== PLAYMODE_NONE) {
|
||
if ((keyJustHit && shiftDown && event.includes(keys.Y)) || keysym === " ") {
|
||
stopPlayback(); drawAlwaysOnElems()
|
||
}
|
||
return
|
||
}
|
||
|
||
if (keyJustHit && shiftDown && event.includes(keys.U)) {
|
||
cueIdx = ordersCursor; clampCue(); startPlayCue(); drawAlwaysOnElems(); return
|
||
}
|
||
if ((keyJustHit && shiftDown && event.includes(keys.O)) || keysym === " ") {
|
||
stopPlayback(); drawAlwaysOnElems(); return
|
||
}
|
||
|
||
if (keysym === '<UP>' || keysym === '<DOWN>' || keysym === '<PAGE_UP>' || keysym === '<PAGE_DOWN>') {
|
||
const oldCursor = ordersCursor
|
||
const oldScroll = ordersScroll
|
||
|
||
if (keysym === '<UP>') {
|
||
ordersCursor = Math.max(0, ordersCursor - moveDelta)
|
||
} else if (keysym === '<DOWN>') {
|
||
ordersCursor = Math.min(maxCue, ordersCursor + moveDelta)
|
||
} else if (keysym === '<PAGE_UP>') {
|
||
ordersCursor = Math.max(0, ordersCursor - PTNVIEW_HEIGHT)
|
||
} else if (keysym === '<PAGE_DOWN>') {
|
||
ordersCursor = Math.min(maxCue, ordersCursor + PTNVIEW_HEIGHT)
|
||
}
|
||
scrollOrdersTo(ordersCursor)
|
||
|
||
if (ordersCursor === oldCursor && ordersScroll === oldScroll) return
|
||
const dScroll = ordersScroll - oldScroll
|
||
if (dScroll === 0) {
|
||
drawOrdersRowAt(oldCursor)
|
||
drawOrdersRowAt(ordersCursor)
|
||
} else if (Math.abs(dScroll) >= PTNVIEW_HEIGHT) {
|
||
drawOrdersContents(wo)
|
||
} else {
|
||
shiftPatternArea(-dScroll)
|
||
if (dScroll > 0) for (let i = 0; i < dScroll; i++) drawOrdersRowAt(ordersScroll + PTNVIEW_HEIGHT - 1 - i)
|
||
else for (let i = 0; i < -dScroll; i++) drawOrdersRowAt(ordersScroll + i)
|
||
if (oldCursor >= ordersScroll && oldCursor < ordersScroll + PTNVIEW_HEIGHT) drawOrdersRowAt(oldCursor)
|
||
drawOrdersRowAt(ordersCursor)
|
||
}
|
||
} else if (keysym === '<LEFT>' || keysym === '<RIGHT>') {
|
||
const oldVoiceOff = ordersVoiceOff
|
||
const oldColCursor = ordersColCursor
|
||
ordersColCursor += (keysym === '<LEFT>') ? -1 : 1
|
||
clampOrdersHoriz()
|
||
if (ordersColCursor === oldColCursor) return // hit edge
|
||
|
||
const dVoice = ordersVoiceOff - oldVoiceOff
|
||
if (dVoice !== 0) {
|
||
shiftOrdersAreaHorizontal(dVoice)
|
||
if (dVoice > 0) for (let i = 0; i < dVoice; i++) drawOrdersVoiceColumnAt(VOCSIZE_ORDERS - 1 - i)
|
||
else for (let i = 0; i < -dVoice; i++) drawOrdersVoiceColumnAt(i)
|
||
}
|
||
drawOrdersHeader()
|
||
drawOrdersRowAt(ordersCursor)
|
||
} else if (keyJustHit && keysym === '\n') {
|
||
cueIdx = ordersCursor
|
||
clampCue()
|
||
currentPanel = VIEW_TIMELINE
|
||
drawAll()
|
||
return
|
||
} else {
|
||
return
|
||
}
|
||
drawAlwaysOnElems()
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// PATTERN EDITOR PANEL
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
// Returns the visual width of a TSVM string (handles \u0084Nnu escape sequences)
|
||
function visWidth(s) {
|
||
let w = 0, i = 0
|
||
while (i < s.length) {
|
||
if (s.charCodeAt(i) === 0x84) {
|
||
i++
|
||
while (i < s.length && s[i] !== 'u') i++
|
||
i++
|
||
w++
|
||
} else { i++; w++ }
|
||
}
|
||
return w
|
||
}
|
||
|
||
// Centre-anchored scroll: keep `sel` at the middle row of a `vis`-row viewport,
|
||
// clamped at the list's top and bottom. Returns the new scroll offset.
|
||
function centerScroll(sel, scroll, vis, total) {
|
||
if (sel < scroll) scroll = sel
|
||
if (sel < scroll + (vis >>> 1) && scroll > 0) scroll = sel - (vis >>> 1)
|
||
if (sel >= scroll + ((vis + 1) >>> 1)) scroll = sel - ((vis + 1) >>> 1) + 1
|
||
if (scroll < 0) scroll = 0
|
||
if (scroll + vis > total) scroll = Math.max(0, total - vis)
|
||
return scroll
|
||
}
|
||
|
||
function clampPatternIdx() {
|
||
if (song.numPats === 0) { patternIdx = 0; patternListScroll = 0; return }
|
||
if (patternIdx < 0) patternIdx = 0
|
||
if (patternIdx >= song.numPats) patternIdx = song.numPats - 1
|
||
patternListScroll = centerScroll(patternIdx, patternListScroll, PTNVIEW_HEIGHT, song.numPats)
|
||
}
|
||
|
||
function scrollPatternGridTo(row) {
|
||
patternGridScroll = centerScroll(row, patternGridScroll, PTNVIEW_HEIGHT, ROWS_PER_PAT)
|
||
}
|
||
|
||
function scrollOrdersTo(ci) {
|
||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||
ordersScroll = centerScroll(ci, ordersScroll, PTNVIEW_HEIGHT, maxCue + 1)
|
||
}
|
||
|
||
function clampPatternGrid() {
|
||
if (patternGridRow < 0) patternGridRow = 0
|
||
if (patternGridRow >= ROWS_PER_PAT) patternGridRow = ROWS_PER_PAT - 1
|
||
scrollPatternGridTo(patternGridRow)
|
||
if (patternGridCol < 0) patternGridCol = 0
|
||
if (patternGridCol > 5) patternGridCol = 5
|
||
}
|
||
|
||
// Returns the row to use for drawVoiceDetail: pbRow during playback, editor cursor otherwise
|
||
function getActiveRowForDetail() {
|
||
return (playbackMode !== PLAYMODE_NONE) ? pbRow : patternGridRow
|
||
}
|
||
|
||
// Walk pattern rows 0..uptoRow and accumulate engine-visible cohort state.
|
||
// Mirrors AudioAdapter.kt applyTrackerRow / applyEffectRow / applySEffect for the
|
||
// state surfaced in the voice-detail panel. Out of scope: B/C control flow,
|
||
// SEx pattern delay, SBx pattern loop, NNA / past-note actions, envelope toggles.
|
||
function simulateRowState(ptnDat, uptoRow) {
|
||
const OP_1 = 1, OP_8 = 8, OP_9 = 9, OP_A = 10
|
||
const OP_D = 13, OP_E = 14, OP_F = 15, OP_G = 16
|
||
const OP_H = 17, OP_I = 18, OP_J = 19, OP_O = 24
|
||
const OP_Q = 26, OP_R = 27, OP_S = 28, OP_T = 29
|
||
const OP_U = 30, OP_V = 31, OP_W = 32, OP_Y = 34
|
||
|
||
// ST3-style finetune offsets, mirrors AudioAdapter.kt FINETUNE_OFFSET
|
||
const FINETUNE_OFFSET = [
|
||
-0x0154, -0x0132, -0x0111, -0x00E4, -0x00B8, -0x008B, -0x005D, -0x003B,
|
||
0x0000, 0x0023, 0x0046, 0x0074, 0x0098, 0x00C8, 0x00F9, 0x0110
|
||
]
|
||
|
||
let lastNote = 0x0000, lastInst = 0
|
||
let volAbs = 0x3F // 6-bit per-note volume (engine: noteVolume axis;
|
||
// M / N's per-channel axis is not modelled here)
|
||
let panAbs = 0x80 // 8-bit channel pan (engine width); centre = $80
|
||
let pitchOff = 0, portaTarget = -1
|
||
let bpm = audio.getBPM(PLAYHEAD) // best-effort starting tempo
|
||
let speed = audio.getTickRate(PLAYHEAD)
|
||
let globalVol = 0xFF
|
||
let toneMode = 0 // 0=linear, 1=Amiga, 2=linear-freq, 3=reserved
|
||
|
||
let memEF = 0, memG = 0
|
||
let memHU = { speed: 0, depth: 0 }
|
||
let memR = { speed: 0, depth: 0 }
|
||
let memY = { speed: 0, depth: 0 }
|
||
let memD = 0, memI = 0, memJ = 0, memO = 0, memQ = 0, memTSlide = 0, memW = 0
|
||
|
||
// Bitcrusher / overdrive (clipMode shared between OP_8 and OP_9)
|
||
let bitcrushDepth = 0, bitcrushSkip = 0
|
||
let overdriveAmp = 0
|
||
let clipMode = 0
|
||
|
||
// S-effect state
|
||
let glissandoOn = false
|
||
let vibratoWave = 0, tremoloWave = 0, panbrelloWave = 0
|
||
|
||
const clampV = v => Math.max(0, Math.min(0x3F, v | 0))
|
||
const clampP = v => Math.max(0, Math.min(0xFF, v | 0))
|
||
const clampG = v => Math.max(0, Math.min(0xFF, v | 0))
|
||
|
||
const limit = Math.min(uptoRow, ROWS_PER_PAT - 1)
|
||
for (let row = 0; row <= limit; row++) {
|
||
const off = 8 * row
|
||
const note = ptnDat[off] | (ptnDat[off+1] << 8)
|
||
const inst = ptnDat[off+2]
|
||
const voleff = ptnDat[off+3]
|
||
const paneff = ptnDat[off+4]
|
||
const effop = ptnDat[off+5]
|
||
const effarg = ptnDat[off+6] | (ptnDat[off+7] << 8)
|
||
|
||
// Note column
|
||
const isGRow = (effop === OP_G)
|
||
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
|
||
// Track whether this row reloads the per-note default volume. Engine:
|
||
// triggerNote() (and the tone-porta-with-inst branch in advanceRow)
|
||
// seed noteVolume from the instrument's Default Note Volume (byte 196)
|
||
// — only when the row carries an instrument byte; a note-only retrigger
|
||
// (inst === 0) inherits the channel's existing note volume. Tone-porta
|
||
// rows follow the same rule (matches schism csf_instrument_change
|
||
// inst_column branch, effects.c:1302). The per-channel axis
|
||
// (channelVolume, set by Mxx / Nxx) is NOT reset on re-trigger and is
|
||
// not tracked by this simulator. The simulator approximates the seed
|
||
// as 0x3F (legacy fallback) — see the longer note below.
|
||
let reloadDefaultVol = false
|
||
if (note !== 0x0000 && note !== 0x0002 && !(note >= 0x0010 && note <= 0x001F)) {
|
||
if (note === 0x0001) {
|
||
// key-off; sample stays referenced
|
||
} else if (isGRow) {
|
||
portaTarget = note
|
||
if (inst !== 0) reloadDefaultVol = true
|
||
} else if (isNoteDelay) {
|
||
// Delayed trigger: latched but doesn't fire on this row's first tick.
|
||
// For "state at end of row" treat as if it triggered.
|
||
lastNote = note
|
||
pitchOff = 0
|
||
portaTarget = -1
|
||
if (inst !== 0) reloadDefaultVol = true
|
||
} else {
|
||
lastNote = note
|
||
pitchOff = 0
|
||
portaTarget = -1
|
||
if (inst !== 0) reloadDefaultVol = true
|
||
}
|
||
}
|
||
if (inst !== 0) lastInst = inst
|
||
// Default vol reset must happen before the volume column so a SET selector
|
||
// can still override on the same row (engine order: triggerNote → applyVolColumn).
|
||
// Pan: simulator does not track per-instrument default pan, so it never resets
|
||
// panAbs on trigger — this naturally matches the "stay at old value when inst === 0"
|
||
// half of the policy. The engine-side default-pan reload (gated on inst !== 0)
|
||
// is invisible here. Same limitation now applies to default volume: the engine
|
||
// seeds noteVolume from the instrument's byte-196 "Default Note Volume" since
|
||
// 2026-05-09 (terranmon §171, §196), but the simulator has no instrument-byte
|
||
// access, so it falls back to 0x3F — equivalent to the legacy "DNV unset"
|
||
// path. Tracker UI displays may therefore show a slightly off note volume on
|
||
// fresh triggers when the instrument carries a reduced DNV.
|
||
if (reloadDefaultVol) volAbs = 0x3F
|
||
|
||
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).
|
||
const rowHasS80 = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0x8)
|
||
|
||
// Volume column. voleff = (sel<<6) | value6. $C0 = sel 3 / value 0 = empty nop.
|
||
const volSel = (voleff >>> 6) & 3
|
||
const volVal = voleff & 63
|
||
if (voleff !== 0xC0) {
|
||
if (volSel === 0) {
|
||
volAbs = volVal
|
||
} else if (volSel === 1) {
|
||
volAbs = clampV(volAbs + volVal * (speed - 1)) // engine: per non-first tick
|
||
} else if (volSel === 2) {
|
||
volAbs = clampV(volAbs - volVal * (speed - 1))
|
||
} else if (volSel === 3 && volVal !== 0) {
|
||
const mag = volVal & 0x1F
|
||
if ((volVal & 0x20) !== 0) volAbs = clampV(volAbs + mag) // fine up
|
||
else volAbs = clampV(volAbs - mag) // fine down
|
||
}
|
||
}
|
||
|
||
// Pan column. Same encoding as volume. Engine pan is 8-bit; SET expands 6→8 by replicating bits.
|
||
const panSel = (paneff >>> 6) & 3
|
||
const panVal = paneff & 63
|
||
if (paneff !== 0xC0) {
|
||
if (panSel === 0) {
|
||
if (!rowHasS80) panAbs = ((panVal << 2) | (panVal >>> 4)) & 0xFF
|
||
} else if (panSel === 1) {
|
||
panAbs = clampP(panAbs + panVal * (speed - 1))
|
||
} else if (panSel === 2) {
|
||
panAbs = clampP(panAbs - panVal * (speed - 1))
|
||
} else if (panSel === 3 && panVal !== 0) {
|
||
const mag = panVal & 0x1F
|
||
if ((panVal & 0x20) !== 0) panAbs = clampP(panAbs + mag)
|
||
else panAbs = clampP(panAbs - mag)
|
||
}
|
||
}
|
||
|
||
if (effop !== 0 || effarg !== 0) {
|
||
if (effop === OP_1) {
|
||
const flags = (effarg >>> 8) & 0xFF
|
||
toneMode = flags & 3
|
||
}
|
||
else if (effop === OP_8) {
|
||
const x = (effarg >>> 12) & 0xF
|
||
const y = (effarg >>> 8) & 0xF
|
||
const z = effarg & 0xFF
|
||
clipMode = x & 3
|
||
if (effarg === 0) { bitcrushDepth = 0; bitcrushSkip = 0 }
|
||
else if (y !== 0 || z !== 0) { bitcrushDepth = y; bitcrushSkip = z }
|
||
}
|
||
else if (effop === OP_9) {
|
||
const x = (effarg >>> 12) & 0xF
|
||
const z = effarg & 0xFF
|
||
clipMode = x & 3
|
||
if (effarg === 0) overdriveAmp = 0
|
||
else if (z !== 0) overdriveAmp = z
|
||
}
|
||
else if (effop === OP_A) {
|
||
if ((effarg >>> 8) !== 0) speed = (effarg >>> 8)
|
||
}
|
||
else if (effop === OP_D) {
|
||
const raw = (effarg !== 0) ? (memD = effarg) : memD
|
||
if (raw !== 0) {
|
||
const hb = (raw >>> 8) & 0xFF
|
||
const hiNib = (hb >>> 4) & 0xF
|
||
const loNib = hb & 0xF
|
||
if (hb === 0xFF || hb === 0xF0) {
|
||
volAbs = clampV(volAbs + 0xF) // $FF00 / $F000 quirk
|
||
} else if (hiNib === 0xF && loNib !== 0) {
|
||
volAbs = clampV(volAbs - loNib) // $Fy00 fine down
|
||
} else if (loNib === 0xF && hiNib !== 0) {
|
||
volAbs = clampV(volAbs + hiNib) // $xF00 fine up
|
||
} else if (hiNib === 0 && loNib !== 0) {
|
||
volAbs = clampV(volAbs - loNib * (speed - 1)) // $0y00 coarse down
|
||
} else if (hiNib !== 0 && loNib === 0) {
|
||
volAbs = clampV(volAbs + hiNib * (speed - 1)) // $x000 coarse up
|
||
}
|
||
}
|
||
}
|
||
else if (effop === OP_E || effop === OP_F) {
|
||
const raw = (effarg !== 0) ? (memEF = effarg) : memEF
|
||
if (raw !== 0) {
|
||
const fine = (raw & 0xF000) === 0xF000
|
||
const amt = fine ? (raw & 0x0FFF) : raw * (speed - 1)
|
||
if (effop === OP_E) pitchOff -= amt
|
||
else pitchOff += amt
|
||
}
|
||
}
|
||
else if (effop === OP_G) {
|
||
if (effarg !== 0) memG = effarg
|
||
if (portaTarget !== -1 && memG !== 0 && lastNote !== 0x0000) {
|
||
const curPitch = lastNote + pitchOff
|
||
const diff = portaTarget - curPitch
|
||
if (diff !== 0) {
|
||
const absDiff = Math.abs(diff)
|
||
const maxStep = memG * (speed - 1)
|
||
pitchOff += Math.sign(diff) * Math.min(absDiff, maxStep)
|
||
if (absDiff <= maxStep) {
|
||
pitchOff = portaTarget - lastNote
|
||
portaTarget = -1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else if (effop === OP_H || effop === OP_U) {
|
||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||
if (spd !== 0) memHU.speed = spd
|
||
if (dep !== 0) memHU.depth = dep
|
||
}
|
||
else if (effop === OP_R) {
|
||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||
if (spd !== 0) memR.speed = spd
|
||
if (dep !== 0) memR.depth = dep
|
||
}
|
||
else if (effop === OP_Y) {
|
||
const spd = (effarg >>> 8) & 0xFF; const dep = effarg & 0xFF
|
||
if (spd !== 0) memY.speed = spd
|
||
if (dep !== 0) memY.depth = dep
|
||
}
|
||
else if (effop === OP_I) { if (effarg !== 0) memI = effarg }
|
||
else if (effop === OP_J) { if (effarg !== 0) memJ = effarg }
|
||
else if (effop === OP_O) { if (effarg !== 0) memO = effarg }
|
||
else if (effop === OP_Q) { if (effarg !== 0) memQ = effarg }
|
||
else if (effop === OP_S) {
|
||
const sub = (effarg >>> 12) & 0xF
|
||
const x = (effarg >>> 8) & 0xF
|
||
if (sub === 0x1) {
|
||
glissandoOn = (x !== 0)
|
||
} else if (sub === 0x2) {
|
||
pitchOff += FINETUNE_OFFSET[x]
|
||
} else if (sub === 0x3) {
|
||
vibratoWave = x & 3
|
||
} else if (sub === 0x4) {
|
||
tremoloWave = x & 3
|
||
} else if (sub === 0x5) {
|
||
panbrelloWave = x & 3
|
||
} else if (sub === 0x8) {
|
||
panAbs = effarg & 0xFF // S$80xx full 8-bit pan SET
|
||
}
|
||
// 0x6/0x7/0xB/0xC/0xD/0xE/0xF — out of scope (control flow / per-tick / NNA).
|
||
}
|
||
else if (effop === OP_T) {
|
||
const hi = (effarg >>> 8) & 0xFF
|
||
if (hi !== 0) {
|
||
bpm = Math.max(25, Math.min(280, hi + 0x19))
|
||
} else {
|
||
const low = effarg & 0xFF
|
||
if ((low & 0xF0) === 0x00 || (low & 0xF0) === 0x10) memTSlide = low
|
||
// bpm slide accumulates per-tick in the engine; not modelled at row granularity
|
||
}
|
||
}
|
||
else if (effop === OP_V) {
|
||
globalVol = (effarg >>> 8) & 0xFF
|
||
}
|
||
else if (effop === OP_W) {
|
||
const raw = (effarg !== 0) ? (memW = effarg) : memW
|
||
if (raw !== 0) {
|
||
const hb = (raw >>> 8) & 0xFF
|
||
const hiNib = (hb >>> 4) & 0xF
|
||
const loNib = hb & 0xF
|
||
if (hb === 0xFF || hb === 0xF0) {
|
||
globalVol = clampG(globalVol + 0xF)
|
||
} else if (hiNib === 0xF && loNib !== 0) {
|
||
globalVol = clampG(globalVol - loNib)
|
||
} else if (loNib === 0xF && hiNib !== 0) {
|
||
globalVol = clampG(globalVol + hiNib)
|
||
} else if (hiNib === 0 && loNib !== 0) {
|
||
globalVol = clampG(globalVol - loNib * (speed - 1))
|
||
} else if (hiNib !== 0 && loNib === 0) {
|
||
globalVol = clampG(globalVol + hiNib * (speed - 1))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return { lastNote, lastInst, volAbs, panAbs, pitchOff,
|
||
bpm, speed, globalVol,
|
||
toneMode,
|
||
bitcrushDepth, bitcrushSkip, overdriveAmp, clipMode,
|
||
glissandoOn, vibratoWave, tremoloWave, panbrelloWave,
|
||
memEF, memG, memHU, memR, memY,
|
||
memD, memI, memJ, memO, memQ, memTSlide, memW }
|
||
}
|
||
|
||
function drawPatternListColumn() {
|
||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||
const pi = patternListScroll + vr
|
||
const y = PTNVIEW_OFFSET_Y + vr
|
||
const isCur = (pi === patternIdx)
|
||
con.move(y, PATEDITOR_LIST_X)
|
||
if (pi >= song.numPats) {
|
||
con.color_pair(255, colBackPtn)
|
||
print(' ')
|
||
} else {
|
||
con.color_pair(isCur ? colStatus : colRowNum, isCur ? colHighlight : 255)
|
||
print(pi.hex03())
|
||
con.color_pair(colSep, 255)
|
||
print(' ')
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param viewRow which row
|
||
*/
|
||
function drawPatternGridRowAt(viewRow) {
|
||
const actualRow = patternGridScroll + viewRow
|
||
const y = PTNVIEW_OFFSET_Y + viewRow
|
||
|
||
if (actualRow >= ROWS_PER_PAT) {
|
||
con.move(y, PATEDITOR_GRID_X)
|
||
con.color_pair(colBackPtn, 255)
|
||
print(' '.repeat(PATEDITOR_SEP2_X - PATEDITOR_GRID_X))
|
||
return
|
||
}
|
||
|
||
const ptn = song.patterns[patternIdx]
|
||
const isPbRow = (playbackMode !== PLAYMODE_NONE && actualRow === pbRow)
|
||
const isCurRow = (actualRow === patternGridRow)
|
||
// Row number gets highlight bg to mark cursor row; playhead takes colPlayback priority
|
||
const rowNumBack = isPbRow ? colPlayback : (isCurRow ? colHighlight : colBackPtn)
|
||
const cellBack = isPbRow ? colPlayback : colBackPtn
|
||
|
||
con.color_pair(actualRow % 4 === 0 ? colRowNumEmph1 : colRowNum, rowNumBack)
|
||
const rowstr = actualRow.dec02()
|
||
con.move(y, PATEDITOR_GRID_X); con.prnch(rowstr.charCodeAt(0))
|
||
con.move(y, PATEDITOR_GRID_X+1); con.prnch(rowstr.charCodeAt(1))
|
||
con.move(y, PATEDITOR_GRID_X+2)
|
||
con.color_pair(colBackPtn, cellBack); con.addch(32)
|
||
|
||
const cell = buildRowCell(ptn, actualRow)
|
||
drawCellAtStyled(y, PATEDITOR_CELL_X, cell, cellBack, -1)
|
||
|
||
// Overlay sub-field cursor highlight on the cursor row (not playhead).
|
||
// Style -1 fixed column offsets from PATEDITOR_CELL_X: 0,5,8,11,14,15
|
||
if (isCurRow && !isPbRow) {
|
||
const fieldOffsets = [0, 5, 8, 11, 14, 15]
|
||
const fieldStrs = [
|
||
cell.sNote,
|
||
cell.sInst,
|
||
cell.sVolEff + cell.sVolArg,
|
||
cell.sPanEff + cell.sPanArg,
|
||
cell.sEffOp,
|
||
cell.sEffArg,
|
||
]
|
||
const fieldFgs = [colNote, colInst, colVol, colPan, colEffOp, colEffArg]
|
||
const col = patternGridCol
|
||
con.move(y, PATEDITOR_CELL_X + fieldOffsets[col])
|
||
con.color_pair(fieldFgs[col], colHighlight)
|
||
print(fieldStrs[col])
|
||
}
|
||
}
|
||
|
||
function drawPatternGrid() {
|
||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) drawPatternGridRowAt(vr)
|
||
}
|
||
|
||
function drawPatternsHeader() {
|
||
fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255)
|
||
con.move(PTNVIEW_OFFSET_Y - 1, PATEDITOR_LIST_X)
|
||
con.color_pair(colVoiceHdr, 255)
|
||
print('Ptn ')
|
||
con.move(PTNVIEW_OFFSET_Y - 1, PATEDITOR_GRID_X)
|
||
if (song.numPats > 0)
|
||
print(`Pattern ${patternIdx.hex03()} Row ${patternGridRow.dec02()}`)
|
||
}
|
||
|
||
function drawPatternsContents(wo) {
|
||
drawPatternsHeader()
|
||
if (song.numPats === 0) {
|
||
con.move(PTNVIEW_OFFSET_Y, 1)
|
||
con.color_pair(colStatus, 255)
|
||
print('(no patterns)')
|
||
return
|
||
}
|
||
|
||
drawPatternListColumn()
|
||
drawPatternGrid()
|
||
|
||
// Column separators
|
||
con.color_pair(colSep, 255)
|
||
for (let y = PTNVIEW_OFFSET_Y - 1; y < PTNVIEW_OFFSET_Y + PTNVIEW_HEIGHT; y++) {
|
||
con.move(y, PATEDITOR_SEP1_X); con.prnch(VERT)
|
||
con.move(y, PATEDITOR_SEP2_X); con.prnch(VERT)
|
||
}
|
||
|
||
const activeRow = getActiveRowForDetail()
|
||
const key = `${patternIdx}:${activeRow}:${playbackMode}`
|
||
if (key !== simStateKey) {
|
||
simState = simulateRowState(song.patterns[patternIdx], activeRow)
|
||
simStateKey = key
|
||
}
|
||
drawVoiceDetail(true, song.patterns[patternIdx], activeRow, simState)
|
||
}
|
||
|
||
function patternsInput(wo, event) {
|
||
const keysym = event[1]
|
||
const keyJustHit = (1 == event[2])
|
||
const shiftDown = (event.includes(59) || event.includes(60))
|
||
const moveDelta = shiftDown ? 4 : 1
|
||
|
||
if (keyJustHit && (keysym === '[' || keysym === ']')) { nudgeTickRate(keysym === '[' ? -1 : 1); return }
|
||
|
||
if (playbackMode !== PLAYMODE_NONE) {
|
||
if ((keyJustHit && shiftDown && event.includes(keys.Y)) || keysym === " ") {
|
||
stopPlayback(); simStateKey = ''; drawPatternsContents(wo); drawAlwaysOnElems()
|
||
}
|
||
return
|
||
}
|
||
|
||
if (keyJustHit && shiftDown && event.includes(keys.U)) { startPlayPattern(); drawPatternsContents(wo); return }
|
||
if ( shiftDown && event.includes(keys.I)) { startPlayPatternRow(); drawPatternGrid(); return }
|
||
if ((keyJustHit && shiftDown && event.includes(keys.O)) || keysym === " ") { stopPlayback(); drawAlwaysOnElems(); return }
|
||
|
||
if (song.numPats === 0) return
|
||
|
||
if (keysym === '<UP>' || keysym === '<DOWN>') {
|
||
patternGridRow += (keysym === '<UP>') ? -moveDelta : moveDelta
|
||
clampPatternGrid()
|
||
simStateKey = ''
|
||
drawPatternGrid()
|
||
con.color_pair(colSep, 255)
|
||
for (let y = PTNVIEW_OFFSET_Y - 1; y < PTNVIEW_OFFSET_Y + PTNVIEW_HEIGHT; y++) {
|
||
con.move(y, PATEDITOR_SEP1_X); con.prnch(VERT)
|
||
con.move(y, PATEDITOR_SEP2_X); con.prnch(VERT)
|
||
}
|
||
const activeRow = getActiveRowForDetail()
|
||
const key = `${patternIdx}:${activeRow}:${playbackMode}`
|
||
if (key !== simStateKey) { simState = simulateRowState(song.patterns[patternIdx], activeRow); simStateKey = key }
|
||
drawVoiceDetail(true, song.patterns[patternIdx], activeRow, simState)
|
||
drawPatternsHeader()
|
||
return
|
||
}
|
||
|
||
if (keysym === '<HOME>') { patternGridRow = 0; clampPatternGrid(); simStateKey = ''; drawPatternsContents(wo); return }
|
||
if (keysym === '<END>') { patternGridRow = ROWS_PER_PAT-1; clampPatternGrid(); simStateKey = ''; drawPatternsContents(wo); return }
|
||
|
||
if (keysym === '<LEFT>' || keysym === '<RIGHT>') {
|
||
patternGridCol += (keysym === '<LEFT>') ? -1 : 1
|
||
clampPatternGrid()
|
||
drawPatternGridRowAt(patternGridRow - patternGridScroll)
|
||
con.color_pair(colSep, 255)
|
||
con.move(patternGridRow - patternGridScroll + PTNVIEW_OFFSET_Y, PATEDITOR_SEP1_X); con.prnch(VERT)
|
||
con.move(patternGridRow - patternGridScroll + PTNVIEW_OFFSET_Y, PATEDITOR_SEP2_X); con.prnch(VERT)
|
||
return
|
||
}
|
||
|
||
if (keysym === '<PAGE_UP>' || keysym === '<PAGE_DOWN>') {
|
||
patternIdx += (keysym === '<PAGE_UP>') ? -moveDelta : moveDelta
|
||
clampPatternIdx()
|
||
simStateKey = ''
|
||
drawPatternsContents(wo)
|
||
return
|
||
}
|
||
}
|
||
|
||
const panelTimeline = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, timelineInput, drawTimelineContents, undefined, ()=>{})
|
||
const panelOrders = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, ordersInput, drawOrdersContents, undefined, ()=>{})
|
||
const panelPatterns = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, patternsInput, drawPatternsContents, undefined, ()=>{})
|
||
|
||
// External sub-program panels: drawContents launches the sub-program synchronously.
|
||
// The sub-program draws rows 4+ and does NOT touch rows 1-3 (drawn by taut.js before launch).
|
||
// On exit, the sub-program sets _G.TAUT.UI.NEXTPANEL to request a tab switch.
|
||
function makeExternalPanelDraw(progName) {
|
||
return function(wo) {
|
||
// stop any playback first
|
||
stopPlayback()
|
||
// update the top bar
|
||
drawAlwaysOnElems()
|
||
|
||
_G.TAUT.UI.NEXTPANEL = undefined
|
||
_G.shell.execute(`${progName} ${fullPathObj.full} ${currentPanel}`)
|
||
}
|
||
}
|
||
|
||
// Row offsets (within the meta block at the top of the Project panel) of the editable rows.
|
||
const PROJ_META_ROW_FLAGS = 5
|
||
const PROJ_META_ROW_GVOL = 6
|
||
const PROJ_META_ROW_MVOL = 7
|
||
const PROJ_META_VALUE_X = 12
|
||
|
||
function drawProjectContents(wo) {
|
||
fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255)
|
||
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255)
|
||
|
||
let mixerflag = initialTrackerMixerflags
|
||
let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3]
|
||
let intpModeStr = ['Default','None','A500','A1200','SNES','DPCM','',''][(mixerflag >>> 2) & 7]
|
||
let flagStrSelected = [toneModeStr, intpModeStr]
|
||
|
||
|
||
let projMeta = {
|
||
Filename: fullPathObj.string.split('\\').last(),
|
||
ProjName: songsMeta.projectName || '(unnamed)',
|
||
Patterns: `${song.numPats}/4095 ($${song.numPats.hex03()})`,
|
||
Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`,
|
||
Notation: pitchTablePresets[PITCH_PRESET_IDX].name,
|
||
Flags: `${flagStrSelected.join(', ')} ($${mixerflag.hex02()})`,
|
||
GlobalVol: `$${initialGlobalVolume.hex02()}`,
|
||
MixingVol: `$${initialMixingVolume.hex02()}`
|
||
}
|
||
|
||
const editableMap = {
|
||
[PROJ_META_ROW_FLAGS]: PROJ_META_FLAGS,
|
||
[PROJ_META_ROW_GVOL] : PROJ_META_GVOL,
|
||
[PROJ_META_ROW_MVOL] : PROJ_META_MVOL,
|
||
}
|
||
|
||
Object.entries(projMeta).forEach(([key, value], index) => {
|
||
con.move(PTNVIEW_OFFSET_Y + index, 2)
|
||
con.color_pair(colStatus, 255); print(key)
|
||
con.move(PTNVIEW_OFFSET_Y + index, PROJ_META_VALUE_X)
|
||
const isEditable = (index in editableMap)
|
||
const isSelected = isEditable && projectCursor === editableMap[index]
|
||
if (isSelected) {
|
||
con.color_pair(colWHITE, colHighlight); print(' ' + value + ' ')
|
||
} else if (isEditable) {
|
||
con.color_pair(colVoiceHdr, colBackPtn); print(' ' + value + ' ')
|
||
} else {
|
||
con.color_pair(colVoiceHdr, colBLACK); print(value)
|
||
}
|
||
})
|
||
|
||
drawProjectSongList()
|
||
|
||
con.color_pair(colStatus, 255) // reset colour
|
||
}
|
||
|
||
const PROJ_SONGLIST_Y = PTNVIEW_OFFSET_Y + 9 // header row of the song list
|
||
const PROJ_SONGLIST_X = 2
|
||
|
||
function projectSongListRowsVisible() {
|
||
return Math.max(0, SCRH - PROJ_SONGLIST_Y - 1)
|
||
}
|
||
|
||
let projectSongScroll = 0
|
||
|
||
function clampProjectCursor() {
|
||
const n = songsMeta.numSongs
|
||
const maxCur = PROJ_META_ROWS_COUNT + Math.max(0, n - 1)
|
||
if (projectCursor < 0) projectCursor = 0
|
||
if (projectCursor > maxCur) projectCursor = maxCur
|
||
const rowsVis = projectSongListRowsVisible()
|
||
if (projectCursor >= PROJ_META_ROWS_COUNT) {
|
||
const songIdx = projectCursor - PROJ_META_ROWS_COUNT
|
||
if (songIdx < projectSongScroll) projectSongScroll = songIdx
|
||
else if (songIdx >= projectSongScroll + rowsVis)
|
||
projectSongScroll = songIdx - rowsVis + 1
|
||
}
|
||
if (projectSongScroll < 0) projectSongScroll = 0
|
||
}
|
||
|
||
function drawProjectSongList() {
|
||
const headerY = PROJ_SONGLIST_Y
|
||
con.move(headerY, PROJ_SONGLIST_X)
|
||
con.color_pair(colStatus, 255)
|
||
print(`Songs: ${songsMeta.numSongs}`)
|
||
|
||
const rowsVis = projectSongListRowsVisible()
|
||
const colW = SCRW - PROJ_SONGLIST_X - 1
|
||
for (let row = 0; row < rowsVis; row++) {
|
||
const idx = projectSongScroll + row
|
||
const y = headerY + 1 + row
|
||
con.move(y, PROJ_SONGLIST_X)
|
||
if (idx >= songsMeta.numSongs) {
|
||
con.color_pair(colStatus, colBackPtn)
|
||
print(' '.repeat(colW))
|
||
continue
|
||
}
|
||
const s = songsMeta.songs[idx]
|
||
const isActive = (idx === currentSongIndex)
|
||
const isSel = (projectCursor >= PROJ_META_ROWS_COUNT) &&
|
||
(idx === projectCursor - PROJ_META_ROWS_COUNT)
|
||
const back = isSel ? colHighlight : colBackPtn
|
||
|
||
const marker = isActive ? sym.playhead : ' '
|
||
const numStr = (idx + 1).toString().padStart(2, '0')
|
||
const nameRaw = s.name || `(song ${idx + 1})`
|
||
const META_W = 28
|
||
const nameW = Math.max(4, colW - 6 - META_W)
|
||
const nameStr = nameRaw.length > nameW ? nameRaw.substring(0, nameW) : nameRaw.padEnd(nameW)
|
||
const meta = `V${s.numVoices.dec02()} P${s.numPats.toString().padStart(3,'0')}` +
|
||
` BPM${s.bpm.toString().padStart(3,'0')} tk${s.tickRate.dec02()}` +
|
||
` g${s.songGlobalVolume.hex02()}`
|
||
|
||
con.color_pair(isActive ? colWHITE : colVoiceHdr, back)
|
||
print(`${marker} ${numStr} ${nameStr} ${meta}`)
|
||
}
|
||
|
||
// scroll indicator on the right edge
|
||
if (songsMeta.numSongs > rowsVis) {
|
||
const maxScroll = songsMeta.numSongs - rowsVis
|
||
const indPos = (maxScroll === 0) ? 0 : ((projectSongScroll * (rowsVis - 1) / maxScroll) | 0)
|
||
for (let r = 0; r < rowsVis; r++) {
|
||
con.move(headerY + 1 + r, SCRW)
|
||
con.color_pair(colStatus, colBackPtn)
|
||
print(r === indPos ? sym.ticked : sym.unticked)
|
||
}
|
||
}
|
||
}
|
||
|
||
function projectInput(wo, event) {
|
||
if (event[0] !== 'key_down') return
|
||
const keysym = event[1]
|
||
const keyJustHit = (1 == event[2])
|
||
const shiftDown = (event.includes(59) || event.includes(60))
|
||
const moveDelta = shiftDown ? 4 : 1
|
||
|
||
if (playbackMode !== PLAYMODE_NONE) {
|
||
if (keysym === ' ' || (keyJustHit && shiftDown && (event.includes(keys.Y) || event.includes(keys.O)))) {
|
||
stopPlayback(); drawAlwaysOnElems()
|
||
}
|
||
return
|
||
}
|
||
|
||
// if (!keyJustHit) return
|
||
|
||
if (keysym === '<UP>') {
|
||
projectCursor -= moveDelta; clampProjectCursor(); redrawPanel(); return
|
||
}
|
||
if (keysym === '<DOWN>') {
|
||
projectCursor += moveDelta; clampProjectCursor(); redrawPanel(); return
|
||
}
|
||
if (keysym === '<PAGE_UP>') {
|
||
projectCursor -= projectSongListRowsVisible(); clampProjectCursor(); redrawPanel(); return
|
||
}
|
||
if (keysym === '<PAGE_DOWN>') {
|
||
projectCursor += projectSongListRowsVisible(); clampProjectCursor(); redrawPanel(); return
|
||
}
|
||
if (keysym === '<HOME>') {
|
||
projectCursor = 0; clampProjectCursor(); redrawPanel(); return
|
||
}
|
||
if (keysym === '<END>') {
|
||
projectCursor = PROJ_META_ROWS_COUNT + Math.max(0, songsMeta.numSongs - 1)
|
||
clampProjectCursor(); redrawPanel(); return
|
||
}
|
||
if (keysym === '\n') {
|
||
if (projectCursor === PROJ_META_FLAGS) {
|
||
openFlagsPopup()
|
||
} else if (projectCursor === PROJ_META_GVOL) {
|
||
const v = openInlineHexEdit(PTNVIEW_OFFSET_Y + PROJ_META_ROW_GVOL, PROJ_META_VALUE_X, 2, initialGlobalVolume)
|
||
if (v !== null) {
|
||
initialGlobalVolume = v & 0xFF
|
||
audio.setSongGlobalVolume(PLAYHEAD, initialGlobalVolume)
|
||
hasUnsavedChanges = true
|
||
}
|
||
redrawPanel()
|
||
} else if (projectCursor === PROJ_META_MVOL) {
|
||
const v = openInlineHexEdit(PTNVIEW_OFFSET_Y + PROJ_META_ROW_MVOL, PROJ_META_VALUE_X, 2, initialMixingVolume)
|
||
if (v !== null) {
|
||
initialMixingVolume = v & 0xFF
|
||
audio.setSongMixingVolume(PLAYHEAD, initialMixingVolume)
|
||
hasUnsavedChanges = true
|
||
}
|
||
redrawPanel()
|
||
} else {
|
||
const songIdx = projectCursor - PROJ_META_ROWS_COUNT
|
||
if (songIdx !== currentSongIndex) switchSong(songIdx)
|
||
}
|
||
return
|
||
}
|
||
if (keysym === ' ') {
|
||
stopPlayback(); drawAlwaysOnElems(); return
|
||
}
|
||
}
|
||
|
||
function externalPanelInput(wo, event) {}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// SAMPLES VIEWER
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// The Samples tab is an internal viewer: sample list on the left, properties +
|
||
// "used by" instrument list + waveform graphics on the right, and an Edit
|
||
// button that launches the external taut_sampleedit sub-program.
|
||
//
|
||
// Sample identity in .taud is derived from (samplePtr, sampleLen) inside the
|
||
// 256-byte instrument records (terranmon.txt §"Instrument bin"). Conversion
|
||
// scripts pack samples into the 8 MB pool in slot order, so sorting unique
|
||
// pointers ascending lines up with SNam[i] in the project-data block.
|
||
|
||
// Peripheral memory window offsets, from terranmon.txt:1994-1999.
|
||
const TAUT_SBANK_SIZE = 524288 // 512 K window for sample bin
|
||
const TAUT_INST_WINDOW_OFF = 720896 // instrument bin starts here in peri space
|
||
const TAUT_INST_RECORD_SIZE = 256
|
||
const TAUT_INST_COUNT = 256 // slots 0..255; slot 0 is unused
|
||
|
||
// Read one 256-byte instrument record straight out of the audio adapter.
|
||
function readInstRecord(slot) {
|
||
const memBase = audio.getMemAddr()
|
||
const base = TAUT_INST_WINDOW_OFF + slot * TAUT_INST_RECORD_SIZE
|
||
const rec = new Uint8Array(TAUT_INST_RECORD_SIZE)
|
||
for (let i = 0; i < TAUT_INST_RECORD_SIZE; i++) {
|
||
rec[i] = sys.peek(memBase - (base + i)) & 0xFF
|
||
}
|
||
return rec
|
||
}
|
||
|
||
// Decode the fields the viewer actually cares about. Offsets from terranmon.txt:2071+.
|
||
function decodeInstRecord(rec) {
|
||
const samplePtr = (rec[0]) | (rec[1] << 8) | (rec[2] << 16) | (rec[3] * 0x1000000)
|
||
const sampleLen = rec[4] | (rec[5] << 8)
|
||
const c4Rate = rec[6] | (rec[7] << 8)
|
||
const playStart = rec[8] | (rec[9] << 8)
|
||
const loopStart = rec[10] | (rec[11] << 8)
|
||
const loopEnd = rec[12] | (rec[13] << 8)
|
||
const sampleFlags = rec[14]
|
||
const instGV = rec[171]
|
||
const defNoteVol = rec[196]
|
||
const detune = rec[184] | (rec[185] << 8)
|
||
return {
|
||
samplePtr, sampleLen, c4Rate, playStart, loopStart, loopEnd,
|
||
sampleFlags, instGV, defNoteVol, detune
|
||
}
|
||
}
|
||
|
||
// Scan all 256 instruments and build the deduped sample list. Each returned
|
||
// entry is { ptr, len, c4Rate, playStart, loopStart, loopEnd, sampleFlags,
|
||
// usedBy[], name }. usedBy is a list of instrument slot numbers (1..255).
|
||
let samplesCache = null
|
||
|
||
function buildSampleIndex() {
|
||
const byPtr = new Map()
|
||
for (let i = 1; i < TAUT_INST_COUNT; i++) {
|
||
const d = decodeInstRecord(readInstRecord(i))
|
||
if (d.sampleLen === 0) continue
|
||
const key = d.samplePtr + ':' + d.sampleLen
|
||
if (!byPtr.has(key)) {
|
||
byPtr.set(key, {
|
||
ptr: d.samplePtr,
|
||
len: d.sampleLen,
|
||
c4Rate: d.c4Rate,
|
||
playStart: d.playStart,
|
||
loopStart: d.loopStart,
|
||
loopEnd: d.loopEnd,
|
||
sampleFlags:d.sampleFlags,
|
||
usedBy: [],
|
||
name: ''
|
||
})
|
||
}
|
||
byPtr.get(key).usedBy.push(i)
|
||
}
|
||
const list = Array.from(byPtr.values()).sort((a, b) => a.ptr - b.ptr)
|
||
const names = (songsMeta && songsMeta.sampleNames) || []
|
||
for (let i = 0; i < list.length; i++) {
|
||
// SNam is slot-indexed (entry 0 unused); converters keep sample order
|
||
// identical to pool order, so list[i] corresponds to names[i+1].
|
||
const n = names[i + 1]
|
||
list[i].name = (n != null) ? n : ''
|
||
}
|
||
return list
|
||
}
|
||
|
||
function refreshSamplesCache() { samplesCache = buildSampleIndex() }
|
||
|
||
// ── Layout ───────────────────────────────────────────────────────────────────
|
||
// Panel area is rows PTNVIEW_OFFSET_Y .. SCRH-1 (the hint bar lives at SCRH).
|
||
// Columns mirror the Patterns tab: list body | scroll-bar col | VERT separator | right pane.
|
||
const SMP_LIST_X = 1
|
||
const SMP_LIST_BODY_W = 26 // text width of one list row
|
||
const SMP_LIST_W = SMP_LIST_BODY_W + 1 // body + 1-col scroll indicator
|
||
const SMP_LIST_SCROLL_X = SMP_LIST_X + SMP_LIST_BODY_W // scroll-indicator column
|
||
const SMP_LIST_Y = PTNVIEW_OFFSET_Y
|
||
const SMP_LIST_H = PTNVIEW_HEIGHT // full panel height
|
||
const SMP_SEP_X = SMP_LIST_X + SMP_LIST_W // vertical separator column
|
||
const SMP_RIGHT_X = SMP_SEP_X + 1
|
||
const SMP_RIGHT_Y = PTNVIEW_OFFSET_Y
|
||
const SMP_PROP_H = 10 // rows 5..14
|
||
const SMP_USED_Y = SMP_RIGHT_Y + SMP_PROP_H // header row
|
||
const SMP_USED_HDR_H = 1
|
||
const SMP_USED_LIST_H = 5
|
||
const SMP_WAVE_Y = SMP_USED_Y + SMP_USED_HDR_H + SMP_USED_LIST_H // row 21
|
||
const SMP_BTN_Y = SCRH - 1 // bottom-most panel row, reserved for Edit button
|
||
const SMP_WAVE_H_ROWS = SMP_BTN_Y - SMP_WAVE_Y // visual rows used by the waveform
|
||
|
||
const colSmpListBg = colBackPtn
|
||
const colSmpListSel = colHighlight
|
||
const colSmpListNumFg = colInst
|
||
const colSmpListNameFg = colStatus
|
||
const colSmpPropLabel = colVoiceHdr
|
||
const colSmpPropValue = colWHITE
|
||
const colSmpUsedHdr = colVoiceHdr
|
||
const colSmpUsedFg = colInst
|
||
const colSmpWaveLine = 77 // bright cyan-ish; visible on dark bg
|
||
const colSmpWaveMid = 246 // dim grey for zero-line
|
||
|
||
let smpListScroll = 0
|
||
let smpListCursor = 0
|
||
|
||
function clampSamplesCursor() {
|
||
const n = samplesCache ? samplesCache.length : 0
|
||
if (smpListCursor < 0) smpListCursor = 0
|
||
if (smpListCursor >= n) smpListCursor = Math.max(0, n - 1)
|
||
if (smpListCursor < smpListScroll) smpListScroll = smpListCursor
|
||
if (smpListCursor >= smpListScroll + SMP_LIST_H)
|
||
smpListScroll = smpListCursor - SMP_LIST_H + 1
|
||
if (smpListScroll < 0) smpListScroll = 0
|
||
}
|
||
|
||
function drawSamplesListColumn() {
|
||
const n = samplesCache ? samplesCache.length : 0
|
||
for (let row = 0; row < SMP_LIST_H; row++) {
|
||
const idx = smpListScroll + row
|
||
const y = SMP_LIST_Y + row
|
||
con.move(y, SMP_LIST_X)
|
||
if (idx >= n) {
|
||
con.color_pair(colSmpListNameFg, colSmpListBg)
|
||
print(' '.repeat(SMP_LIST_BODY_W))
|
||
continue
|
||
}
|
||
const s = samplesCache[idx]
|
||
const isSel = (idx === smpListCursor)
|
||
const back = isSel ? colSmpListSel : colSmpListBg
|
||
const numStr = (idx + 1).toString(16).toUpperCase().padStart(2, '0')
|
||
const nameRaw = (s.name && s.name.length) ? s.name : '(sample ' + (idx + 1) + ')'
|
||
const nameW = SMP_LIST_BODY_W - 6 // ' NN name ' totals 6 + N chars
|
||
const nameStr = (nameRaw.length > nameW ? nameRaw.substring(0, nameW) : nameRaw.padEnd(nameW))
|
||
con.color_pair(colSmpListNumFg, back); print(' ' + numStr + ' ')
|
||
con.color_pair(colSmpListNameFg, back); print(' ')
|
||
con.color_pair(isSel ? colWHITE : colSmpListNameFg, back); print(nameStr)
|
||
con.color_pair(colSmpListNameFg, back); print(' ')
|
||
}
|
||
// scroll indicator on the rightmost column of the list area (left of the separator)
|
||
if (n > SMP_LIST_H) {
|
||
const maxScroll = n - SMP_LIST_H
|
||
const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0)
|
||
for (let r = 0; r < SMP_LIST_H; r++) {
|
||
con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X)
|
||
con.color_pair(colStatus, colSmpListBg)
|
||
print(r === indPos ? sym.ticked : sym.unticked)
|
||
}
|
||
} else {
|
||
for (let r = 0; r < SMP_LIST_H; r++) {
|
||
con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X)
|
||
con.color_pair(colStatus, colSmpListBg); print(' ')
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawSamplesSeparator() {
|
||
con.color_pair(colSep, colBackPtn)
|
||
for (let y = SMP_LIST_Y; y < SCRH; y++) {
|
||
con.move(y, SMP_SEP_X); con.prnch(VERT)
|
||
}
|
||
}
|
||
|
||
function loopModeName(flags) {
|
||
const lp = flags & 3
|
||
const sus = (flags >>> 2) & 1
|
||
const names = ['none', 'forward', 'pingpong', 'oneshot']
|
||
return names[lp] + (sus ? ' (sustain)' : '')
|
||
}
|
||
|
||
function drawSamplesProperties() {
|
||
const rightW = SCRW - SMP_RIGHT_X + 1
|
||
// Clear right side
|
||
for (let r = 0; r < SMP_PROP_H + SMP_USED_HDR_H + SMP_USED_LIST_H; r++) {
|
||
con.move(SMP_RIGHT_Y + r, SMP_RIGHT_X)
|
||
con.color_pair(colSmpPropValue, colBackPtn)
|
||
print(' '.repeat(rightW))
|
||
}
|
||
|
||
const n = samplesCache ? samplesCache.length : 0
|
||
if (n === 0) {
|
||
con.move(SMP_RIGHT_Y, SMP_RIGHT_X)
|
||
con.color_pair(colSmpPropLabel, colBackPtn)
|
||
print('No samples in this project.')
|
||
return
|
||
}
|
||
|
||
const s = samplesCache[smpListCursor]
|
||
if (!s) return
|
||
|
||
const rows = [
|
||
['Sample #', (smpListCursor + 1).toString(16).toUpperCase().padStart(2, '0') + ' ($' + s.ptr.toString(16).toUpperCase().padStart(6, '0') + ')'],
|
||
['Name', s.name && s.name.length ? s.name : '(unnamed)'],
|
||
['Length', s.len + ' bytes ($' + s.len.toString(16).toUpperCase().padStart(4, '0') + ')'],
|
||
['Rate@C4', s.c4Rate + ' Hz'],
|
||
['Play st.', '$' + s.playStart.toString(16).toUpperCase().padStart(4, '0')],
|
||
['Loop', loopModeName(s.sampleFlags) +
|
||
' [$' + s.loopStart.toString(16).toUpperCase().padStart(4, '0') +
|
||
'..$' + s.loopEnd.toString(16).toUpperCase().padStart(4, '0') + ']'],
|
||
['Bank', ((s.ptr / TAUT_SBANK_SIZE) | 0) + '/15'],
|
||
['Used by', s.usedBy.length + ' instrument' + (s.usedBy.length === 1 ? '' : 's')],
|
||
]
|
||
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const y = SMP_RIGHT_Y + i
|
||
con.move(y, SMP_RIGHT_X)
|
||
con.color_pair(colSmpPropLabel, colBackPtn)
|
||
print((rows[i][0] + ' ').substring(0, 10))
|
||
con.color_pair(colSmpPropValue, colBackPtn)
|
||
const v = rows[i][1]
|
||
const valMax = rightW - 11
|
||
print(v.length > valMax ? v.substring(0, valMax) : v)
|
||
}
|
||
}
|
||
|
||
// Vertical scroll for the "Used by instruments" list (small in this viewer).
|
||
let smpUsedScroll = 0
|
||
|
||
function drawSamplesUsedBy() {
|
||
const rightW = SCRW - SMP_RIGHT_X + 1
|
||
con.move(SMP_USED_Y, SMP_RIGHT_X)
|
||
con.color_pair(colSmpUsedHdr, colBackPtn)
|
||
print('Used by instruments:'.padEnd(rightW))
|
||
|
||
const s = (samplesCache && samplesCache[smpListCursor]) || null
|
||
const used = s ? s.usedBy : []
|
||
const names = (songsMeta && songsMeta.instNames) || []
|
||
const visible = SMP_USED_LIST_H
|
||
|
||
if (smpUsedScroll > Math.max(0, used.length - visible))
|
||
smpUsedScroll = Math.max(0, used.length - visible)
|
||
if (smpUsedScroll < 0) smpUsedScroll = 0
|
||
|
||
for (let r = 0; r < visible; r++) {
|
||
const y = SMP_USED_Y + 1 + r
|
||
con.move(y, SMP_RIGHT_X)
|
||
con.color_pair(colSmpPropValue, colBackPtn)
|
||
const idx = smpUsedScroll + r
|
||
if (idx >= used.length) {
|
||
print(' '.repeat(rightW))
|
||
continue
|
||
}
|
||
const slot = used[idx]
|
||
const iname = names[slot] || '(unnamed)'
|
||
const numStr = '$' + slot.toString(16).toUpperCase().padStart(2, '0')
|
||
con.color_pair(colSmpUsedFg, colBackPtn)
|
||
print(' ' + numStr + ' ')
|
||
con.color_pair(colSmpPropValue, colBackPtn)
|
||
const nameW = rightW - 5
|
||
print(iname.length > nameW ? iname.substring(0, nameW) : iname.padEnd(nameW))
|
||
}
|
||
}
|
||
|
||
// ── Waveform rendering ──────────────────────────────────────────────────────
|
||
// Renders one sample under the right panel as a min/max envelope, using the
|
||
// graphics layer. Samples are unsigned 8-bit; bank-switch is required because
|
||
// only 512 K of the 8 MB pool is mapped at a time. We restore bank 0 (the
|
||
// playback-expected default) when done.
|
||
|
||
// Pixel rect occupied by the waveform inside the Samples viewer. Both the
|
||
// waveform painter and the leave-Samples cleanup need to reach for the same
|
||
// geometry, so it lives in one helper.
|
||
function sampleWaveformRect() {
|
||
return {
|
||
x: (SMP_RIGHT_X - 1) * CELL_PW,
|
||
y: (SMP_WAVE_Y - 1) * CELL_PH,
|
||
w: (SCRW - SMP_RIGHT_X + 1) * CELL_PW,
|
||
h: SMP_WAVE_H_ROWS * CELL_PH,
|
||
}
|
||
}
|
||
|
||
function clearSampleWaveformArea() {
|
||
const r = sampleWaveformRect()
|
||
graphics.plotRect(r.x-1, r.y-1, r.w+1, r.h+1, 255) // 255 = transparent
|
||
}
|
||
|
||
function drawSampleWaveform() {
|
||
const r = sampleWaveformRect()
|
||
const wx0 = r.x, wy0 = r.y, wW = r.w, wH = r.h
|
||
|
||
// Clear waveform area to transparent (255 = transparent against text bg)
|
||
clearSampleWaveformArea()
|
||
|
||
const s = (samplesCache && samplesCache[smpListCursor]) || null
|
||
if (!s || s.len === 0) return
|
||
|
||
const bankIdxFirst = (s.ptr / TAUT_SBANK_SIZE) | 0
|
||
const bankOff = s.ptr - bankIdxFirst * TAUT_SBANK_SIZE
|
||
const memBase = audio.getMemAddr()
|
||
const prevBank = audio.getSampleBank() || 0
|
||
|
||
// Centre line
|
||
graphics.plotRect(wx0, wy0 + (wH >>> 1), wW, 1, colSmpWaveMid)
|
||
|
||
// Walk the sample at one column per output pixel. For each column we read
|
||
// a chunk and reduce to min/max; vertical extent comes from (max-min).
|
||
// Bank switching is per-step: each output column may straddle banks.
|
||
const samplesPerCol = Math.max(1, (s.len / wW) | 0)
|
||
let pos = 0 // byte offset into the sample, 0..len-1
|
||
let curBank = -1
|
||
for (let col = 0; col < wW; col++) {
|
||
const start = (col * s.len / wW) | 0
|
||
const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0))
|
||
if (end <= start) continue
|
||
|
||
let mn = 255, mx = 0
|
||
// Step in coarse strides for speed when samples are long.
|
||
const step = Math.max(1, ((end - start) / 8) | 0)
|
||
for (let p = start; p < end; p += step) {
|
||
const abs = s.ptr + p
|
||
const bank = (abs / TAUT_SBANK_SIZE) | 0
|
||
if (bank !== curBank) {
|
||
audio.setSampleBank(bank)
|
||
curBank = bank
|
||
}
|
||
const off = abs - bank * TAUT_SBANK_SIZE
|
||
const v = sys.peek(memBase - off) & 0xFF
|
||
if (v < mn) mn = v
|
||
if (v > mx) mx = v
|
||
}
|
||
// unsigned 8-bit → centred around 128
|
||
const yTop = wy0 + ((wH * (255 - mx)) / 255) | 0
|
||
const yBot = wy0 + ((wH * (255 - mn)) / 255) | 0
|
||
const h = Math.max(1, yBot - yTop + 1)
|
||
graphics.plotRect(wx0 + col, yTop, 1, h, colSmpWaveLine)
|
||
}
|
||
|
||
// Restore bank 0 for playback (engine expects bank 0 as default)
|
||
audio.setSampleBank(prevBank)
|
||
}
|
||
|
||
function drawSamplesEditButton() {
|
||
const y = SMP_BTN_Y
|
||
con.move(y, SMP_RIGHT_X)
|
||
con.color_pair(colSmpUsedHdr, colBackPtn)
|
||
print('[ E ]')
|
||
con.color_pair(colSmpPropValue, colBackPtn)
|
||
const label = ' Edit sample'
|
||
print(label)
|
||
const rest = SCRW - (SMP_RIGHT_X + 5 + label.length) + 1
|
||
if (rest > 0) print(' '.repeat(rest))
|
||
}
|
||
|
||
function clearSamplesPanel() {
|
||
// Panel area only — leave the hint row (SCRH) alone; drawControlHint owns it.
|
||
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colSmpPropValue, colBackPtn)
|
||
}
|
||
|
||
function drawSamplesContents(wo) {
|
||
if (samplesCache === null) refreshSamplesCache()
|
||
clampSamplesCursor()
|
||
clearSamplesPanel()
|
||
drawSamplesListColumn()
|
||
drawSamplesSeparator()
|
||
drawSamplesProperties()
|
||
drawSamplesUsedBy()
|
||
drawSampleWaveform()
|
||
drawSamplesEditButton()
|
||
}
|
||
|
||
let pendingEditorLaunch = null // { progName, args[] }
|
||
|
||
function requestEditorLaunch(progName, args) {
|
||
pendingEditorLaunch = { progName, args: args || [] }
|
||
}
|
||
|
||
// Jump into the in-process instrument viewer with the cursor parked on `instSlot`.
|
||
// `instSlotToIdx` is the {slot → cache index} map built by refreshInstrumentsCache;
|
||
// when the slot isn't in the cache (rare — empty slot with no name), we fall back
|
||
// to cursor 0 instead of failing the switch.
|
||
function launchInstrumentViewerFor(instSlot) {
|
||
if (instrumentsCache === null) refreshInstrumentsCache()
|
||
const idx = (instSlotToIdx && instSlotToIdx[instSlot] != null) ? instSlotToIdx[instSlot] : -1
|
||
if (idx >= 0) instListCursor = idx
|
||
clampInstrumentsCursor()
|
||
switchToPanel(VIEW_INSTRMNT)
|
||
}
|
||
|
||
function samplesInput(wo, event) {
|
||
if (event[0] !== 'key_down') return
|
||
const keysym = event[1]
|
||
const keyJustHit = (1 == event[2])
|
||
const shiftDown = (event.includes(59) || event.includes(60))
|
||
const moveDelta = shiftDown ? 8 : 1
|
||
|
||
const n = samplesCache ? samplesCache.length : 0
|
||
if (n === 0) {
|
||
if (keysym === 'e' || keysym === 'E') {
|
||
requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, -1])
|
||
}
|
||
return
|
||
}
|
||
|
||
if (keysym === '<UP>') { smpListCursor -= moveDelta; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return }
|
||
if (keysym === '<DOWN>') { smpListCursor += moveDelta; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return }
|
||
if (keysym === '<PAGE_UP>') { smpListCursor -= SMP_LIST_H; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return }
|
||
if (keysym === '<PAGE_DOWN>') { smpListCursor += SMP_LIST_H; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return }
|
||
if (keysym === '<HOME>') { smpListCursor = 0; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return }
|
||
if (keysym === '<END>') { smpListCursor = n - 1; clampSamplesCursor(); smpUsedScroll = 0; drawSamplesContents(); return }
|
||
|
||
if (keysym === 'e' || keysym === 'E') {
|
||
requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, smpListCursor])
|
||
return
|
||
}
|
||
|
||
if (keysym === '\n') {
|
||
// Open the first instrument that uses this sample in the (stub) inst viewer
|
||
const s = samplesCache[smpListCursor]
|
||
if (s && s.usedBy.length > 0) {
|
||
launchInstrumentViewerFor(s.usedBy[0])
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// END SAMPLES VIEWER
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// INSTRUMENTS VIEWER
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// Mirrors the Samples tab skeleton: list on the left, multi-tabbed property pane
|
||
// on the right. Tabs are General / Volume / Panning / Pitch — the latter three
|
||
// each carry an envelope graph rendered through the graphics layer.
|
||
//
|
||
// All field offsets/encodings follow terranmon.txt §"Instrument bin" (offsets
|
||
// 0..196). Envelope nodes (offsets 21 / 71 / 121) are 25 × {value u8, time u8}
|
||
// where the time byte is a 3.5 unsigned minifloat — converted here using the
|
||
// same decoder formula as ThreeFiveMinifloat.kt / taud_common.py MINUFLOAT_LUT.
|
||
|
||
// 3.5 unsigned minifloat: exp = bits 7..5 (0..7), mant = bits 4..0 (0..31).
|
||
// exp == 0 : value = mant / 256 (smallest non-zero step = 1/256 s)
|
||
// exp > 0 : value = (mant + 32) * 2^(exp - 9) (max = 15.75 s at 0xFF)
|
||
function envTimeFromByte(b) {
|
||
const exp = (b >>> 5) & 7
|
||
const mant = b & 31
|
||
return (exp === 0) ? (mant / 256) : ((mant + 32) * Math.pow(2, exp - 9))
|
||
}
|
||
|
||
// Decode one of the three envelopes from the 256-byte instrument record. `kind`
|
||
// selects the node array (vol/pan/pf) and the LOOP/SUSTAIN word locations.
|
||
// nodes: {value, durByte, durSec} array, truncated at the first dur=0 node
|
||
// (the terminator — see terranmon.txt §envelope nodes "0 = hold").
|
||
// terminatorIdx: index of the terminator, or -1 if all 25 slots are walked.
|
||
function decodeEnvelope(rec, kind) {
|
||
const nodeBase = (kind === 'vol') ? 21 : (kind === 'pan') ? 71 : 121
|
||
const loopOff = (kind === 'vol') ? 15 : (kind === 'pan') ? 17 : 19
|
||
const sustOff = (kind === 'vol') ? 189 : (kind === 'pan') ? 191 : 193
|
||
const valMask = (kind === 'vol') ? 0x3F : 0xFF
|
||
const loopWord = rec[loopOff] | (rec[loopOff + 1] << 8)
|
||
const sustWord = rec[sustOff] | (rec[sustOff + 1] << 8)
|
||
const present = ((loopWord >>> 13) & 1) === 1
|
||
const loopEnable = ((loopWord >>> 5) & 1) === 1
|
||
const loopStart = (loopWord >>> 8) & 0x1F
|
||
const loopEnd = (loopWord) & 0x1F
|
||
const carry = ((loopWord >>> 6) & 1) === 1
|
||
const panUseDef = (kind === 'pan') && (((loopWord >>> 7) & 1) === 1)
|
||
const pfFilter = (kind === 'pf') && (((loopWord >>> 7) & 1) === 1)
|
||
const sustEnable = ((sustWord >>> 5) & 1) === 1
|
||
const sustStart = (sustWord >>> 8) & 0x1F
|
||
const sustEnd = (sustWord) & 0x1F
|
||
const nodes = []
|
||
let terminatorIdx = -1
|
||
for (let i = 0; i < 25; i++) {
|
||
const value = rec[nodeBase + i * 2] & valMask
|
||
const durByte = rec[nodeBase + i * 2 + 1] & 0xFF
|
||
const durSec = envTimeFromByte(durByte)
|
||
nodes.push({ value, durByte, durSec })
|
||
if (durByte === 0) { terminatorIdx = i; break }
|
||
}
|
||
return {
|
||
kind, present, loopEnable, loopStart, loopEnd, carry,
|
||
panUseDef, pfFilter, sustEnable, sustStart, sustEnd,
|
||
nodes, terminatorIdx, valueMax: valMask
|
||
}
|
||
}
|
||
|
||
// Decode the full 256-byte instrument record into a structured object suitable
|
||
// for display. Field offsets/encodings track terranmon.txt §"Instrument bin".
|
||
function decodeInstFull(rec) {
|
||
const samplePtr = (rec[0]) | (rec[1] << 8) | (rec[2] << 16) | (rec[3] * 0x1000000)
|
||
const sampleLen = rec[4] | (rec[5] << 8)
|
||
const c4Rate = rec[6] | (rec[7] << 8)
|
||
const playStart = rec[8] | (rec[9] << 8)
|
||
const sLoopStart = rec[10] | (rec[11] << 8)
|
||
const sLoopEnd = rec[12] | (rec[13] << 8)
|
||
const sampleFlags = rec[14]
|
||
const igv = rec[171]
|
||
const fadeoutLo = rec[172]
|
||
const fadeoutHi = rec[173]
|
||
const fadeout = fadeoutLo | ((fadeoutHi & 0x0F) << 8)
|
||
const volSwing = rec[174]
|
||
const vibSpeed = rec[175]
|
||
const vibSweep = rec[176]
|
||
const defPan = rec[177]
|
||
const pitchPanCenter = rec[178] | (rec[179] << 8)
|
||
let pitchPanSep = rec[180]; if (pitchPanSep >= 128) pitchPanSep -= 256
|
||
const panSwing = rec[181]
|
||
const defCutoff = rec[182]
|
||
const defReso = rec[183]
|
||
let detune = rec[184] | (rec[185] << 8); if (detune >= 0x8000) detune -= 0x10000
|
||
const instFlag = rec[186]
|
||
const nna = instFlag & 3
|
||
const vibWaveform = (instFlag >>> 2) & 7
|
||
const vibDepth = rec[187]
|
||
const vibRate = rec[188]
|
||
const dcByte = rec[195]
|
||
const dct = dcByte & 3
|
||
const dca = (dcByte >>> 2) & 3
|
||
const defNoteVol = rec[196]
|
||
return {
|
||
samplePtr, sampleLen, c4Rate, playStart, sLoopStart, sLoopEnd, sampleFlags,
|
||
igv, fadeout, volSwing, vibSpeed, vibSweep, defPan,
|
||
pitchPanCenter, pitchPanSep, panSwing, defCutoff, defReso,
|
||
detune, nna, vibWaveform, vibDepth, vibRate, dct, dca, defNoteVol,
|
||
volEnv: decodeEnvelope(rec, 'vol'),
|
||
panEnv: decodeEnvelope(rec, 'pan'),
|
||
pfEnv: decodeEnvelope(rec, 'pf')
|
||
}
|
||
}
|
||
|
||
// Scan slots 1..255. Keep any slot that either has a non-empty sample length
|
||
// or a project-data INam entry. Returns a flat list — UI cursor walks this,
|
||
// not raw slot numbers — and a {slot → cacheIdx} reverse map for
|
||
// launchInstrumentViewerFor's jump-to-slot path.
|
||
let instrumentsCache = null
|
||
let instSlotToIdx = null
|
||
|
||
function buildInstrumentIndex() {
|
||
const list = []
|
||
const names = (songsMeta && songsMeta.instNames) || []
|
||
for (let i = 1; i < TAUT_INST_COUNT; i++) {
|
||
const rec = readInstRecord(i)
|
||
const sampleLen = rec[4] | (rec[5] << 8)
|
||
const nm = names[i] || ''
|
||
if (sampleLen === 0 && nm === '') continue
|
||
list.push({ slot: i, name: nm, decoded: decodeInstFull(rec) })
|
||
}
|
||
instSlotToIdx = {}
|
||
for (let i = 0; i < list.length; i++) instSlotToIdx[list[i].slot] = i
|
||
return list
|
||
}
|
||
|
||
function refreshInstrumentsCache() { instrumentsCache = buildInstrumentIndex() }
|
||
|
||
// ── Layout ─────────────────────────────────────────────────────────────────
|
||
const INST_LIST_X = 1
|
||
const INST_LIST_BODY_W = 26
|
||
const INST_LIST_W = INST_LIST_BODY_W + 1
|
||
const INST_LIST_SCROLL_X = INST_LIST_X + INST_LIST_BODY_W
|
||
const INST_LIST_Y = PTNVIEW_OFFSET_Y
|
||
const INST_LIST_H = PTNVIEW_HEIGHT
|
||
const INST_SEP_X = INST_LIST_X + INST_LIST_W
|
||
const INST_RIGHT_X = INST_SEP_X + 1
|
||
const INST_RIGHT_Y = PTNVIEW_OFFSET_Y
|
||
const INST_RIGHT_W = SCRW - INST_RIGHT_X + 1
|
||
const INST_BTN_Y = SCRH - 1
|
||
const INST_TAB_Y = INST_RIGHT_Y // tab strip row
|
||
const INST_BODY_Y = INST_RIGHT_Y + 2 // first content row
|
||
const INST_BODY_H = INST_BTN_Y - INST_BODY_Y // content rows (excludes button)
|
||
|
||
// General tab content does not fit in the 24-row body area of an 80x32 terminal,
|
||
// so it splits into two pages (sample/volume/panning on page 1;
|
||
// filter/vibrato/note-actions/tuning on page 2).
|
||
const INST_TAB_NAMES = ['Gen.1', 'Gen.2', 'Volume', 'Pan', 'Pitch']
|
||
const INST_TAB_GEN1 = 0, INST_TAB_GEN2 = 1, INST_TAB_VOL = 2, INST_TAB_PAN = 3, INST_TAB_PIT = 4
|
||
|
||
const colInstListBg = colBackPtn
|
||
const colInstListSel = colHighlight
|
||
const colInstListNumFg = colInst
|
||
const colInstListNameFg = colStatus
|
||
const colInstGroupHdr = colVoiceHdr
|
||
const colInstLabel = colStatus
|
||
const colInstValue = colWHITE
|
||
const colInstHighlight = colVol
|
||
const colInstEnvLine = 77 // bright cyan-ish, same as sample wave
|
||
const colInstEnvNode = 198 // pink-ish — node markers stand out from line
|
||
const colInstEnvAxis = 246 // dim grey for zero/center line
|
||
const colInstEnvHair = 251 // darker grey — quarter-point hairlines (dashed)
|
||
const colInstEnvLoop = 220 // muted yellow-orange — loop range band
|
||
const colInstEnvSust = 161 // muted red — sustain range band
|
||
|
||
let instListScroll = 0
|
||
let instListCursor = 0
|
||
let instSubTab = INST_TAB_GEN1
|
||
|
||
function clampInstrumentsCursor() {
|
||
const n = instrumentsCache ? instrumentsCache.length : 0
|
||
if (instListCursor < 0) instListCursor = 0
|
||
if (instListCursor >= n) instListCursor = Math.max(0, n - 1)
|
||
if (instListCursor < instListScroll) instListScroll = instListCursor
|
||
if (instListCursor >= instListScroll + INST_LIST_H)
|
||
instListScroll = instListCursor - INST_LIST_H + 1
|
||
if (instListScroll < 0) instListScroll = 0
|
||
}
|
||
|
||
function drawInstrumentsListColumn() {
|
||
const n = instrumentsCache ? instrumentsCache.length : 0
|
||
for (let row = 0; row < INST_LIST_H; row++) {
|
||
const idx = instListScroll + row
|
||
const y = INST_LIST_Y + row
|
||
con.move(y, INST_LIST_X)
|
||
if (idx >= n) {
|
||
con.color_pair(colInstListNameFg, colInstListBg)
|
||
print(' '.repeat(INST_LIST_BODY_W))
|
||
continue
|
||
}
|
||
const e = instrumentsCache[idx]
|
||
const isSel = (idx === instListCursor)
|
||
const back = isSel ? colInstListSel : colInstListBg
|
||
const numStr = e.slot.toString(16).toUpperCase().padStart(2, '0')
|
||
const nameRaw = (e.name && e.name.length) ? e.name : '(instrument $' + numStr + ')'
|
||
const nameW = INST_LIST_BODY_W - 6
|
||
const nameStr = (nameRaw.length > nameW ? nameRaw.substring(0, nameW) : nameRaw.padEnd(nameW))
|
||
con.color_pair(colInstListNumFg, back); print(' ' + numStr + ' ')
|
||
con.color_pair(colInstListNameFg, back); print(' ')
|
||
con.color_pair(isSel ? colWHITE : colInstListNameFg, back); print(nameStr)
|
||
con.color_pair(colInstListNameFg, back); print(' ')
|
||
}
|
||
// scroll indicator column
|
||
if (n > INST_LIST_H) {
|
||
const maxScroll = n - INST_LIST_H
|
||
const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0)
|
||
for (let r = 0; r < INST_LIST_H; r++) {
|
||
con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X)
|
||
con.color_pair(colStatus, colInstListBg)
|
||
print(r === indPos ? sym.ticked : sym.unticked)
|
||
}
|
||
} else {
|
||
for (let r = 0; r < INST_LIST_H; r++) {
|
||
con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X)
|
||
con.color_pair(colStatus, colInstListBg); print(' ')
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawInstrumentsSeparator() {
|
||
con.color_pair(colSep, colBackPtn)
|
||
for (let y = INST_LIST_Y; y < SCRH; y++) {
|
||
con.move(y, INST_SEP_X); con.prnch(VERT)
|
||
}
|
||
}
|
||
|
||
// Geometry helper for one tab chip in the right-pane tab strip. Tabs partition
|
||
// INST_RIGHT_W into 4 equal-width chips with a 1-col gap at each boundary; the
|
||
// click handler uses the same formula in reverse to map cx → tab index.
|
||
function instTabRect(tabIdx) {
|
||
const slotW = (INST_RIGHT_W / INST_TAB_NAMES.length) | 0
|
||
return { x: INST_RIGHT_X + tabIdx * slotW, y: INST_TAB_Y, w: slotW }
|
||
}
|
||
|
||
function drawInstrumentsTabStrip() {
|
||
// background row for the tab strip
|
||
con.move(INST_TAB_Y, INST_RIGHT_X)
|
||
con.color_pair(colTabBarOrn, colTabBarBack)
|
||
print(' '.repeat(INST_RIGHT_W))
|
||
for (let i = 0; i < INST_TAB_NAMES.length; i++) {
|
||
const r = instTabRect(i)
|
||
const active = (instSubTab === i)
|
||
const fg = active ? colTabActive : colTabInactive
|
||
const bg = active ? colTabBarBack2 : colTabBarBack
|
||
con.move(r.y, r.x)
|
||
con.color_pair(fg, bg)
|
||
const lbl = INST_TAB_NAMES[i]
|
||
const pad = Math.max(0, r.w - lbl.length)
|
||
const padL = pad >>> 1
|
||
const padR = pad - padL
|
||
print(' '.repeat(padL) + lbl + ' '.repeat(padR))
|
||
}
|
||
// 1-row gap under the tabs
|
||
con.move(INST_TAB_Y + 1, INST_RIGHT_X)
|
||
con.color_pair(colInstValue, colBackPtn)
|
||
print(' '.repeat(INST_RIGHT_W))
|
||
}
|
||
|
||
// Clear the right-pane body area (tab content rows + button row).
|
||
function clearInstrumentsBody() {
|
||
for (let r = 0; r < INST_BODY_H + 1; r++) {
|
||
con.move(INST_BODY_Y + r, INST_RIGHT_X)
|
||
con.color_pair(colInstValue, colBackPtn)
|
||
print(' '.repeat(INST_RIGHT_W))
|
||
}
|
||
}
|
||
|
||
// ── Text helpers ───────────────────────────────────────────────────────────
|
||
function _hex(n, w) { return n.toString(16).toUpperCase().padStart(w, '0') }
|
||
function _signed(n) { return (n >= 0 ? '+' : '') + n }
|
||
|
||
function loopModeNameInst(flags) {
|
||
const lp = flags & 3
|
||
const sus = (flags >>> 2) & 1
|
||
const names = ['none', 'forward', 'pingpong', 'oneshot']
|
||
return names[lp] + (sus ? ' (sustain)' : '')
|
||
}
|
||
const NNA_NAMES = ['Cut', 'Off', 'Continue', 'Fade']
|
||
const DCT_NAMES = ['off', 'note', 'sample', 'instrument']
|
||
const DCA_NAMES = ['Cut', 'Off', 'Fade', 'reserved']
|
||
const VIB_WF_NAMES = ['sine', 'ramp-dn', 'square', 'random', 'ramp-up', '?', '?', '?']
|
||
|
||
// Place a value at column INST_RIGHT_X + labelW. Labels are colour
|
||
// colInstLabel; values are colInstValue. Truncates to fit INST_RIGHT_W.
|
||
function drawLabelRow(y, label, value, labelW) {
|
||
if (labelW == null) labelW = 12
|
||
con.move(y, INST_RIGHT_X)
|
||
con.color_pair(colInstLabel, colBackPtn)
|
||
print((label + ' '.repeat(labelW)).substring(0, labelW))
|
||
con.color_pair(colInstValue, colBackPtn)
|
||
const maxV = INST_RIGHT_W - labelW
|
||
const v = (value == null) ? '' : String(value)
|
||
print(v.length > maxV ? v.substring(0, maxV) : v)
|
||
}
|
||
|
||
function drawGroupHeader(y, title) {
|
||
con.move(y, INST_RIGHT_X)
|
||
con.color_pair(colInstGroupHdr, colBackPtn)
|
||
const txt = title + ' '
|
||
const dashes = Math.max(0, INST_RIGHT_W - txt.length)
|
||
print(txt + `\u00FB`.repeat(dashes))
|
||
}
|
||
|
||
// ── Tab body: General (page 1 + page 2) ───────────────────────────────────
|
||
// Page 1 (Gen.1):
|
||
// Sample binding — sample link, length, c4Rate, play/loop positions, loop mode
|
||
// Volume — IGV, default note vol, fadeout, vol swing
|
||
// Panning — default pan + "use" flag, pitch-pan centre/separation, pan swing
|
||
// Page 2 (Gen.2):
|
||
// Filter — default cutoff/resonance
|
||
// Vibrato — waveform, speed, depth, sweep, rate
|
||
// Note actions — NNA, DCT/DCA
|
||
// Tuning — signed 4096-TET detune offset
|
||
//
|
||
// Two pages because the 80x32 terminal's 24-row body cannot hold every field at
|
||
// once; the user explicitly OK'd this split.
|
||
function drawInstTabGeneral1(e) {
|
||
const d = e.decoded
|
||
let y = INST_BODY_Y
|
||
const sampleNames = (songsMeta && songsMeta.sampleNames) || []
|
||
// Map decoded.samplePtr+len back to a sample-name slot (best-effort: same
|
||
// dedup convention as buildSampleIndex).
|
||
let sampleLabel = '(none)'
|
||
if (d.sampleLen > 0) {
|
||
// Walk samplesCache if it's been built; otherwise fall back to slot 0.
|
||
if (samplesCache === null) refreshSamplesCache()
|
||
let smpIdx = -1
|
||
for (let i = 0; i < samplesCache.length; i++) {
|
||
if (samplesCache[i].ptr === d.samplePtr && samplesCache[i].len === d.sampleLen) {
|
||
smpIdx = i; break
|
||
}
|
||
}
|
||
if (smpIdx >= 0) {
|
||
const sn = sampleNames[smpIdx + 1] || ''
|
||
sampleLabel = '$' + _hex(smpIdx + 1, 2) + (sn.length ? ' ' + sn : ' (unnamed)')
|
||
} else {
|
||
sampleLabel = '@$' + _hex(d.samplePtr, 6)
|
||
}
|
||
}
|
||
|
||
drawGroupHeader(y++, 'Sample binding')
|
||
drawLabelRow(y++, ' Sample:', sampleLabel)
|
||
drawLabelRow(y++, ' Length:', d.sampleLen + ' bytes ($' + _hex(d.sampleLen, 4) + ') Rate@C4: ' + d.c4Rate + ' Hz')
|
||
drawLabelRow(y++, ' Play st:', '$' + _hex(d.playStart, 4))
|
||
drawLabelRow(y++, ' Loop:', loopModeNameInst(d.sampleFlags) +
|
||
' [$' + _hex(d.sLoopStart, 4) + '..$' + _hex(d.sLoopEnd, 4) + ']')
|
||
|
||
y++
|
||
drawGroupHeader(y++, 'Volume')
|
||
drawLabelRow(y++, ' Inst. GV:', _hex(d.igv, 2) + ' (' + d.igv + '/255)')
|
||
drawLabelRow(y++, ' DefNote:', _hex(d.defNoteVol, 2) + ' (' + d.defNoteVol + '/255' +
|
||
(d.defNoteVol === 0 ? ' legacy: row default 63' : '') + ')')
|
||
let fadeStr
|
||
if (d.fadeout === 0) fadeStr = '0 (no fade)'
|
||
else if (d.fadeout >= 1024) fadeStr = d.fadeout + ' (1-tick cut)'
|
||
else {
|
||
const ticks = (1024 / d.fadeout)
|
||
fadeStr = d.fadeout + ' (~' + ticks.toFixed(1) + ' ticks)'
|
||
}
|
||
drawLabelRow(y++, ' Fadeout:', fadeStr)
|
||
drawLabelRow(y++, ' Swing:', _hex(d.volSwing, 2))
|
||
|
||
y++
|
||
drawGroupHeader(y++, 'Panning')
|
||
drawLabelRow(y++, ' Default:', _hex(d.defPan, 2) + ' Use: ' +
|
||
(d.panEnv.panUseDef ? sym.ticked + ' on' : sym.unticked + ' off'))
|
||
drawLabelRow(y++, ' PPanCtr:', '$' + _hex(d.pitchPanCenter, 4) + ' Sep: ' + _signed(d.pitchPanSep))
|
||
drawLabelRow(y++, ' Swing:', _hex(d.panSwing, 2))
|
||
}
|
||
|
||
function drawInstTabGeneral2(e) {
|
||
const d = e.decoded
|
||
let y = INST_BODY_Y
|
||
|
||
drawGroupHeader(y++, 'Filter')
|
||
drawLabelRow(y++, ' Cutoff:', (d.defCutoff === 0xFF ? 'off' : ('$' + _hex(d.defCutoff, 2) +
|
||
' (' + d.defCutoff + '/254)')))
|
||
drawLabelRow(y++, ' Reso:', (d.defReso === 0xFF ? 'off' : ('$' + _hex(d.defReso, 2) +
|
||
' (' + d.defReso + '/254)')))
|
||
|
||
y++
|
||
drawGroupHeader(y++, 'Vibrato')
|
||
drawLabelRow(y++, ' Waveform:', VIB_WF_NAMES[d.vibWaveform & 7])
|
||
drawLabelRow(y++, ' Speed:', _hex(d.vibSpeed, 2) + ' Depth: ' + _hex(d.vibDepth, 2))
|
||
drawLabelRow(y++, ' Sweep:', _hex(d.vibSweep, 2) + ' Rate: ' + _hex(d.vibRate, 2))
|
||
|
||
y++
|
||
drawGroupHeader(y++, 'Note actions')
|
||
drawLabelRow(y++, ' NNA:', NNA_NAMES[d.nna & 3])
|
||
drawLabelRow(y++, ' DCT:', DCT_NAMES[d.dct & 3])
|
||
drawLabelRow(y++, ' DCA:', DCA_NAMES[d.dca & 3])
|
||
|
||
y++
|
||
drawGroupHeader(y++, 'Tuning')
|
||
const detStr = _signed(d.detune) + ' (' + (d.detune / 0x1000).toFixed(3) + ' octave, 4096-TET)'
|
||
drawLabelRow(y++, ' Detune:', detStr)
|
||
}
|
||
|
||
// ── Envelope rendering (shared by Volume/Panning/Pitch tabs) ───────────────
|
||
|
||
// Pick a "nice" time-grid interval for `totalTime` (seconds). Aims for at most
|
||
// ~8 vertical hairlines, choosing from a fixed ladder so the number rendered
|
||
// next to "Total:" reads cleanly (no 0.157s grids). The smallest viable step
|
||
// covers fast envelopes (~50 ms); the top of the ladder covers the 15.75 s
|
||
// maximum the 3.5 minifloat can encode.
|
||
function pickEnvTimeGrid(totalTime) {
|
||
const ladder = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0]
|
||
for (let i = 0; i < ladder.length; i++) {
|
||
if (totalTime / ladder[i] <= 8) return ladder[i]
|
||
}
|
||
return ladder[ladder.length - 1]
|
||
}
|
||
|
||
// Pixel rect of the envelope-graph area for the given tab content row range.
|
||
// Width spans the full right pane; height is the lower half of the body area.
|
||
function instEnvelopeRect() {
|
||
const graphRowY = INST_BODY_Y + 7 // 7 rows of text above the graph
|
||
const x = (INST_RIGHT_X - 1) * CELL_PW
|
||
const y = (graphRowY - 1) * CELL_PH
|
||
const w = INST_RIGHT_W * CELL_PW
|
||
const h = (INST_BTN_Y - graphRowY) * CELL_PH
|
||
return { x, y, w, h, graphRowY }
|
||
}
|
||
|
||
// Clear graphics overlay over the right-pane envelope graph. Called by
|
||
// drawInstrumentsContents on every redraw and by switchToPanel when leaving
|
||
// the instrument viewer (mirrors clearSampleWaveformArea for the same reason).
|
||
function clearInstrumentsEnvelopeArea() {
|
||
const r = instEnvelopeRect()
|
||
graphics.plotRect(r.x-1, r.y-1, r.w+1, r.h+1, 255)
|
||
// Also clear the row of text that the graph overlays would otherwise visually
|
||
// smudge — the body redraw paints these rows blank anyway, but switchToPanel
|
||
// bypasses the body redraw on exit.
|
||
}
|
||
|
||
// Bresenham line via plotPixel. Used to connect envelope nodes.
|
||
function envPlotLine(x0, y0, x1, y1, col) {
|
||
let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1
|
||
let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1
|
||
let err = dx + dy
|
||
// Guard against pathological inputs; envelope coords are screen-bound.
|
||
let safety = 4096
|
||
while (safety-- > 0) {
|
||
graphics.plotPixel(x0, y0, col)
|
||
if (x0 === x1 && y0 === y1) break
|
||
const e2 = 2 * err
|
||
if (e2 >= dy) { err += dy; x0 += sx }
|
||
if (e2 <= dx) { err += dx; y0 += sy }
|
||
}
|
||
}
|
||
|
||
// Draw the envelope chart for one envelope (vol/pan/pf). Plots:
|
||
// • Quarter-point dashed hairlines as a faint reference grid.
|
||
// • Solid axis line (bottom for vol, mid for pan/pitch).
|
||
// • Loop / sustain wrap regions as faint vertical bands behind the curve.
|
||
// • Polyline through all active nodes; each node a 3×3 marker.
|
||
// Time axis: cumulative durSec across nodes, scaled to fit graph width.
|
||
// Value axis: 0 at bottom, env.valueMax at top.
|
||
function drawEnvelopeGraph(env) {
|
||
const r = instEnvelopeRect()
|
||
clearInstrumentsEnvelopeArea() // clear
|
||
|
||
// Dashed reference hairlines at quarter points of the value range. Drawn
|
||
// first so the solid axis line / loop bands / polyline can stack on top.
|
||
// For pan/pitch the 50% level is the main axis; we skip it here to keep
|
||
// the solid line visually distinct from the dashes.
|
||
const hairFracs = (env.kind === 'vol') ? [0.25, 0.5, 0.75] : [0.25, 0.75]
|
||
for (let fi = 0; fi < hairFracs.length; fi++) {
|
||
const yy = r.y + r.h - 1 - ((hairFracs[fi] * (r.h - 1)) | 0)
|
||
for (let xx = r.x; xx < r.x + r.w; xx += 6) {
|
||
graphics.plotRect(xx, yy, 2, 1, colInstEnvHair)
|
||
}
|
||
}
|
||
|
||
// Solid axis line — bottom of graph for vol, mid for pan/pitch.
|
||
if (env.kind !== 'vol') {
|
||
const midY = r.y + (r.h >>> 1)
|
||
graphics.plotRect(r.x, midY, r.w, 1, colInstEnvAxis)
|
||
} else {
|
||
graphics.plotRect(r.x, r.y + r.h - 1, r.w, 1, colInstEnvAxis)
|
||
}
|
||
|
||
// No envelope to draw when there are zero active nodes (shouldn't happen
|
||
// for well-formed records, but be defensive).
|
||
const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1)
|
||
if (lastIdx < 0) return
|
||
|
||
// Cumulative time of each node (node 0 is at t=0; node i is at sum of
|
||
// dur[0..i-1] for i>=1). The terminator's own dur is 0 so it lands at
|
||
// the sum of the preceding nodes.
|
||
const xs = new Array(lastIdx + 1)
|
||
let acc = 0
|
||
xs[0] = 0
|
||
for (let i = 1; i <= lastIdx; i++) {
|
||
acc += env.nodes[i - 1].durSec
|
||
xs[i] = acc
|
||
}
|
||
// When total time is 0 (single-node held envelope), give the x-axis a
|
||
// tiny non-zero span so node 0 still renders at the left edge.
|
||
const totalTime = Math.max(acc, 1e-6)
|
||
|
||
const valueMax = env.valueMax || 0xFF
|
||
const pxX = (t) => r.x + Math.min(r.w - 1, Math.max(0, ((t / totalTime) * (r.w - 1)) | 0))
|
||
const pxY = (v) => r.y + r.h - 1 - Math.min(r.h - 1, Math.max(0, ((v / valueMax) * (r.h - 1)) | 0))
|
||
|
||
// Vertical time-grid hairlines. Same dashed style as the value-axis
|
||
// hairlines (2 px on, 4 px off) but oriented vertically; spacing comes
|
||
// from pickEnvTimeGrid so we never spam more than ~8 lines across the
|
||
// graph regardless of envelope duration.
|
||
if (acc > 0) {
|
||
const grid = pickEnvTimeGrid(totalTime)
|
||
for (let t = grid; t < totalTime; t += grid) {
|
||
const xx = pxX(t)
|
||
for (let yy = r.y; yy < r.y + r.h; yy += 6) {
|
||
graphics.plotRect(xx, yy, 1, 2, colInstEnvHair)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Loop / sustain bands behind the polyline.
|
||
if (env.loopEnable && env.loopStart <= lastIdx && env.loopEnd <= lastIdx) {
|
||
const x0 = pxX(xs[env.loopStart])
|
||
const x1 = pxX(xs[env.loopEnd])
|
||
const bw = Math.max(1, x1 - x0)
|
||
graphics.plotRect(x0, r.y, bw, r.h, colInstEnvLoop, 2)
|
||
}
|
||
if (env.sustEnable && env.sustStart <= lastIdx && env.sustEnd <= lastIdx) {
|
||
const x0 = pxX(xs[env.sustStart])
|
||
const x1 = pxX(xs[env.sustEnd])
|
||
const bw = Math.max(1, x1 - x0)
|
||
graphics.plotRect(x0, r.y, bw, r.h, colInstEnvSust, 2)
|
||
}
|
||
|
||
// Polyline through the envelope.
|
||
for (let i = 0; i < lastIdx; i++) {
|
||
envPlotLine(pxX(xs[i]), pxY(env.nodes[i].value),
|
||
pxX(xs[i + 1]), pxY(env.nodes[i + 1].value), colInstEnvLine)
|
||
}
|
||
// Node markers (3×3 squares centred on the node coordinate).
|
||
for (let i = 0; i <= lastIdx; i++) {
|
||
const cx = pxX(xs[i]), cy = pxY(env.nodes[i].value)
|
||
graphics.plotRect(cx - 1, cy - 1, 3, 3, colInstEnvNode)
|
||
}
|
||
}
|
||
|
||
// Common envelope-tab body: a few lines of summary text above the graph.
|
||
// `extra` is an array of additional [label, value] rows specific to this kind
|
||
// (e.g. pan's "Use default pan" flag).
|
||
function drawInstTabEnvelope(e, env, kindLabel, extra) {
|
||
let y = INST_BODY_Y
|
||
drawGroupHeader(y++, kindLabel + ' envelope')
|
||
drawLabelRow(y++, ' Present:', (env.present ? sym.ticked + ' yes (P=1)' : sym.unticked + ' no (P=0)'))
|
||
const realCount = (env.terminatorIdx >= 0) ? (env.terminatorIdx + 1) : env.nodes.length
|
||
drawLabelRow(y++, ' Nodes:', realCount + ' / 25' +
|
||
(env.carry ? ' Carry: ' + sym.ticked : ' Carry: ' + sym.unticked))
|
||
drawLabelRow(y++, ' Loop:', env.loopEnable
|
||
? (sym.ticked + ' [' + env.loopStart + '..' + env.loopEnd + ']')
|
||
: (sym.unticked + ' off'))
|
||
drawLabelRow(y++, ' Sustain:', env.sustEnable
|
||
? (sym.ticked + ' [' + env.sustStart + '..' + env.sustEnd + ']')
|
||
: (sym.unticked + ' off'))
|
||
if (extra) {
|
||
for (let i = 0; i < extra.length; i++) {
|
||
drawLabelRow(y++, ' ' + extra[i][0], extra[i][1])
|
||
}
|
||
}
|
||
// Total envelope length + the time-grid step the graph below uses, so the
|
||
// dashed vertical hairlines have a readable scale.
|
||
const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1)
|
||
let totalSec = 0
|
||
for (let i = 0; i < lastIdx; i++) totalSec += env.nodes[i].durSec
|
||
const gridStep = pickEnvTimeGrid(Math.max(totalSec, 1e-6))
|
||
drawLabelRow(y++, ' Length:', totalSec.toFixed(3) + ' s (grid ' + gridStep + ' s)')
|
||
|
||
drawEnvelopeGraph(env)
|
||
}
|
||
|
||
function drawInstTabVolume(e) { drawInstTabEnvelope(e, e.decoded.volEnv, 'Volume') }
|
||
function drawInstTabPanning(e) {
|
||
drawInstTabEnvelope(e, e.decoded.panEnv, 'Panning', [
|
||
['UseDef:', (e.decoded.panEnv.panUseDef ? sym.ticked + ' on' : sym.unticked + ' off') +
|
||
' (chan-pan source: byte $B1)']
|
||
])
|
||
}
|
||
function drawInstTabPitch(e) {
|
||
const env = e.decoded.pfEnv
|
||
drawInstTabEnvelope(e, env, env.pfFilter ? 'Filter' : 'Pitch', [
|
||
['Mode:', env.pfFilter ? 'filter cutoff' : 'pitch']
|
||
])
|
||
}
|
||
|
||
// ── Edit button (bottom row) ───────────────────────────────────────────────
|
||
function drawInstrumentsEditButton() {
|
||
const y = INST_BTN_Y
|
||
con.move(y, INST_RIGHT_X)
|
||
con.color_pair(colInstGroupHdr, colBackPtn); print('[ E ]')
|
||
con.color_pair(colInstValue, colBackPtn)
|
||
const label = ' Edit instrument'
|
||
print(label)
|
||
const rest = INST_RIGHT_W - (5 + label.length)
|
||
if (rest > 0) print(' '.repeat(rest))
|
||
}
|
||
|
||
function clearInstrumentsPanel() {
|
||
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colInstValue, colBackPtn)
|
||
}
|
||
|
||
function drawInstrumentsContents(wo) {
|
||
if (instrumentsCache === null) refreshInstrumentsCache()
|
||
clampInstrumentsCursor()
|
||
clearInstrumentsPanel()
|
||
drawInstrumentsListColumn()
|
||
drawInstrumentsSeparator()
|
||
drawInstrumentsTabStrip()
|
||
|
||
const n = instrumentsCache ? instrumentsCache.length : 0
|
||
if (n === 0) {
|
||
con.move(INST_BODY_Y, INST_RIGHT_X)
|
||
con.color_pair(colInstGroupHdr, colBackPtn)
|
||
print('No instruments in this project.')
|
||
// wipe any old envelope graph
|
||
clearInstrumentsEnvelopeArea()
|
||
drawInstrumentsEditButton()
|
||
return
|
||
}
|
||
const e = instrumentsCache[instListCursor]
|
||
// Body redraw wipes its rows before re-rendering, so don't paint the graph
|
||
// until after the text tabs are drawn — otherwise plotRect-555 fill at the
|
||
// end of the body redraw would erase the graph again.
|
||
clearInstrumentsEnvelopeArea()
|
||
if (instSubTab === INST_TAB_GEN1) drawInstTabGeneral1(e)
|
||
else if (instSubTab === INST_TAB_GEN2) drawInstTabGeneral2(e)
|
||
else if (instSubTab === INST_TAB_VOL) drawInstTabVolume(e)
|
||
else if (instSubTab === INST_TAB_PAN) drawInstTabPanning(e)
|
||
else drawInstTabPitch(e)
|
||
drawInstrumentsEditButton()
|
||
}
|
||
|
||
function instrumentsInput(wo, event) {
|
||
if (event[0] !== 'key_down') return
|
||
const keysym = event[1]
|
||
const keyJustHit = (1 == event[2])
|
||
const shiftDown = (event.includes(59) || event.includes(60))
|
||
const moveDelta = shiftDown ? 8 : 1
|
||
|
||
const n = instrumentsCache ? instrumentsCache.length : 0
|
||
if (n === 0) {
|
||
if (keysym === 'e' || keysym === 'E') {
|
||
requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, -1])
|
||
}
|
||
return
|
||
}
|
||
if (keysym === '<UP>') { instListCursor -= moveDelta; clampInstrumentsCursor(); drawInstrumentsContents(); return }
|
||
if (keysym === '<DOWN>') { instListCursor += moveDelta; clampInstrumentsCursor(); drawInstrumentsContents(); return }
|
||
if (keysym === '<PAGE_UP>') { instListCursor -= INST_LIST_H; clampInstrumentsCursor(); drawInstrumentsContents(); return }
|
||
if (keysym === '<PAGE_DOWN>') { instListCursor += INST_LIST_H; clampInstrumentsCursor(); drawInstrumentsContents(); return }
|
||
if (keysym === '<HOME>') { instListCursor = 0; clampInstrumentsCursor(); drawInstrumentsContents(); return }
|
||
if (keysym === '<END>') { instListCursor = n - 1; clampInstrumentsCursor(); drawInstrumentsContents(); return }
|
||
// Tab cycling. <LEFT>/<RIGHT> walk subtab, mirroring the IT mouse-tab feel.
|
||
if (keysym === '<LEFT>') { instSubTab = (instSubTab + INST_TAB_NAMES.length - 1) % INST_TAB_NAMES.length; drawInstrumentsContents(); return }
|
||
if (keysym === '<RIGHT>') { instSubTab = (instSubTab + 1) % INST_TAB_NAMES.length; drawInstrumentsContents(); return }
|
||
// Number keys 1..5 jump directly to a tab. Convenient when arrow keys are taken.
|
||
if (keysym === '1') { instSubTab = INST_TAB_GEN1; drawInstrumentsContents(); return }
|
||
if (keysym === '2') { instSubTab = INST_TAB_GEN2; drawInstrumentsContents(); return }
|
||
if (keysym === '3') { instSubTab = INST_TAB_VOL; drawInstrumentsContents(); return }
|
||
if (keysym === '4') { instSubTab = INST_TAB_PAN; drawInstrumentsContents(); return }
|
||
if (keysym === '5') { instSubTab = INST_TAB_PIT; drawInstrumentsContents(); return }
|
||
if (keysym === 'e' || keysym === 'E') {
|
||
const e = instrumentsCache[instListCursor]
|
||
if (e) requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, e.slot])
|
||
return
|
||
}
|
||
}
|
||
|
||
function registerInstrumentsMouse() {
|
||
// Left list
|
||
addPanelMouseRegion(INST_LIST_X, INST_LIST_Y, INST_SEP_X - INST_LIST_X, INST_LIST_H, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1) return
|
||
const n = instrumentsCache ? instrumentsCache.length : 0
|
||
const target = instListScroll + (cy - INST_LIST_Y)
|
||
if (target < 0 || target >= n) return
|
||
instListCursor = target
|
||
clampInstrumentsCursor()
|
||
drawInstrumentsContents()
|
||
},
|
||
onWheel: (cy, cx, dy) => {
|
||
instListCursor += dy * 3
|
||
clampInstrumentsCursor()
|
||
drawInstrumentsContents()
|
||
}
|
||
})
|
||
// Right-pane tab strip: clicking a chip selects that tab.
|
||
for (let i = 0; i < INST_TAB_NAMES.length; i++) {
|
||
const idx = i
|
||
const r = instTabRect(i)
|
||
addPanelMouseRegion(r.x, r.y, r.w, 1, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1) return
|
||
instSubTab = idx
|
||
drawInstrumentsContents()
|
||
}
|
||
})
|
||
}
|
||
// Edit button
|
||
addPanelMouseRegion(INST_RIGHT_X, INST_BTN_Y, 22, 1, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1) return
|
||
const n = instrumentsCache ? instrumentsCache.length : 0
|
||
const slot = (n > 0) ? instrumentsCache[instListCursor].slot : -1
|
||
requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, slot])
|
||
}
|
||
})
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// END INSTRUMENTS VIEWER
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
const panelSamples = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, samplesInput, drawSamplesContents, undefined, ()=>{})
|
||
const panelInstrmnt = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, instrumentsInput, drawInstrumentsContents, undefined, ()=>{})
|
||
const panelProject = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, projectInput, drawProjectContents, undefined, ()=>{})
|
||
const panelFile = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_fileop'), undefined, ()=>{})
|
||
|
||
const panels = [panelTimeline, panelOrders, panelPatterns, panelSamples, panelInstrmnt, panelProject, panelFile]
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// PLAYBACK STATE
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
const PLAYHEAD = 0
|
||
|
||
// Scratch cue slot used for pattern-only preview; beyond any real cue the song uses
|
||
const PREVIEW_CUE_IDX = NUM_CUES - 1
|
||
|
||
let playbackMode = PLAYMODE_NONE
|
||
let playStartCue = 0
|
||
let playStartRow = 0
|
||
let pbCue = 0
|
||
let pbRow = 0
|
||
let previewActive = false // true while a pattern-only preview is loaded in PREVIEW_CUE_IDX
|
||
|
||
// Encode a cue object (from song.cues[]) back to its 32-byte wire format
|
||
function encodeCue(cue) {
|
||
const bin = new Uint8Array(CUE_SIZE)
|
||
for (let i = 0; i < 10; i++) {
|
||
const p0 = cue.ptns[i*2], p1 = cue.ptns[i*2+1]
|
||
bin[i] = ((p0 & 0xF) << 4) | (p1 & 0xF)
|
||
bin[10+i] = (((p0 >> 4) & 0xF) << 4) | ((p1 >> 4) & 0xF)
|
||
bin[20+i] = (((p0 >> 8) & 0xF) << 4) | ((p1 >> 8) & 0xF)
|
||
}
|
||
bin[30] = cue.instr || 0
|
||
return bin
|
||
}
|
||
|
||
// Build a preview cue with voice 0 = pidx, all other voices = CUE_EMPTY
|
||
function buildPreviewCue(pidx) {
|
||
const bin = new Uint8Array(CUE_SIZE)
|
||
for (let b = 0; b < 30; b++) bin[b] = 0xFF
|
||
bin[0] = ((pidx & 0xF) << 4) | 0xF
|
||
bin[10] = (((pidx >> 4) & 0xF) << 4) | 0xF
|
||
bin[20] = (((pidx >> 8) & 0xF) << 4) | 0xF
|
||
return bin
|
||
}
|
||
|
||
// Restore the scratch cue slot and original BPM/tickRate before full-song playback
|
||
function restoreFullSongParams() {
|
||
if (!previewActive) return
|
||
audio.uploadCue(PREVIEW_CUE_IDX, encodeCue(song.cues[PREVIEW_CUE_IDX]))
|
||
audio.setBPM(PLAYHEAD, song.bpm)
|
||
audio.setTickRate(PLAYHEAD, song.tickRate)
|
||
previewActive = false
|
||
}
|
||
|
||
// Push in-memory song.patterns to the audio adapter if local edits haven't been
|
||
// uploaded yet. Called by every start-play entry point so playback always reflects
|
||
// the current editor state (e.g. after Retune).
|
||
function reuploadPatternsIfNeeded() {
|
||
if (!patternsOutOfSync) return
|
||
const patBytes = new Array(PATTERN_SIZE)
|
||
for (let p = 0; p < song.numPats; p++) {
|
||
const ptn = song.patterns[p]
|
||
for (let k = 0; k < PATTERN_SIZE; k++) patBytes[k] = ptn[k] & 0xFF
|
||
audio.uploadPattern(p, patBytes)
|
||
}
|
||
patternsOutOfSync = false
|
||
}
|
||
|
||
// Adjust the live tick rate by `delta`. The engine still honours 'A' (set speed) effects,
|
||
// which will overwrite this value when their row is hit during playback.
|
||
function nudgeTickRate(delta) {
|
||
const cur = audio.getTickRate(PLAYHEAD) | 0
|
||
const next = Math.max(1, Math.min(255, cur + delta))
|
||
if (next === cur) return
|
||
audio.setTickRate(PLAYHEAD, next)
|
||
drawAlwaysOnElems()
|
||
}
|
||
|
||
function startPlaySong() {
|
||
restoreFullSongParams()
|
||
reuploadPatternsIfNeeded()
|
||
audio.stop(PLAYHEAD)
|
||
audio.setCuePosition(PLAYHEAD, cueIdx)
|
||
audio.setTrackerRow(PLAYHEAD, 0)
|
||
cursorRow = 0
|
||
clampCursor()
|
||
pbCue = cueIdx
|
||
pbRow = 0
|
||
playbackMode = PLAYMODE_SONG
|
||
audio.play(PLAYHEAD)
|
||
}
|
||
|
||
function startPlayCue() {
|
||
restoreFullSongParams()
|
||
reuploadPatternsIfNeeded()
|
||
audio.stop(PLAYHEAD)
|
||
audio.setCuePosition(PLAYHEAD, cueIdx)
|
||
audio.setTrackerRow(PLAYHEAD, 0)
|
||
playStartCue = cueIdx
|
||
cursorRow = 0
|
||
clampCursor()
|
||
pbCue = cueIdx
|
||
pbRow = 0
|
||
playbackMode = PLAYMODE_CUE
|
||
audio.play(PLAYHEAD)
|
||
}
|
||
|
||
function startPlayRow(fromRow, fromCue) {
|
||
restoreFullSongParams()
|
||
reuploadPatternsIfNeeded()
|
||
if (fromRow === undefined) fromRow = cursorRow
|
||
if (fromCue === undefined) fromCue = cueIdx
|
||
audio.stop(PLAYHEAD)
|
||
audio.setCuePosition(PLAYHEAD, fromCue)
|
||
audio.setTrackerRow(PLAYHEAD, fromRow)
|
||
playStartCue = fromCue
|
||
playStartRow = fromRow
|
||
pbCue = fromCue
|
||
pbRow = fromRow
|
||
playbackMode = PLAYMODE_ROW
|
||
audio.play(PLAYHEAD)
|
||
}
|
||
|
||
function startPlayPattern() {
|
||
if (song.numPats === 0) return
|
||
reuploadPatternsIfNeeded()
|
||
audio.stop(PLAYHEAD)
|
||
audio.setBPM(PLAYHEAD, song.bpm)
|
||
audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx))
|
||
audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX)
|
||
audio.setTrackerRow(PLAYHEAD, 0)
|
||
playStartCue = PREVIEW_CUE_IDX
|
||
pbCue = PREVIEW_CUE_IDX
|
||
pbRow = 0
|
||
playbackMode = PLAYMODE_CUE
|
||
previewActive = true
|
||
audio.play(PLAYHEAD)
|
||
}
|
||
|
||
function startPlayPatternRow() {
|
||
if (song.numPats === 0) return
|
||
reuploadPatternsIfNeeded()
|
||
audio.stop(PLAYHEAD)
|
||
audio.setBPM(PLAYHEAD, song.bpm)
|
||
audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx))
|
||
audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX)
|
||
audio.setTrackerRow(PLAYHEAD, patternGridRow)
|
||
playStartCue = PREVIEW_CUE_IDX
|
||
playStartRow = patternGridRow
|
||
pbCue = PREVIEW_CUE_IDX
|
||
pbRow = patternGridRow
|
||
playbackMode = PLAYMODE_ROW
|
||
previewActive = true
|
||
audio.play(PLAYHEAD)
|
||
}
|
||
|
||
function stopPlayback() {
|
||
audio.stop(PLAYHEAD)
|
||
playbackMode = PLAYMODE_NONE
|
||
clampPatternGrid()
|
||
clearVoiceMeters()
|
||
}
|
||
|
||
function updatePlayback() {
|
||
if (!audio.isPlaying(PLAYHEAD)) {
|
||
playbackMode = PLAYMODE_NONE
|
||
clampPatternGrid()
|
||
if (currentPanel === VIEW_TIMELINE &&
|
||
cursorRow >= scrollRow && cursorRow < scrollRow + PTNVIEW_HEIGHT)
|
||
drawPatternRowAt(cursorRow - scrollRow)
|
||
else if (currentPanel === VIEW_PATTERN_DETAILS && song.numPats > 0) { simStateKey = ''; redrawPanel() }
|
||
drawAlwaysOnElems()
|
||
clearVoiceMeters()
|
||
return
|
||
}
|
||
|
||
drawVoiceMeters()
|
||
|
||
const nowCue = audio.getCuePosition(PLAYHEAD)
|
||
const nowRow = audio.getTrackerRow(PLAYHEAD)
|
||
|
||
if (playbackMode === PLAYMODE_CUE && nowCue !== playStartCue) {
|
||
stopPlayback()
|
||
if (currentPanel === VIEW_TIMELINE) redrawPanel()
|
||
else if (currentPanel === VIEW_PATTERN_DETAILS && song.numPats > 0) { simStateKey = ''; redrawPanel() }
|
||
drawAlwaysOnElems()
|
||
return
|
||
}
|
||
if (playbackMode === PLAYMODE_ROW && (nowRow !== playStartRow || nowCue !== playStartCue)) {
|
||
stopPlayback()
|
||
if (currentPanel === VIEW_TIMELINE &&
|
||
cursorRow >= scrollRow && cursorRow < scrollRow + PTNVIEW_HEIGHT)
|
||
drawPatternRowAt(cursorRow - scrollRow)
|
||
else if (currentPanel === VIEW_PATTERN_DETAILS && song.numPats > 0) { simStateKey = ''; redrawPanel() }
|
||
drawAlwaysOnElems()
|
||
return
|
||
}
|
||
|
||
if (nowCue === pbCue && nowRow === pbRow) return
|
||
|
||
pbCue = nowCue
|
||
pbRow = nowRow
|
||
|
||
if (!previewActive && nowCue !== cueIdx) {
|
||
cueIdx = nowCue
|
||
cursorRow = nowRow
|
||
clampCursor()
|
||
if (currentPanel === VIEW_TIMELINE) redrawPanel()
|
||
else if (currentPanel === VIEW_PATTERN_DETAILS && song.numPats > 0) { simStateKey = ''; redrawPanel() }
|
||
else if (currentPanel === VIEW_CUES) {
|
||
scrollOrdersTo(cueIdx)
|
||
drawOrdersContents()
|
||
}
|
||
} else if (previewActive || nowCue === cueIdx) {
|
||
const oldCursor = cursorRow
|
||
const oldScroll = scrollRow
|
||
cursorRow = nowRow
|
||
clampCursor()
|
||
if (currentPanel === VIEW_TIMELINE) {
|
||
const dScroll = scrollRow - oldScroll
|
||
if (dScroll === 0) {
|
||
drawPatternRowAt(oldCursor - scrollRow)
|
||
drawPatternRowAt(cursorRow - scrollRow)
|
||
} else if (Math.abs(dScroll) >= PTNVIEW_HEIGHT) {
|
||
drawPatternView()
|
||
} else {
|
||
shiftPatternArea(-dScroll)
|
||
if (dScroll > 0) {
|
||
for (let i = 0; i < dScroll; i++) drawPatternRowAt(PTNVIEW_HEIGHT - 1 - i)
|
||
} else {
|
||
for (let i = 0; i < -dScroll; i++) drawPatternRowAt(i)
|
||
}
|
||
if (oldCursor >= scrollRow && oldCursor < scrollRow + PTNVIEW_HEIGHT)
|
||
drawPatternRowAt(oldCursor - scrollRow)
|
||
drawPatternRowAt(cursorRow - scrollRow)
|
||
}
|
||
drawSeparators(separatorStyle)
|
||
drawVoiceDetail()
|
||
} else if (currentPanel === VIEW_PATTERN_DETAILS && song.numPats > 0) {
|
||
simStateKey = ''
|
||
const activeRow = getActiveRowForDetail()
|
||
simState = simulateRowState(song.patterns[patternIdx], activeRow)
|
||
simStateKey = `${patternIdx}:${activeRow}:${playbackMode}`
|
||
scrollPatternGridTo(pbRow)
|
||
drawPatternGrid()
|
||
drawVoiceDetail(true, song.patterns[patternIdx], activeRow, simState)
|
||
}
|
||
drawAlwaysOnElems()
|
||
}
|
||
}
|
||
|
||
function clampCursor() {
|
||
if (cursorRow < 0) cursorRow = 0
|
||
if (cursorRow >= ROWS_PER_PAT) cursorRow = ROWS_PER_PAT - 1
|
||
scrollRow = centerScroll(cursorRow, scrollRow, PTNVIEW_HEIGHT, ROWS_PER_PAT)
|
||
}
|
||
|
||
function clampVoice() {
|
||
if (cursorVox < 0) cursorVox = 0
|
||
if (cursorVox >= song.numVoices) cursorVox = song.numVoices - 1
|
||
if (cursorVox < voiceOff) voiceOff = cursorVox
|
||
// keep cursor centred until view reaches an edge (mirrors clampCursor logic)
|
||
if (cursorVox < voiceOff + (VOCSIZE_TIMELINE_FULL>>>1) && voiceOff > 0) voiceOff = cursorVox - (VOCSIZE_TIMELINE_FULL>>>1)
|
||
if (cursorVox >= voiceOff + ((VOCSIZE_TIMELINE_FULL+1)>>>1)) voiceOff = cursorVox - ((VOCSIZE_TIMELINE_FULL+1)>>>1) + 1
|
||
const maxOff = Math.max(0, song.numVoices - VOCSIZE_TIMELINE_FULL)
|
||
if (voiceOff < 0) voiceOff = 0
|
||
if (voiceOff > maxOff) voiceOff = maxOff
|
||
}
|
||
|
||
function clampCue() {
|
||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||
if (cueIdx < 0) cueIdx = 0
|
||
if (cueIdx > maxCue) cueIdx = maxCue
|
||
}
|
||
|
||
function clampOrdersHoriz() {
|
||
if (ordersColCursor < 0) ordersColCursor = 0
|
||
if (ordersColCursor > song.numVoices) ordersColCursor = song.numVoices
|
||
if (ordersColCursor >= 1) {
|
||
const v = ordersColCursor - 1
|
||
if (v < ordersVoiceOff) ordersVoiceOff = v
|
||
if (v >= ordersVoiceOff + VOCSIZE_ORDERS) ordersVoiceOff = v - VOCSIZE_ORDERS + 1
|
||
if (ordersVoiceOff < 0) ordersVoiceOff = 0
|
||
}
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// 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
|
||
|
||
win.showDialog({
|
||
title: `Help: ${PANEL_NAMES[currentPanel]}`,
|
||
drawFrame: popupDrawFrame,
|
||
colours: popupColours,
|
||
list: {
|
||
items: lines.map(l => ({ label: l })),
|
||
bg: colPopupBack,
|
||
height: HELP_CONTENT_H,
|
||
width: HELP_CONTENT_W+4,
|
||
selectable: () => false,
|
||
renderItem: (ctx) => {
|
||
con.color_pair(colText, ctx.listBg)
|
||
con.move(ctx.y, ctx.x)
|
||
const line = (ctx.item.label != null ? ctx.item.label : '')
|
||
print(line.padEnd(ctx.w, ' ').substring(0, ctx.w))
|
||
},
|
||
},
|
||
buttons: [{ label: 'OK', action: 'ok', default: true }],
|
||
onKey: (ks, _shift, ctx) => {
|
||
if (ks === '!' || ks === 'q') { ctx.close({ action: 'cancel' }); return true }
|
||
return false
|
||
},
|
||
})
|
||
drawAll()
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// SHARED POPUP CHROME
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
// Custom window-frame painter passed to wintex showDialog as `drawFrame`.
|
||
// Paints a title bar at the top row, then fills the rest of the popup with
|
||
// `colPopupBack` (including the bottom row, so the spacing row below wintex's
|
||
// button strip stays painted).
|
||
const popupDrawFrame = (wo) => {
|
||
// draw header
|
||
con.move(wo.y, wo.x)
|
||
con.color_pair(colTabBarOrn, colTabBarBack)
|
||
print(`\u00FB`.repeat(wo.width))
|
||
|
||
// imprint title
|
||
let titleWidth = wo.title.length
|
||
con.move(wo.y, wo.x + (((wo.width - titleWidth - 2) & 254) >>> 1))
|
||
con.color_pair(colTabInactive, colTabBarBack); print(` ${wo.title} `)
|
||
|
||
// fill content area (title row already painted above)
|
||
for (let r = 1; r < wo.height; r++) {
|
||
con.move(wo.y + r, wo.x)
|
||
con.color_pair(230, colPopupBack)
|
||
print(' '.repeat(wo.width))
|
||
}
|
||
}
|
||
|
||
// Standard colour palette shared by every taut popup so wintex's defaults blend
|
||
// with taut's popup chrome.
|
||
const popupColours = {
|
||
// fg: colStatus,
|
||
// bg: colPopupBack,
|
||
// fieldBg: 240,
|
||
// dimFg: colVoiceHdrMuted,
|
||
// hlFg: colWHITE,
|
||
// focusBg: colHighlight,
|
||
// listBg: colPopupBack,
|
||
// listSelBg: colHighlight,
|
||
}
|
||
|
||
function applyGoto(num) {
|
||
if (currentPanel === VIEW_TIMELINE) {
|
||
cueIdx = num; clampCue()
|
||
} else if (currentPanel === VIEW_CUES) {
|
||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||
ordersCursor = Math.max(0, Math.min(maxCue, num))
|
||
if (ordersCursor < ordersScroll) ordersScroll = ordersCursor
|
||
if (ordersCursor >= ordersScroll + PTNVIEW_HEIGHT)
|
||
ordersScroll = Math.max(0, ordersCursor - PTNVIEW_HEIGHT + 1)
|
||
} else if (currentPanel === VIEW_PATTERN_DETAILS) {
|
||
patternIdx = num; clampPatternIdx()
|
||
}
|
||
}
|
||
|
||
function openConfirmQuit() {
|
||
const messageLines = ['Exit Microtone?']
|
||
if (hasUnsavedChanges) messageLines.push('You have unsaved changes.')
|
||
|
||
const res = win.showDialog({
|
||
title: 'Quit?',
|
||
drawFrame: popupDrawFrame,
|
||
colours: popupColours,
|
||
message: messageLines,
|
||
buttons: [
|
||
{ label: 'Yes', action: 'yes', default: true },
|
||
{ label: 'No', action: 'no' },
|
||
],
|
||
onKey: (ks, _shift, ctx) => {
|
||
if (ks === 'y' || ks === 'Y') { ctx.close({ action: 'yes' }); return true }
|
||
if (ks === 'n' || ks === 'N') { ctx.close({ action: 'no' }); return true }
|
||
return false
|
||
},
|
||
})
|
||
|
||
const result = (res.action === 'yes')
|
||
if (!result) drawAll()
|
||
return result
|
||
}
|
||
|
||
function openGotoPopup() {
|
||
const prompts = ['Cue (hex):', 'Cue (hex):', 'Pattern (hex):']
|
||
const promptStr = prompts[currentPanel] || 'Number:'
|
||
|
||
const res = win.showDialog({
|
||
title: 'Go To',
|
||
drawFrame: popupDrawFrame,
|
||
colours: popupColours,
|
||
fields: [{ label: promptStr, width: 3, maxLength: 3 }],
|
||
buttons: [
|
||
{ label: 'OK', action: 'ok', default: true },
|
||
{ label: 'Cancel', action: 'cancel' },
|
||
],
|
||
})
|
||
if (res.action === 'ok' && res.values[0]) {
|
||
const n = parseInt(res.values[0], 16)
|
||
if (!isNaN(n)) applyGoto(n)
|
||
}
|
||
drawAll()
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// RETUNE POPUP
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
function openRetunePopup() {
|
||
const entries = Object.values(pitchTablePresets).sort((a, b) => a.index - b.index)
|
||
const n = entries.length
|
||
|
||
// Foreground colour by tuning type (preset.t):
|
||
// 'd' = 12-tone family, 'M' = Macrotonal, 'm' = microtonal, '' = Raw.
|
||
const tuningTypeColour = { d: 230, M: colPan, m: colInst, '': colStatus }
|
||
|
||
const methodLabels = {
|
||
pitch: 'Nearest-note',
|
||
delta: 'Nearest-delta',
|
||
cadence: 'Nearest-cadence',
|
||
harmonic: 'Nearest-harmonic', // this thing is cadence-aware (hopefully)
|
||
}
|
||
const methodCycle = ['pitch', 'harmonic', 'delta'/*, 'cadence'*/]
|
||
let method = 'pitch'
|
||
|
||
let selIdx = entries.findIndex(p => p.index === PITCH_PRESET_IDX)
|
||
if (selIdx < 0) selIdx = 0
|
||
|
||
const items = entries.map(e => ({ label: e.name, preset: e }))
|
||
const listH = Math.min(n, 13)
|
||
const messageLines = [
|
||
'Select new tuning preset:',
|
||
'Method: ' + methodLabels[method],
|
||
]
|
||
|
||
const res = win.showDialog({
|
||
title: 'Retune',
|
||
drawFrame: popupDrawFrame,
|
||
colours: popupColours,
|
||
message: messageLines,
|
||
list: {
|
||
items: items,
|
||
height: listH,
|
||
width: 36,
|
||
cursor: selIdx,
|
||
renderItem: (ctx) => {
|
||
const e = ctx.item.preset
|
||
const isCur = (e.index === PITCH_PRESET_IDX)
|
||
const fore = (e.t in tuningTypeColour) ? tuningTypeColour[e.t] : 230
|
||
const useFg = (ctx.isCursor && ctx.focused) ? colWHITE : fore
|
||
const useBg = (ctx.isCursor && ctx.focused) ? colHighlight : ctx.listBg
|
||
con.color_pair(useFg, useBg)
|
||
con.move(ctx.y, ctx.x)
|
||
const marker = isCur ? sym.playhead : ' '
|
||
let label = `${marker} ${e.name}`
|
||
if (label.length > ctx.w) label = label.substring(0, ctx.w)
|
||
else label = label.padEnd(ctx.w, ' ')
|
||
print(label)
|
||
},
|
||
},
|
||
buttons: [
|
||
{ label: 'OK', action: 'ok', default: true },
|
||
{ label: 'Cancel', action: 'cancel' },
|
||
],
|
||
onKey: (ks, _shift, ctx) => {
|
||
if (ks === 'm' || ks === 'M') {
|
||
method = methodCycle[(methodCycle.indexOf(method) + 1) % methodCycle.length]
|
||
messageLines[1] = 'Method: ' + methodLabels[method]
|
||
ctx.render()
|
||
return true
|
||
}
|
||
return false
|
||
},
|
||
})
|
||
|
||
if (res.action === 'ok' && res.listItem) {
|
||
const target = res.listItem.preset
|
||
if (target && target.index !== PITCH_PRESET_IDX) {
|
||
retuneAllPatterns(target.index, method)
|
||
}
|
||
}
|
||
|
||
drawAll()
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// MIXER FLAGS POPUP
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
function openFlagsPopup() {
|
||
const toneNames = ['Linear pitch', 'Amiga pitch', 'Linear freq']
|
||
const intpNames = ['Default', 'None', 'A500', 'A1200', 'SNES', 'DPCM']
|
||
|
||
let toneMode = initialTrackerMixerflags & 3
|
||
let intpMode = (initialTrackerMixerflags >>> 2) & 7
|
||
if (toneMode >= toneNames.length) toneMode = 0
|
||
if (intpMode >= intpNames.length) intpMode = 0
|
||
|
||
// Build list rows: headers + selectable radio options.
|
||
const items = []
|
||
items.push({ label: 'Tone Mode:', kind: 'header' })
|
||
toneNames.forEach((nm, i) => items.push({ label: nm, kind: 'tone', idx: i }))
|
||
items.push({ label: '', kind: 'spacer' })
|
||
items.push({ label: 'Interpolation:', kind: 'header' })
|
||
intpNames.forEach((nm, i) => items.push({ label: nm, kind: 'intp', idx: i }))
|
||
|
||
const res = win.showDialog({
|
||
title: 'Mixer Flags',
|
||
drawFrame: popupDrawFrame,
|
||
colours: popupColours,
|
||
list: {
|
||
items: items,
|
||
height: items.length,
|
||
width: 22,
|
||
showScrollbar: false,
|
||
selectable: (it) => it.kind === 'tone' || it.kind === 'intp',
|
||
renderItem: (ctx) => {
|
||
const it = ctx.item
|
||
con.move(ctx.y, ctx.x)
|
||
if (it.kind === 'header') {
|
||
con.color_pair(colStatus, colPopupBack)
|
||
print(it.label.padEnd(ctx.w, ' ').substring(0, ctx.w))
|
||
return
|
||
}
|
||
if (it.kind === 'spacer') {
|
||
con.color_pair(colStatus, colPopupBack)
|
||
print(' '.repeat(ctx.w))
|
||
return
|
||
}
|
||
const isChecked = (it.kind === 'tone')
|
||
? (toneMode === it.idx)
|
||
: (intpMode === it.idx)
|
||
const useBg = (ctx.isCursor && ctx.focused) ? colHighlight : colPopupBack
|
||
const useFg = isChecked ? colVoiceHdr : colWHITE
|
||
con.color_pair(useFg, useBg)
|
||
const line = ' ' + (isChecked ? sym.ticked : sym.unticked) + ' ' + it.label
|
||
print(line.padEnd(ctx.w, ' ').substring(0, ctx.w))
|
||
},
|
||
// Space and left-click toggle the radio; Enter commits via OK.
|
||
onActivate: (item, _idx, key) => {
|
||
if (key === ' ' || key === 'click') {
|
||
if (item.kind === 'tone') toneMode = item.idx
|
||
else if (item.kind === 'intp') intpMode = item.idx
|
||
return null
|
||
}
|
||
if (key === '\n') return 'ok'
|
||
return null
|
||
},
|
||
},
|
||
buttons: [
|
||
{ label: 'OK', action: 'ok', default: true },
|
||
{ label: 'Cancel', action: 'cancel' },
|
||
],
|
||
})
|
||
|
||
if (res.action === 'ok') {
|
||
const newFlags = (initialTrackerMixerflags & ~0x1F) |
|
||
(toneMode & 3) | ((intpMode & 7) << 2)
|
||
if (newFlags !== initialTrackerMixerflags) {
|
||
initialTrackerMixerflags = newFlags
|
||
audio.setTrackerMixerFlags(PLAYHEAD, newFlags)
|
||
hasUnsavedChanges = true
|
||
}
|
||
}
|
||
|
||
drawAll()
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// INLINE HEX EDITOR
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
// Overlay an editable hex field at (y, x) with `digits` digits, pre-filled from `initialValue`.
|
||
// Returns the new integer on commit, or null on cancel. Reusable for pattern-grid edits.
|
||
function openInlineHexEdit(y, x, digits, initialValue) {
|
||
let buf = (initialValue >>> 0).toString(16).toUpperCase()
|
||
if (buf.length > digits) buf = buf.substring(buf.length - digits)
|
||
buf = buf.padStart(digits, '0')
|
||
|
||
let cur = 0
|
||
let cancelled = false
|
||
let done = false
|
||
|
||
const repaint = () => {
|
||
con.move(y, x)
|
||
con.color_pair(colWHITE, colHighlight)
|
||
print(' $' + buf + ' ')
|
||
con.move(y, x + 2 + cur)
|
||
con.color_pair(colBLACK, colWHITE)
|
||
print(buf[cur])
|
||
con.color_pair(colStatus, 255)
|
||
}
|
||
|
||
repaint()
|
||
let eventJustReceived = true
|
||
|
||
// Field spans " $XX " — onClick on a digit moves the cursor there.
|
||
// Outside-click commits (Enter); right-click cancels.
|
||
// Region order matters: dispatchMouseEvent searches in reverse, so the
|
||
// field region (registered last) is tested before the catch-all.
|
||
pushMousePopup([
|
||
{ x: 1, y: 1, w: SCRW, h: SCRH, onClick: (cy, cx, btn) => {
|
||
if (btn === 1) done = true
|
||
else if (btn === 2) { cancelled = true; done = true }
|
||
}},
|
||
{ x: x + 2, y: y, w: digits, h: 1, onClick: (cy, cx, btn) => {
|
||
if (btn === 1) { cur = cx - (x + 2); repaint() }
|
||
else if (btn === 2) { cancelled = true; done = true }
|
||
}, onWheel: (cy, cx, dy) => {
|
||
// Wheel adjusts the digit under the cursor.
|
||
const digit = parseInt(buf[cur], 16)
|
||
const next = (digit + (dy < 0 ? 1 : -1) + 16) & 0xF
|
||
buf = buf.substring(0, cur) + next.toString(16).toUpperCase() + buf.substring(cur + 1)
|
||
repaint()
|
||
}},
|
||
])
|
||
|
||
while (!done) {
|
||
input.withEvent(ev => {
|
||
if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) {
|
||
eventJustReceived = false; return
|
||
}
|
||
if (dispatchMouseEvent(ev)) return
|
||
if (ev[0] !== 'key_down') return
|
||
if (1 !== ev[2]) return
|
||
const ks = ev[1]
|
||
|
||
if (ks === '<ESC>') { cancelled = true; done = true; return }
|
||
if (ks === '\n') { done = true; return }
|
||
if (ks === '<LEFT>' && cur > 0) { cur--; repaint(); return }
|
||
if (ks === '<RIGHT>' && cur < digits - 1) { cur++; repaint(); return }
|
||
if (ks === '<HOME>') { cur = 0; repaint(); return }
|
||
if (ks === '<END>') { cur = digits - 1; repaint(); return }
|
||
if (ks.length === 1 && '0123456789abcdefABCDEF'.includes(ks)) {
|
||
buf = buf.substring(0, cur) + ks.toUpperCase() + buf.substring(cur + 1)
|
||
if (cur < digits - 1) cur++
|
||
else done = true
|
||
repaint()
|
||
return
|
||
}
|
||
})
|
||
}
|
||
|
||
popMousePopup()
|
||
|
||
return cancelled ? null : parseInt(buf, 16)
|
||
}
|
||
|
||
clampCursor(); clampVoice(); clampCue(); clampOrdersHoriz(); clampPatternIdx(); clampPatternGrid()
|
||
drawAll()
|
||
|
||
resetAudioDevice()
|
||
taud.uploadTaudFile(fullPathObj.full, currentSongIndex, PLAYHEAD)
|
||
refreshSamplesCache()
|
||
audio.setMasterVolume(PLAYHEAD, 255)
|
||
audio.setMasterPan(PLAYHEAD, 128)
|
||
let initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
|
||
let initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD)
|
||
let initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD)
|
||
|
||
function isExternalPanel(p) {
|
||
return p === VIEW_FILE
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
// MOUSE INPUT
|
||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||
|
||
// Region registry. Coordinates are 1-indexed text cell positions. Each region:
|
||
// {x, y, w, h, onClick(cy, cx, btn, ev)?, onWheel(cy, cx, dy, ev)?, onRelease(...)?}
|
||
// MOUSE_GLOBAL — tabs + transport, live for the whole session.
|
||
// MOUSE_PANEL — per-panel viewport handlers, cleared whenever the panel changes.
|
||
// MOUSE_POPUP_STACK — popups push their own region set on open and pop on close;
|
||
// while non-empty, only the topmost set receives mouse events.
|
||
const MOUSE_GLOBAL = []
|
||
const MOUSE_PANEL = []
|
||
const MOUSE_POPUP_STACK = []
|
||
|
||
// Wrap push/pop so closing a popup also drops any onHoverLeave that would otherwise
|
||
// be invoked against the popup's stale regions on the next mouse move.
|
||
//
|
||
// When the pop happens with a mouse button still held, the popup was almost certainly
|
||
// closed by a click. We arm `swallowResidualClick` so the trailing mouse_up (and any
|
||
// echo mouse_down from that same physical click) doesn't leak into the panel that the
|
||
// popup was covering. A keyboard close leaves no button held, so this is a no-op.
|
||
let swallowResidualClick = false
|
||
function pushMousePopup(regions) { MOUSE_POPUP_STACK.push(regions); lastHoveredRegion = null }
|
||
function popMousePopup() {
|
||
MOUSE_POPUP_STACK.pop()
|
||
lastHoveredRegion = null
|
||
if ((sys.peek(-37) & 0x07) !== 0) swallowResidualClick = true
|
||
}
|
||
|
||
function pixelToCell(px, py) {
|
||
return [(py / CELL_PH | 0) + 1, (px / CELL_PW | 0) + 1] // [cy, cx], 1-indexed
|
||
}
|
||
|
||
function regionHits(r, cy, cx) {
|
||
return cy >= r.y && cy < r.y + r.h && cx >= r.x && cx < r.x + r.w
|
||
}
|
||
|
||
// Dispatch a mouse event to the topmost matching region. Returns true if handled.
|
||
// `mouse_move` also fires onHoverLeave for the previously-hovered region so popups can
|
||
// repaint un-hovered buttons without tracking that themselves.
|
||
let lastHoveredRegion = null
|
||
function dispatchMouseEvent(event) {
|
||
const t = event[0]
|
||
if (t !== 'mouse_down' && t !== 'mouse_wheel' && t !== 'mouse_up' && t !== 'mouse_move') return false
|
||
|
||
// Eat residual events from the click that just closed a popup. The flag is armed
|
||
// by popMousePopup when a button was still held at pop time; it clears on the
|
||
// matching mouse_up so the next fresh press goes through normally.
|
||
if (swallowResidualClick && MOUSE_POPUP_STACK.length === 0) {
|
||
if (t === 'mouse_up') { swallowResidualClick = false; return true }
|
||
if (t === 'mouse_down') { return true }
|
||
if (t === 'mouse_move') { return true }
|
||
// mouse_wheel passes through — it's its own gesture, not part of the closing click
|
||
}
|
||
|
||
const [cy, cx] = pixelToCell(event[1], event[2])
|
||
const pool = (MOUSE_POPUP_STACK.length > 0)
|
||
? MOUSE_POPUP_STACK[MOUSE_POPUP_STACK.length - 1]
|
||
: MOUSE_PANEL.concat(MOUSE_GLOBAL)
|
||
|
||
if (t === 'mouse_move') {
|
||
let hit = null
|
||
for (let i = pool.length - 1; i >= 0; i--) {
|
||
const r = pool[i]
|
||
if (regionHits(r, cy, cx) && (r.onHover || r.onHoverLeave)) { hit = r; break }
|
||
}
|
||
if (hit !== lastHoveredRegion) {
|
||
if (lastHoveredRegion && lastHoveredRegion.onHoverLeave) lastHoveredRegion.onHoverLeave()
|
||
lastHoveredRegion = hit
|
||
}
|
||
if (hit && hit.onHover) { hit.onHover(cy, cx, event); return true }
|
||
return false
|
||
}
|
||
|
||
for (let i = pool.length - 1; i >= 0; i--) {
|
||
const r = pool[i]
|
||
if (!regionHits(r, cy, cx)) continue
|
||
if (t === 'mouse_down' && r.onClick) { r.onClick(cy, cx, event[3], event); return true }
|
||
if (t === 'mouse_wheel' && r.onWheel) { r.onWheel(cy, cx, event[3], event); return true }
|
||
if (t === 'mouse_up' && r.onRelease) { r.onRelease(cy, cx, event[3], event); return true }
|
||
}
|
||
return false
|
||
}
|
||
|
||
function clearPanelMouseRegions() { MOUSE_PANEL.length = 0 }
|
||
function addPanelMouseRegion(x, y, w, h, handlers) { MOUSE_PANEL.push(Object.assign({x, y, w, h}, handlers)) }
|
||
function addGlobalMouseRegion(x, y, w, h, handlers) { MOUSE_GLOBAL.push(Object.assign({x, y, w, h}, handlers)) }
|
||
|
||
// Apply the same panel-switch logic the Tab key path uses.
|
||
function switchToPanel(newPanel) {
|
||
if (newPanel === currentPanel) return
|
||
const wasTimeline = (currentPanel === VIEW_TIMELINE)
|
||
const wasSamples = (currentPanel === VIEW_SAMPLES)
|
||
const wasInstrmnt = (currentPanel === VIEW_INSTRMNT)
|
||
currentPanel = newPanel
|
||
applyMuteTransition(currentPanel)
|
||
if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters()
|
||
if (wasSamples && currentPanel !== VIEW_SAMPLES) clearSampleWaveformArea()
|
||
if (wasInstrmnt && currentPanel !== VIEW_INSTRMNT) clearInstrumentsEnvelopeArea()
|
||
if (isExternalPanel(currentPanel)) {
|
||
clearPanelMouseRegions()
|
||
con.clear(); drawAlwaysOnElems(); drawControlHint()
|
||
pendingExternalDraw = true
|
||
} else {
|
||
rebuildPanelMouseRegions()
|
||
drawAll()
|
||
}
|
||
}
|
||
|
||
// --- Tab bar regions (registered once; tab geometry is constant) ---
|
||
function registerTabRegions() {
|
||
let col = 2 // XOFF, mirrors drawTabBar
|
||
for (let i = 0; i < PANEL_NAMES.length; i++) {
|
||
const w = 1 + PANEL_NAMES[i].length + 1 // spcL + name + spcR
|
||
const tabIdx = i
|
||
addGlobalMouseRegion(col, 3, w, 1, {
|
||
onClick: (cy, cx, btn) => { if (btn === 1) switchToPanel(tabIdx) }
|
||
})
|
||
col += w + (i < PANEL_NAMES.length - 1 ? TAB_GAP : 0)
|
||
}
|
||
}
|
||
|
||
// --- Transport regions (rows 1-2 on the right edge) ---
|
||
// Order j: 0=stop, 1=playrow, 2=playcue, 3=playall — mirrors drawStatusBar's loop.
|
||
function registerTransportRegions() {
|
||
for (let j = 0; j < 4; j++) {
|
||
const glyphCol = SCRW - 5 * (j + 1) + 3
|
||
const idx = j
|
||
addGlobalMouseRegion(glyphCol - 1, 1, 3, 2, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1) return
|
||
if (idx === 0) {
|
||
if (playbackMode !== PLAYMODE_NONE) { stopPlayback(); drawAlwaysOnElems(); redrawPanel() }
|
||
return
|
||
}
|
||
// The play handlers vary by panel — match the keyboard shortcut mapping.
|
||
if (currentPanel === VIEW_PATTERN_DETAILS) {
|
||
if (idx === 1) startPlayPatternRow()
|
||
else startPlayPattern()
|
||
drawPatternsContents(panelPatterns)
|
||
} else {
|
||
if (idx === 1) startPlayRow()
|
||
else if (idx === 2) startPlayCue()
|
||
else startPlaySong()
|
||
redrawPanel()
|
||
}
|
||
drawAlwaysOnElems()
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// --- Per-panel viewport regions ---
|
||
function rebuildPanelMouseRegions() {
|
||
clearPanelMouseRegions()
|
||
if (currentPanel === VIEW_TIMELINE) registerTimelineMouse()
|
||
else if (currentPanel === VIEW_CUES) registerOrdersMouse()
|
||
else if (currentPanel === VIEW_PATTERN_DETAILS) registerPatternsMouse()
|
||
else if (currentPanel === VIEW_SAMPLES) registerSamplesMouse()
|
||
else if (currentPanel === VIEW_INSTRMNT) registerInstrumentsMouse()
|
||
else if (currentPanel === VIEW_PROJECT) registerProjectMouse()
|
||
}
|
||
|
||
function registerSamplesMouse() {
|
||
// Left list (incl. scroll-indicator column, but excluding the separator).
|
||
addPanelMouseRegion(SMP_LIST_X, SMP_LIST_Y, SMP_SEP_X - SMP_LIST_X, SMP_LIST_H, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1) return
|
||
const n = samplesCache ? samplesCache.length : 0
|
||
const target = smpListScroll + (cy - SMP_LIST_Y)
|
||
if (target < 0 || target >= n) return
|
||
smpListCursor = target
|
||
smpUsedScroll = 0
|
||
clampSamplesCursor()
|
||
drawSamplesContents()
|
||
},
|
||
onWheel: (cy, cx, dy) => {
|
||
smpListCursor += dy * 3
|
||
clampSamplesCursor()
|
||
smpUsedScroll = 0
|
||
drawSamplesContents()
|
||
}
|
||
})
|
||
// Right "Used by" list: click launches inst viewer for that slot
|
||
addPanelMouseRegion(SMP_RIGHT_X, SMP_USED_Y + 1, SCRW - SMP_RIGHT_X + 1, SMP_USED_LIST_H, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1) return
|
||
const s = samplesCache && samplesCache[smpListCursor]
|
||
if (!s) return
|
||
const idx = smpUsedScroll + (cy - (SMP_USED_Y + 1))
|
||
if (idx < 0 || idx >= s.usedBy.length) return
|
||
launchInstrumentViewerFor(s.usedBy[idx])
|
||
},
|
||
onWheel: (cy, cx, dy) => {
|
||
const s = samplesCache && samplesCache[smpListCursor]
|
||
if (!s) return
|
||
smpUsedScroll += dy
|
||
drawSamplesUsedBy()
|
||
}
|
||
})
|
||
// Bottom-row Edit button
|
||
addPanelMouseRegion(SMP_RIGHT_X, SMP_BTN_Y, 18, 1, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1) return
|
||
requestEditorLaunch('taut_sampleedit', [fullPathObj.full, VIEW_SAMPLES, smpListCursor])
|
||
}
|
||
})
|
||
}
|
||
|
||
function registerTimelineMouse() {
|
||
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
|
||
const viewRow = cy - PTNVIEW_OFFSET_Y
|
||
const targetRow = scrollRow + viewRow
|
||
if (targetRow < 0 || targetRow >= ROWS_PER_PAT) return
|
||
const oldCursor = cursorRow
|
||
const oldVoxOff = voiceOff
|
||
cursorRow = targetRow
|
||
const relCol = cx - PTNVIEW_OFFSET_X
|
||
if (relCol >= 0) {
|
||
const colSlot = (relCol / COLSIZE_TIMELINE_FULL) | 0
|
||
const targetVox = voiceOff + colSlot
|
||
if (targetVox >= 0 && targetVox < song.numVoices) {
|
||
cursorVox = targetVox
|
||
const fieldX = relCol - colSlot * COLSIZE_TIMELINE_FULL
|
||
let field = 0
|
||
for (let k = 0; k < TL_FIELD_OFFSETS.length; k++) if (fieldX >= TL_FIELD_OFFSETS[k]) field = k
|
||
timelineColCursor = field
|
||
}
|
||
}
|
||
clampCursor(); clampVoice()
|
||
if (voiceOff !== oldVoxOff || Math.abs(cursorRow - oldCursor) >= PTNVIEW_HEIGHT) drawAll()
|
||
else {
|
||
drawPatternView(); drawVoiceHeaders(); drawSeparators(separatorStyle)
|
||
drawAlwaysOnElems(); drawVoiceDetail()
|
||
}
|
||
},
|
||
onWheel: (cy, cx, dy) => {
|
||
if (playbackMode !== PLAYMODE_NONE) return
|
||
cursorRow += dy * 3
|
||
clampCursor()
|
||
drawPatternView(); drawSeparators(separatorStyle); drawAlwaysOnElems(); drawVoiceDetail()
|
||
}
|
||
})
|
||
}
|
||
|
||
function registerOrdersMouse() {
|
||
// Layout (1-indexed cells, mirrors drawOrdersRowAt):
|
||
// cols 1..3 = row number (no column meaning)
|
||
// col 4 = gap
|
||
// cols 5..10 = CMD (ordersColCursor = 0)
|
||
// col 11 = gap
|
||
// cols 12 + s*4 .. 12 + s*4 + 3 = voice slot s on screen
|
||
// (ordersColCursor = ordersVoiceOff + s + 1)
|
||
//
|
||
// Returns the ordersColCursor value for a given cx, or -1 if not on a column.
|
||
const colAtX = (cx) => {
|
||
if (cx >= ORDERS_CMD_X && cx < ORDERS_CMD_X + 6) return 0
|
||
if (cx >= ORDERS_VOICE_X) {
|
||
const slot = ((cx - ORDERS_VOICE_X) / ORDERS_VOICE_COL_W) | 0
|
||
if (slot < 0 || slot >= VOCSIZE_ORDERS) return -1
|
||
const v = ordersVoiceOff + slot
|
||
if (v >= song.numVoices) return -1
|
||
return v + 1
|
||
}
|
||
return -1
|
||
}
|
||
|
||
const hscrollBy = (dx) => {
|
||
const maxOff = Math.max(0, song.numVoices - VOCSIZE_ORDERS)
|
||
const next = Math.max(0, Math.min(maxOff, ordersVoiceOff + dx))
|
||
if (next === ordersVoiceOff) return false
|
||
ordersVoiceOff = next
|
||
return true
|
||
}
|
||
|
||
// Header row: click selects a column without touching the row; wheel scrolls
|
||
// voice columns horizontally (it's the natural place for column navigation).
|
||
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y - 1, SCRW, 1, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
|
||
const col = colAtX(cx)
|
||
if (col < 0) return
|
||
ordersColCursor = col
|
||
clampOrdersHoriz(); redrawPanel(); drawAlwaysOnElems()
|
||
},
|
||
onWheel: (cy, cx, dy) => {
|
||
if (hscrollBy(dy * 3)) { redrawPanel(); drawAlwaysOnElems() }
|
||
},
|
||
})
|
||
|
||
// Content rows: click sets the row and (when on a column) the column too;
|
||
// wheel scrolls vertically; Shift+wheel scrolls horizontally.
|
||
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, {
|
||
onClick: (cy, cx, btn, ev) => {
|
||
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
|
||
const viewRow = cy - PTNVIEW_OFFSET_Y
|
||
const targetIdx = ordersScroll + viewRow
|
||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||
if (targetIdx < 0 || targetIdx > maxCue) return
|
||
ordersCursor = targetIdx
|
||
const col = colAtX(cx)
|
||
if (col >= 0) ordersColCursor = col
|
||
scrollOrdersTo(ordersCursor)
|
||
clampOrdersHoriz()
|
||
redrawPanel(); drawAlwaysOnElems()
|
||
},
|
||
onWheel: (cy, cx, dy, ev) => {
|
||
const shiftDown = (ev.includes(59) || ev.includes(60))
|
||
if (shiftDown) {
|
||
if (hscrollBy(dy * 3)) { redrawPanel(); drawAlwaysOnElems() }
|
||
return
|
||
}
|
||
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
|
||
ordersCursor += dy * 3
|
||
if (ordersCursor < 0) ordersCursor = 0
|
||
if (ordersCursor > maxCue) ordersCursor = maxCue
|
||
scrollOrdersTo(ordersCursor)
|
||
redrawPanel(); drawAlwaysOnElems()
|
||
}
|
||
})
|
||
}
|
||
|
||
function registerPatternsMouse() {
|
||
// Left column: pattern list. cx in [PATEDITOR_LIST_X, PATEDITOR_SEP1_X)
|
||
addPanelMouseRegion(PATEDITOR_LIST_X, PTNVIEW_OFFSET_Y,
|
||
PATEDITOR_SEP1_X - PATEDITOR_LIST_X, PTNVIEW_HEIGHT, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1 || song.numPats === 0 || playbackMode !== PLAYMODE_NONE) return
|
||
const targetIdx = patternListScroll + (cy - PTNVIEW_OFFSET_Y)
|
||
if (targetIdx < 0 || targetIdx >= song.numPats) return
|
||
patternIdx = targetIdx
|
||
clampPatternIdx(); simStateKey = ''
|
||
drawPatternsContents(panelPatterns)
|
||
},
|
||
onWheel: (cy, cx, dy) => {
|
||
if (song.numPats === 0) return
|
||
patternIdx += dy
|
||
clampPatternIdx(); simStateKey = ''
|
||
drawPatternsContents(panelPatterns)
|
||
}
|
||
})
|
||
// Middle grid: pattern editor cells. cx in [PATEDITOR_GRID_X, PATEDITOR_DETAIL_X)
|
||
addPanelMouseRegion(PATEDITOR_GRID_X, PTNVIEW_OFFSET_Y,
|
||
PATEDITOR_DETAIL_X - PATEDITOR_GRID_X, PTNVIEW_HEIGHT, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1 || song.numPats === 0 || playbackMode !== PLAYMODE_NONE) return
|
||
const targetRow = patternGridScroll + (cy - PTNVIEW_OFFSET_Y)
|
||
if (targetRow < 0 || targetRow >= ROWS_PER_PAT) return
|
||
patternGridRow = targetRow
|
||
const cellRel = cx - PATEDITOR_CELL_X
|
||
const fieldOffsets = [0, 5, 8, 11, 14, 15]
|
||
let field = 0
|
||
for (let k = 0; k < fieldOffsets.length; k++) if (cellRel >= fieldOffsets[k]) field = k
|
||
if (field < 0) field = 0; if (field > 5) field = 5
|
||
patternGridCol = field
|
||
clampPatternGrid(); simStateKey = ''
|
||
drawPatternsContents(panelPatterns)
|
||
},
|
||
onWheel: (cy, cx, dy) => {
|
||
if (song.numPats === 0) return
|
||
patternGridRow += dy * 3
|
||
clampPatternGrid(); simStateKey = ''
|
||
drawPatternsContents(panelPatterns)
|
||
}
|
||
})
|
||
}
|
||
|
||
function registerProjectMouse() {
|
||
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, {
|
||
onClick: (cy, cx, btn) => {
|
||
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
|
||
// Meta rows occupy PTNVIEW_OFFSET_Y .. PTNVIEW_OFFSET_Y + PROJ_META_ROWS_COUNT - 1.
|
||
// The song list starts at PROJ_SONGLIST_Y + 1.
|
||
const metaRow = cy - PTNVIEW_OFFSET_Y
|
||
if (metaRow >= 0 && metaRow < PROJ_META_ROWS_COUNT) {
|
||
projectCursor = metaRow
|
||
clampProjectCursor(); redrawPanel()
|
||
return
|
||
}
|
||
const songRow = cy - (PROJ_SONGLIST_Y + 1)
|
||
if (songRow >= 0) {
|
||
const songIdx = projectSongScroll + songRow
|
||
if (songIdx >= 0 && songIdx < songsMeta.numSongs) {
|
||
projectCursor = PROJ_META_ROWS_COUNT + songIdx
|
||
clampProjectCursor(); redrawPanel()
|
||
}
|
||
}
|
||
},
|
||
onWheel: (cy, cx, dy) => {
|
||
const rowsVis = projectSongListRowsVisible()
|
||
const maxScroll = Math.max(0, songsMeta.numSongs - rowsVis)
|
||
projectSongScroll += dy * 3
|
||
if (projectSongScroll < 0) projectSongScroll = 0
|
||
if (projectSongScroll > maxScroll) projectSongScroll = maxScroll
|
||
redrawPanel()
|
||
}
|
||
})
|
||
}
|
||
|
||
registerTabRegions()
|
||
registerTransportRegions()
|
||
rebuildPanelMouseRegions()
|
||
|
||
// Launching a sub-program from inside an input.withEvent callback causes the triggering
|
||
// Tab event to leak into the sub-program's own withEvent call (the event hasn't been
|
||
// consumed yet when the callback is still executing). We avoid this by deferring the
|
||
// actual shell.execute until after withEvent returns.
|
||
let exitFlag = false
|
||
let pendingExternalDraw = false
|
||
|
||
while (!exitFlag) {
|
||
input.withEvent(event => {
|
||
if (dispatchMouseEvent(event)) return
|
||
if (event[0] !== "key_down") return
|
||
const keysym = event[1]
|
||
const keyJustHit = (1 == event[2])
|
||
const shiftDown = (event.includes(59) || event.includes(60))
|
||
|
||
if (keyJustHit && shiftDown && event.includes(keys.Q) &&
|
||
(currentPanel === VIEW_TIMELINE || currentPanel === VIEW_PATTERN_DETAILS)) {
|
||
openRetunePopup()
|
||
return
|
||
}
|
||
|
||
if (keyJustHit && keysym === "q") {
|
||
if (openConfirmQuit()) exitFlag = true
|
||
return
|
||
}
|
||
|
||
if (keyJustHit && keysym === "<TAB>") {
|
||
const wasTimeline = (currentPanel === VIEW_TIMELINE)
|
||
const wasSamples = (currentPanel === VIEW_SAMPLES)
|
||
currentPanel = (currentPanel + (shiftDown ? -1 : 1))
|
||
if (currentPanel < 0) currentPanel += panels.length
|
||
currentPanel = currentPanel % panels.length
|
||
applyMuteTransition(currentPanel)
|
||
if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters()
|
||
if (wasSamples && currentPanel !== VIEW_SAMPLES) clearSampleWaveformArea()
|
||
if (isExternalPanel(currentPanel)) {
|
||
// Redraw header now so the tab highlight is visible immediately,
|
||
// but defer the actual sub-program launch to after withEvent returns.
|
||
clearPanelMouseRegions()
|
||
con.clear(); drawAlwaysOnElems(); drawControlHint()
|
||
pendingExternalDraw = true
|
||
} else {
|
||
rebuildPanelMouseRegions()
|
||
drawAll()
|
||
}
|
||
return
|
||
}
|
||
|
||
if (keyJustHit && shiftDown && event.includes(keys.G)) {
|
||
openGotoPopup()
|
||
return
|
||
}
|
||
|
||
if (keyJustHit && keysym === '!') {
|
||
openHelpPopup()
|
||
return
|
||
}
|
||
|
||
panels[currentPanel].processInput(event)
|
||
})
|
||
|
||
// Launch external sub-program OUTSIDE the withEvent callback so the triggering
|
||
// Tab event is fully consumed before the sub-program's event loop begins.
|
||
if (pendingExternalDraw) {
|
||
pendingExternalDraw = false
|
||
redrawPanel()
|
||
while (_G.TAUT.UI.NEXTPANEL !== undefined && _G.TAUT.UI.NEXTPANEL !== null) {
|
||
const wasTimeline = (currentPanel === VIEW_TIMELINE)
|
||
currentPanel = _G.TAUT.UI.NEXTPANEL
|
||
_G.TAUT.UI.NEXTPANEL = undefined
|
||
applyMuteTransition(currentPanel)
|
||
if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters()
|
||
if (isExternalPanel(currentPanel)) {
|
||
clearPanelMouseRegions()
|
||
con.clear(); drawAlwaysOnElems(); drawControlHint()
|
||
redrawPanel()
|
||
} else {
|
||
rebuildPanelMouseRegions()
|
||
drawAll()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Launch the sample / instrument editor as a sub-program. Same deferral
|
||
// reason as pendingExternalDraw: avoid leaking the trigger key into the
|
||
// sub-program's own withEvent loop. The editor sets NEXTPANEL on exit
|
||
// (typically back to its parent viewer), so the loop above will repaint.
|
||
if (pendingEditorLaunch) {
|
||
const { progName, args } = pendingEditorLaunch
|
||
pendingEditorLaunch = null
|
||
stopPlayback()
|
||
clearSampleWaveformArea()
|
||
clearPanelMouseRegions()
|
||
con.clear(); drawAlwaysOnElems(); drawControlHint()
|
||
_G.TAUT.UI.NEXTPANEL = undefined
|
||
_G.shell.execute(`${progName} ${args.join(' ')}`)
|
||
// After the editor returns, instruments / samples may have changed —
|
||
// rebuild the deduped sample list before redrawing whatever panel comes next.
|
||
refreshSamplesCache()
|
||
if (_G.TAUT.UI.NEXTPANEL === undefined || _G.TAUT.UI.NEXTPANEL === null) {
|
||
// Editor declined to switch panels — repaint the current panel.
|
||
rebuildPanelMouseRegions()
|
||
drawAll()
|
||
} else {
|
||
while (_G.TAUT.UI.NEXTPANEL !== undefined && _G.TAUT.UI.NEXTPANEL !== null) {
|
||
const wasTimeline = (currentPanel === VIEW_TIMELINE)
|
||
currentPanel = _G.TAUT.UI.NEXTPANEL
|
||
_G.TAUT.UI.NEXTPANEL = undefined
|
||
applyMuteTransition(currentPanel)
|
||
if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters()
|
||
if (isExternalPanel(currentPanel)) {
|
||
clearPanelMouseRegions()
|
||
con.clear(); drawAlwaysOnElems(); drawControlHint()
|
||
redrawPanel()
|
||
} else {
|
||
rebuildPanelMouseRegions()
|
||
drawAll()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (playbackMode !== PLAYMODE_NONE) updatePlayback()
|
||
}
|
||
|
||
audio.stop(PLAYHEAD)
|
||
resetAudioDevice()
|
||
sys.free(SCRATCH_PTR)
|
||
font.resetLowRom()
|
||
font.resetHighRom()
|
||
graphics.clearPixels(255)
|
||
con.clear()
|
||
con.move(1, 1)
|
||
con.curs_set(1)
|
||
return 0 |