实施循环和第一个效果
#javascript #webaudio #retrocomputing

循环样品

当前,玩家只能播放“一次性”样本,这意味着他们一次播放一次然后停下来。这意味着循环样品无法正常工作。在仪器数据中,有两个称为repeatOffsetrepeatLength的字段。这些用于定义样品的循环点。对于没有循环的样本,repeatOffset设置为0,并且repeatLength设置为0或2,具体取决于使用哪种版本的MOD格式。我在Instrument类中添加了一个新的成员变量,以检查样品是否被循环:isLooped,并在Channel类中使用该样本来确定样品是否应循环。

class Instrument {
    constructor(modfile, index, sampleStart) {
        const data = new Uint8Array(modfile, 20 + index * 30, 30);
        const nameBytes = data.slice(0, 21).filter(a => !!a);
        this.name = String.fromCodePoint(...nameBytes).trim();
        this.length = 2 * (data[22] * 256 + data[23]);
        this.finetune = data[24]; // Signed 4 bit integer
        if (this.finetune > 7) this.finetune -= 16;
        this.volume = data[25];
        this.repeatOffset = 2 * (data[26] * 256 + data[27]);
        this.repeatLength = 2 * (data[28] * 256 + data[29]);
        this.bytes = new Int8Array(modfile, sampleStart, this.length);
        this.isLooped = this.repeatOffset != 0 || this.repeatLength > 2;
    }
}
    nextOutput() {
        if (!this.instrument || !this.period) return 0.0;
        const sample = this.instrument.bytes[this.sampleIndex | 0];

        this.sampleIndex += this.sampleSpeed;

        if (this.instrument.isLooped) {
            if (this.sampleIndex >= this.instrument.repeatOffset + this.instrument.repeatLength) {
                this.sampleIndex = this.instrument.repeatOffset;
            }
        }
        else if (this.sampleIndex >= this.instrument.length) {
            return 0.0;
        }

        return sample / 256.0;
    }

找到最重要的效果

mod格式定义了很多效果,但是其中一些效果并不经常在演示歌曲中使用。我本文系列的目标是能够播放三首歌曲:

找出这三首歌曲使用的效果,我重构Channel类的effect方法来记录每个未完成效果的效果ID。在阅读了有关不同效果ID的更多信息之后,我还发现0x0E效应被用作扩展效果的前缀,因此也添加了。

// Declared at the top of the file, outside of the class definition
const unimplementedEffects = new Set();
const EXTENDED = 0x0e;
const SET_SPEED = 0x0f;

// Updated effect method of the Channel class
    effect(raw) {
        let id = raw >> 8;
        let data = raw & 0xff;

        if (id == EXTENDED) {
            // Effect ID can be E0..EF
            id = (id << 4) | (data >> 4);
            data = data & 0x0f;
        }

        if (id == SET_SPEED) {
            if (data >= 1 && data <= 31) {
                this.worklet.setTicksPerRow(data);
            }
            else {
                this.worklet.setBpm(data);
            }
        }
        else {
            if (!unimplementedEffects.has(id)) {
                unimplementedEffects.add(id);
                console.log('Unimplemented effect ' + id.toString(16));
            }
        }
    }

使用三首歌曲运行播放器后,我将获得以下效果列表,并且可以将其定义添加到player-worklet.js文件:

  • 效果0•Arpeggio
  • 效果1 - 滑动
  • 效果2 - 滑动
  • 效果3 - 音调
  • 效果4颤音
  • 效果5 - 音调 +音量幻灯片
  • 效果6颤音 +音量幻灯片
  • 效果9样品偏移
  • 效果A量幻灯片
  • 效果câset卷
  • 效果D模式断裂
  • 效果e9
  • 效果ED延迟注释
  • 效果f - 设置速度 (完成)
const ARPEGGIO = 0x00;
const SLIDE_UP = 0x01;
const SLIDE_DOWN = 0x02;
const TONE_PORTAMENTO = 0x03;
const VIBRATO = 0x04;
const TONE_PORTAMENTO_WITH_VOLUME_SLIDE = 0x05;
const VIBRATO_WITH_VOLUME_SLIDE = 0x06;
const SAMPLE_OFFSET = 0x09;
const VOLUME_SLIDE = 0x0A;
const SET_VOLUME = 0x0C;
const PATTERN_BREAK = 0x0D;
const EXTENDED = 0x0e;
const SET_SPEED = 0x0f;
const RETRIGGER_NOTE = 0xe9;
const DELAY_NOTE = 0xed;

