Compare commits

...

2 Commits

Author SHA1 Message Date
minjaesong
46ae6511f6 taut.js: view multiple songs 2026-05-13 01:51:57 +09:00
minjaesong
577d46d31e keys to change playback tickrate 2026-05-12 23:35:42 +09:00
5 changed files with 304 additions and 17 deletions

View File

@@ -256,7 +256,7 @@ The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 0-1):
- `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources should be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here.
- `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630).
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). ST3 linear mode is the expected import source; Amiga-mode G sources should be treated as linear. MONOTONE `3xx` → Taud `G $00xx` verbatim under ff=2. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). Amiga-mode G sources should be treated as linear. MONOTONE `3xx` → Taud `G $00xx` verbatim under ff=2. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
**Implementation.**
@@ -1140,8 +1140,8 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
0b 000 rrr ff
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- ff = 0: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
- rrr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear.
@@ -1277,10 +1277,9 @@ These quirks of ST3 are worth preserving or flagging when importing S3M files in
**Global volume scale.** ST3's 0..$40 maps to Taud's 0..$FF with a ×4 scale on import, truncated ÷4 on export.
**Linear pitch slides.** ST3's slide arithmetic is period-based (Amiga) or linear-table-indexed; Taud carries both interpretations and selects between them via the song-table `f` flag. Conversion rules:
**Linear pitch slides.** ST3's slide arithmetic is period-based; Taud supports both linear and period-based and selects between them via the song-table `f` flag. Conversion rules:
- **ST3 linear mode** (`linear_slides` set in S3M flags): coarse forms (Exx/Fxx) use `round(× 64/3)` (1/16 semitone per ST3 unit); fine/extra-fine (EFx/EEx/FFx/FEx) use `round(× 16/3)` (1/64 semitone per ST3 unit). Taud `f` flag is **clear**; the engine subtracts the stored 4096-TET argument directly from the channel pitch.
- **ST3 Amiga mode** (`linear_slides` clear): both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character.
- Clear `linear_slides`. Both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character.
- G (tone portamento) is always converted with `round(× 64/3)` and treated as linear, regardless of mode.
**Default tempo byte.** Taud's default $64 equals 125 BPM under the $19 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$64` after subtracting $19. Converters must remap on both import and export.

View File