在我发现的文档中,效果以非常技术性的方式描述,但缺少许多细节。同样,如果不听取行动,就不可能确切知道效果听起来像什么。幸运的是,我发现了Demoscene音乐家Alex Löfgren (Wasp)YouTube playlist的形式找到了一个很大的信息来源,其中详细解释了许多效果,并在作用中显示。在整个效果的实施过程中,我正在聆听播放列表中的示例,以确保我正确地完成它们,并在Protracker中制作最少的测试歌曲以尝试效果。

在本文中,我实施了四个效果,其余的剩下的文章都留下了:

  • 效果câset卷
  • 效果9样品偏移
  • 效果D模式断裂
  • 效果A量幻灯片

效果c set卷

到目前为止,所有样本都在全部播放,这是不正确的。首先,每个仪器都有一个volume属性,该属性是0到64 之间的值(Amiga Paula芯片中音频的最大音量)。其次,集体积效应可用于覆盖仪器的体积。可以在触发音符时设置它,或在音符期间的任何时间更改。要实现此目的,我将volume属性添加到Channel类中,更新nextOutput方法以使用频道卷代替仪器卷,然后在遇到效果时更新effect方法以设置卷。

    constructor(worklet) {
        this.worklet = worklet;
        this.instrument = null;
        this.playing = false;
        this.period = 0;
        this.sampleSpeed = 0.0;
        this.sampleIndex = 0;
        this.volume = 64;
    }
    nextOutput() {
        if (!this.instrument || !this.period) return 0.0;
        const sample = this.instrument.bytes[this.sampleIndex | 0];

        this.sampleIndex += this.sampleSpeed;

        if (this.instrument.isLooped) {
            if (this.sampleIndex >= this.instrument.repeatOffset + this.instrument.repeatLength) {
                this.sampleIndex = this.instrument.repeatOffset;
            }
        }
        else if (this.sampleIndex >= this.instrument.length) {
            return 0.0;
        }

        return sample / 256.0 * this.volume / 64;
    }
        switch (id) {
            case SET_VOLUME:
                this.volume = data;
                break;
            case SET_SPEED:
                if (data >= 1 && data <= 31) {
                    this.worklet.setTicksPerRow(data);
                }
                else {
                    this.worklet.setBpm(data);
                }
                break;
            default:
                if (!unimplementedEffects.has(id)) {
                    unimplementedEffects.add(id);
                    console.log('Unimplemented effect ' + id.toString(16));
                }
                break;
        }

效果9样品偏移

这种效果使玩家将样本中的位置设置为特定值。该值是样品偏移的上部八位,因此必须将其乘以256才能获得实际偏移。该效果可用于在样本播放时向前和向后跳,或者在触发音符时从选定的位置开始。实现这一点非常简单,我只需要更新遇到效果时设置样本索引的effect方法。

            case SAMPLE_OFFSET:
                this.sampleIndex = data * 256;
                break;

效果D图案中断

这种效果使玩家在下一个模式下跳到特定行。跳跃发生在当前行完成之后,因此不会立即触发效果。效果的值是以二进制编码十进制表示法开始播放的行号。第一个十六进制数字是行号的“数十个”,第二个十六进制数字是“一个”。为了制作这项工作,我添加了我在构造函数中初始化为falsePlayerWorklet类的patternBreak成员变量,然后添加了一个setPatternBreak方法,当遇到效果时,我从effect方法中调用了setPatternBreak方法。


    constructor() {
        super();
        this.port.onmessage = this.onmessage.bind(this);
        this.mod = null;
        this.channels = [ new Channel(this), new Channel(this), new Channel(this), new Channel(this) ];
        this.patternBreak = false;
    }
    setPatternBreak(row) {
        this.patternBreak = row;
    }
    nextRow() {
        ++this.rowIndex;
        if (this.patternBreak !== false) {
            this.rowIndex = this.patternBreak;
            ++this.position;
            this.patternBreak = false;
        }
        else if (this.rowIndex == 64) {
            this.rowIndex = 0;
            ++this.position;
        }

        // The rest of the nextRow method is unchanged
            case PATTERN_BREAK:
                const row = (data >> 4) * 10 + (data & 0x0f);
                this.worklet.setPatternBreak(row);
                break;

效果卷幻灯片

通过反复更改通道的音量,每行的数量多次。由于次数的数量与设定速度效果设置的ticksPerRow值相同,因此在播放代码中已经有一个“ tick”的想法。不幸的是,播放器不会跟踪当前刻度,因此我需要在PlayerWorklet类中添加tick成员变量和nextTick方法。与其跟踪何时移至下一行,我会跟踪何时移至下一个tick,并在tick到达ticksPerRow值时致电nextRow。完成所有操作后,我可以实现卷幻灯片效应,以及其他使用tick数的效果。

// Only the changed methods are shown here
    onmessage(e) {
        if (e.data.type === 'play') {
            this.mod = e.data.mod;
            this.sampleRate = e.data.sampleRate;

            this.setBpm(125);
            this.setTicksPerRow(6);

            // Start at the last tick of the pattern "before the first pattern"
            this.position = -1;
            this.rowIndex = 63;
            this.tick = 5;
            this.ticksPerRow = 6;

            // Immediately move to the first row of the first pattern
            this.outputsUntilNextTick = 0;
        }
    }

    setTicksPerRow(ticksPerRow) {
        this.ticksPerRow = ticksPerRow;
    }

    setBpm(bpm) {
        this.bpm = bpm;
        this.outputsPerTick = this.sampleRate * 60 / this.bpm / 4 / 6;
    }

    nextTick() {
        ++this.tick;
        if (this.tick == this.ticksPerRow) {
            this.tick = 0;
            this.nextRow();
        }

        for (let i = 0; i < 4; ++i) {
            this.channels[i].performTick();
        }
    }

    nextOutput() {
        if (!this.mod) return 0.0;

        if (this.outputsUntilNextTick <= 0) {
            this.nextTick();
            this.outputsUntilNextTick += this.outputsPerTick;
        }

        this.outputsUntilNextTick--;

        const rawOutput = this.channels.reduce((acc, channel) => acc + channel.nextOutput(), 0.0);
        return Math.tanh(rawOutput);
    }
// Only the changed methods are shown here
    nextOutput() {
        if (!this.instrument || !this.period) return 0.0;
        const sample = this.instrument.bytes[this.sampleIndex | 0];

        this.sampleIndex += this.sampleSpeed;

        if (this.instrument.isLooped) {
            if (this.sampleIndex >= this.instrument.repeatOffset + this.instrument.repeatLength) {
                this.sampleIndex = this.instrument.repeatOffset;
            }
        }
        else if (this.sampleIndex >= this.instrument.length) {
            return 0.0;
        }

        return sample / 256.0 * this.currentVolume / 64;
    }

    performTick() {
        if (this.volumeSlide) {
            this.currentVolume += this.volumeSlide;
            if (this.currentVolume < 0) this.currentVolume = 0;
            if (this.currentVolume > 64) this.currentVolume = 64;
        }
    }

    effect(raw) {
        this.volumeSlide = 0;

        if (!raw) return;

        let id = raw >> 8;
        let data = raw & 0xff;

        if (id == EXTENDED) {
            id = (id << 4) | (data >> 4);
            data = data & 0x0f;
        }

        switch (id) {
            case SET_VOLUME:
                this.volume = data;
                this.currentVolume = this.volume;
                break;
            case VOLUME_SLIDE:
                if (data & 0xf0) {
                    this.volumeSlide = data >> 4;
                }
                else if (data & 0x0f) {
                    this.volumeSlide = -(data & 0x0f);
                }
                break;
        // The rest of the effect method is unchanged

结论

效果是mod文件音乐体验的非常重要的一部分,仅在这四个效果之后,参考mod听起来好多了。在接下来的几篇文章中,我将实现剩余的效果,然后我将继续实施更多的事情,这些内容将帮助我在演示中使用mod文件。

您可以在atornblad.github.io/js-mod-player中尝试此解决方案。

该代码的最新版本始终在the GitHub repository中可用。