@@ -537,12 +537,127 @@ function loadTaud(filePath, songIndex) {
sys.free(ptr)
return {
filePath, version, numSongs, numVoices, numPats,
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: '',
}
}
let projectName = ''
// 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
}
// '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
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) {
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 }
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// GUI DEFINITION
@@ -1355,7 +1470,10 @@ const buttonTexture = new gl.Texture(2, 28, buttonBytes)
font.setLowRom("A:/tvdos/bin/tautfont_low.chr")
font.setHighRom("A:/tvdos/bin/tautfont_high.chr")
const song = loadTaud(fullPathObj.full, 0)
const songsMeta = loadTaudSongList(fullPathObj.full)
let currentSongIndex = 0
let projectSongCursor = 0
let song = loadTaud(fullPathObj.full, currentSongIndex)
const voiceMutes = new Array(NUM_VOICES).fill(false)
let timelineMuteSnapshot = null
@@ -1382,6 +1500,47 @@ function applyMuteTransition(toPanel) {
}
}
// 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)
taud.uploadTaudFile(fullPathObj.full, newIndex, PLAYHEAD)
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() {
@@ -1555,6 +1714,8 @@ function timelineInput(wo, event) {
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>") {
@@ -2188,6 +2349,8 @@ function patternsInput(wo, event) {
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()
@@ -2266,12 +2429,13 @@ function drawProjectContents(wo) {
let mixerflag = initialTrackerMixerflags
let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3]
let intpModeStr = ['Fast Sinc','No intp.','A500 intp.','A1200 intp.'][(mixerflag >>> 2) & 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,
@@ -2287,13 +2451,127 @@ function drawProjectContents(wo) {
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 clampProjectSongCursor() {
const n = songsMeta.numSongs
if (projectSongCursor < 0) projectSongCursor = 0
if (projectSongCursor > n - 1) projectSongCursor = n - 1
const rowsVis = projectSongListRowsVisible()
if (projectSongCursor < projectSongScroll) projectSongScroll = projectSongCursor
else if (projectSongCursor >= projectSongScroll + rowsVis)
projectSongScroll = projectSongCursor - 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 = (idx === projectSongCursor)
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 ? colBrand : 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>') {
projectSongCursor -= moveDelta; clampProjectSongCursor(); redrawPanel(); return
}
if (keysym === '<DOWN>') {
projectSongCursor += moveDelta; clampProjectSongCursor(); redrawPanel(); return
}
if (keysym === '<PAGE_UP>') {
projectSongCursor -= projectSongListRowsVisible(); clampProjectSongCursor(); redrawPanel(); return
}
if (keysym === '<PAGE_DOWN>') {
projectSongCursor += projectSongListRowsVisible(); clampProjectSongCursor(); redrawPanel(); return
}
if (keysym === '<HOME>') {
projectSongCursor = 0; clampProjectSongCursor(); redrawPanel(); return
}
if (keysym === '<END>') {
projectSongCursor = songsMeta.numSongs - 1; clampProjectSongCursor(); redrawPanel(); return
}
if (keysym === '\n') {
if (projectSongCursor !== currentSongIndex) switchSong(projectSongCursor)
return
}
if (keysym === ' ') {
stopPlayback(); drawAlwaysOnElems(); return
}
}
function externalPanelInput(wo, event) {}
const panelSamples = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_sampleedit'), undefined, ()=>{})
const panelInstrmnt = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, makeExternalPanelDraw('taut_instredit'), undefined, ()=>{})
const panelProject = new win.WindowObject(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, externalPanelInput, drawProjectContents, 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]
@@ -2346,6 +2624,16 @@ function restoreFullSongParams() {
previewActive = 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()
audio.stop(PLAYHEAD)
@@ -2392,7 +2680,6 @@ function startPlayPattern() {
if (song.numPats === 0) return
audio.stop(PLAYHEAD)
audio.setBPM(PLAYHEAD, song.bpm)
audio.setTickRate(PLAYHEAD, song.tickRate)
audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx))
audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX)
audio.setTrackerRow(PLAYHEAD, 0)
@@ -2408,7 +2695,6 @@ function startPlayPatternRow() {
if (song.numPats === 0) return
audio.stop(PLAYHEAD)
audio.setBPM(PLAYHEAD, song.bpm)
audio.setTickRate(PLAYHEAD, song.tickRate)
audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx))
audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX)
audio.setTrackerRow(PLAYHEAD, patternGridRow)
@@ -2799,12 +3085,12 @@ clampCursor(); clampVoice(); clampCue(); clampOrdersHoriz(); clampPatternIdx();
drawAll()
resetAudioDevice()
taud.uploadTaudFile(fullPathObj.full, 0, PLAYHEAD)
taud.uploadTaudFile(fullPathObj.full, currentSongIndex, PLAYHEAD)
audio.setMasterVolume(PLAYHEAD, 255)
audio.setMasterPan(PLAYHEAD, 128)
const initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
const initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD)
const initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD)
let initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
let initialGlobalVolume = audio.getSongGlobalVolume(PLAYHEAD)
let initialMixingVolume = audio.getSongMixingVolume(PLAYHEAD)
function isExternalPanel(p) {
return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE

View File

@@ -90,6 +90,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggle timeline view mode. W-most detailed, R-most abridged</O>
&bul;<b>n</b> : <O>toggle soloing of the selected voice</O>
&bul;<b>m</b> : <O>toggle muting of the selected voice</O>
&bul;<b>[</b>&mdot;<b>]</b> : <O>change tick rate of playhead</O>
<b>&nbsp;EDIT MODE</b>
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>

View File

@@ -2399,7 +2399,7 @@ TODO:
previous "row volume default = 63" behaviour.
[x] physical_presence order 0x1F chn 2: note cuts unexpectedly fast — engine fix
[x] GSLINGER order 0x03 chn 1: L 0100 fades unexpectedly fast? — converter fix
[ ] do not reset tickspeed on pattern view play / add key to modify tick speed ('[' down/']' up)
[x] do not reset tickspeed on pattern view play / add key to modify tick speed ('[' down/']' up)
[ ] expose song table on UI (test with `insaniq2.taud`)
TODO - list of demo songs that MUST ship with Microtone:

View File

@@ -3410,6 +3410,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var samplePos = 0.0
var playbackRate = 1.0
var forward = true
var instrumentId = 0
// -1 for live foreground voices held by TrackerState.voices[]; 0..19 for background
// (mixer-private) ghosts spawned by NNA on the matching channel index.