添加与音高相关的效果
#javascript #webaudio #retrocomputing

香草JavaScript mod播放器的进步很好。现在是时候进行某种与音高相关的效果 - 颤音,Portamento和Arpeggio。我还将添加这些效果的变体,使您在音调变化的同时,使您上下滑动音量。这些是我要实现的效果:

  • 效果0 - arpeggio
  • 效果1 - 滑动
  • 效果2 - 滑动
  • 效果3 - 音portamento
  • 效果4颤音
  • 效果5 - 音调 +音量幻灯片
  • 效果6 vibrato +音量幻灯片
  • 效果9样品偏移 (完成)
  • 效果a卷幻灯片 (完成)
  • 效果câset卷 (完成)
  • 效果D图案断裂(完成)
  • 效果e9 recrigger Note (在下一篇文章中)
  • 效果eD delay note (在下一篇文章中)
  • 效果f - 设置速度 (完成)

效果1 slide

这种效果通过减少音符的周期来向上滑动音高。每个刻度,效果参数中指定的值都会缩短周期。为了管理这一点,我需要在Channel类中添加currentPeriod变量和一个periodDelta变量,并在performTick方法中更新该期间。计算样品速度的代码从play方法移动到performTick方法,以便更新速度每个刻度。该周期永远不会小于113 (b-3,mod格式中的最高音符)_ or超过856 _(c-1,最低音符)范围:

// Showing only the changed methods of the Channel class
    play(note) {
        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.sampleIndex = 0;
            this.volume = this.instrument.volume;
            this.currentVolume = this.volume;
        }

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            this.currentPeriod = this.period;
        }

        this.effect(note.effect);
    }

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

        if (this.periodDelta) {
            this.currentPeriod += this.periodDelta;
            if (this.currentPeriod < 113) this.currentPeriod = 113;
            if (this.currentPeriod > 856) this.currentPeriod = 856;
        }

        const sampleRate = PAULA_FREQUENCY / this.currentPeriod;
        this.sampleSpeed = sampleRate / this.worklet.sampleRate;
    }

    effect(raw) {
        this.volumeSlide = 0;
        this.periodDelta = 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 SLIDE_UP:
                this.periodDelta = -data;
                break;

        // The rest of the effect method is unchanged

效果2滑动

既然滑动效应正在起作用,则易于实现滑动下降效果。它归结为仅更改periodDelta变量的符号,其余的已经完成:

            case SLIDE_DOWN:
                this.periodDelta = data;
                break;

效果3轴承轴承

音调portamento效应是幻灯片向上和滑动效应的组合,它利用播放的音符周期来确定幻灯片的方向。当音高达到目标音高时,效果就会停止。 Tone Portamento也是第一个实施“记住”其在行之间的参数的效果。这意味着您可以使用Note并设置慢速的Pertamento效果,例如301,然后任何连续的Portamento效果(零值)都将使用相同的速度。为了实现这一点,我需要确保发挥带有Pertamento效果的音符不会立即设置currentPeriod。相反,该周期仅由performTick方法设置。要注意的一件事是,使用此命令的音符不会从一开始就不会启动样本,而是从启动Pertamento效应的位置继续。要实现此目的,我需要延迟设置sampleIndex变量直到调用effect方法。

    constructor(worklet) {
        // Skipping unchanged lines
        this.portamentoSpeed = 0;
    }

    performTick() {
        // Skipping unchanged volume handling

        if (this.periodDelta) {
            if (this.portamento) {
                if (this.currentPeriod != this.period) {
                    // Which direction to slide?
                    const sign = Math.sign(this.period - this.currentPeriod);
                    // Don't slide past the target period
                    const distance = Math.abs(this.currentPeriod - this.period);
                    const diff = Math.min(distance, this.periodDelta);
                    this.currentPeriod += sign * diff;
                }
            }
            else {
                this.currentPeriod += this.periodDelta;
                if (this.currentPeriod < 113) this.currentPeriod = 113;
                if (this.currentPeriod > 856) this.currentPeriod = 856;
            }
        }

        const sampleRate = PAULA_FREQUENCY / this.currentPeriod;
        this.sampleSpeed = sampleRate / this.worklet.sampleRate;
    }

    play(note) {
        // Keep track of whether the sample should start from the beginning
        this.setSampleIndex = false;

        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.setSampleIndex = 0;
            this.volume = this.instrument.volume;
            this.currentVolume = this.volume;
        }

        // Keep track of whether period should change immediately
        this.setCurrentPeriod = false;

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            this.setCurrentPeriod = true;
        }

        // setSampleIndex and setCurrentPeriod could change back to false in the effect method
        this.effect(note.effect);

        if (this.setCurrentPeriod) {
            this.currentPeriod = this.period;
        }

        if (this.setSampleIndex !== false) {
            this.sampleIndex = this.setSampleIndex;
        }
    }

    effect(raw) {
        this.volumeSlide = 0;
        this.periodDelta = 0;
        this.portamento = false;

        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 SLIDE_UP:
                this.periodDelta = -data;
                break;
            case SLIDE_DOWN:
                this.periodDelta = data;
                break;
            case TONE_PORTAMENTO:
                this.portamento = true;
                if (data) this.portamentoSpeed = data;
                this.periodDelta = this.portamentoSpeed;
                this.setCurrentPeriod = false;
                this.setSampleIndex = false;
                break;

效果4颤音

颤音会影响每个刻度的音符周期。通过在当前时期添加颤音值来计算生效期。参数的上部4位是颤音速度,下部4位是颤音深度。实际上,找到有关此效果正确实现的任何好的文档实际上非常棘手,但是我找到了一个解释,可以让我开始使用非常旧的多媒体Wiki。它似乎使用64步正弦波来改变周期,而速度是每刻度正弦波中向前的步骤数。颤音效应的深度和速度在每个通道的音符之间单独记住。

    constructor(worklet) {
        // Added three variables
        this.vibratoDepth = 0;
        this.vibratoSpeed = 0;
        this.vibratoIndex = 0;
    }

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

        if (this.vibrato) {
            this.vibratoIndex = (this.vibratoIndex + this.vibratoSpeed) % 64;
            this.currentPeriod = this.period + Math.sin(this.vibratoIndex / 64 * Math.PI * 2) * this.vibratoDepth;
        }
        else if (this.periodDelta) {
            if (this.portamento) {
                if (this.currentPeriod != this.period) {
                    const sign = Math.sign(this.period - this.currentPeriod);
                    const distance = Math.abs(this.currentPeriod - this.period);
                    const diff = Math.min(distance, this.periodDelta);
                    this.currentPeriod += sign * diff;
                }
            }
            else {
                this.currentPeriod += this.periodDelta;
            }
        }

        if (this.currentPeriod < 113) this.currentPeriod = 113;
        if (this.currentPeriod > 856) this.currentPeriod = 856;

        const sampleRate = PAULA_FREQUENCY / this.currentPeriod;
        this.sampleSpeed = sampleRate / this.worklet.sampleRate;
    }

    play(note) {
        this.setSampleIndex = false;

        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.setSampleIndex = 0;
            this.volume = this.instrument.volume;
            this.currentVolume = this.volume;
        }

        this.setCurrentPeriod = false;

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            this.setCurrentPeriod = true;
        }

        this.effect(note.effect);

        if (this.setCurrentPeriod) {
            this.currentPeriod = this.period;
        }

        if (this.setSampleIndex !== false) {
            this.sampleIndex = this.setSampleIndex;
        }
    }

    effect(raw) {
        this.volumeSlide = 0;
        this.periodDelta = 0;
        this.portamento = false;
        this.vibrato = false;

        if (!raw) return;

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

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

        switch (id) {
            // Added one case
            case VIBRATO:
                const speed = data >> 4;
                const depth = data & 0x0f;
                if (speed) this.vibratoSpeed = speed;
                if (depth) this.vibratoDepth = depth;
                this.vibrato = true;
                break;

效果5 - 音portamento +音量幻灯片

这种效果是效果3和a的组合,已经实现了。 effect方法所需的唯一更改是将新案例添加到Switch语句中,并结合两个现有效果:

            case TONE_PORTAMENTO_WITH_VOLUME_SLIDE:
                this.portamento = true;
                this.setCurrentPeriod = false;
                this.setSampleIndex = false;
                this.periodDelta = this.portamentoSpeed;
                if (data & 0xf0) {
                    this.volumeSlide = data >> 4;
                }
                else if (data & 0x0f) {
                    this.volumeSlide = -(data & 0x0f);
                }
                break;

效果6 vibrato +音量滑梯

就像效果5一样,效果3和a,效果6的组合是效果4和A的组合。这意味着一切已经到位,可以实现这种效果。唯一需要做的是在频道类的效果方法中添加一个案例。

            case VIBRATO_WITH_VOLUME_SLIDE:
                this.vibrato = true;
                if (data & 0xf0) {
                    this.volumeSlide = data >> 4;
                }
                else if (data & 0x0f) {
                    this.volumeSlide = -(data & 0x0f);
                }
                break;

效果0

这种效果用于快速连续演奏三个音符来演奏和弦。第一个注释是播放的音符,第二个和第三个音符是通过将arpeggio值添加到注释中来播放的。 arpeggio值由上4位和参数的下部4位设置。每个tick,该期间都更改为和弦中的下一个音符。

    constructor(worklet) {
        // Adding one line:
        this.arpeggio = false;
    }

    performTick() {
        if (this.volumeSlide && this.worklet.tick > 0) {
            // Unchanged
        }

        if (this.vibrato) {
            // Unchanged
        }
        else if (this.periodDelta) {
            // Unchanged
        }
        else if (this.arpeggio) {
            const index = this.worklet.tick % this.arpeggio.length;
            const halfNotes = this.arpeggio[index];
            this.currentPeriod = this.period / Math.pow(2, halfNotes / 12);
        }

        if (this.currentPeriod < 113) this.currentPeriod = 113;
        if (this.currentPeriod > 856) this.currentPeriod = 856;

        const sampleRate = PAULA_FREQUENCY / this.currentPeriod;
        this.sampleSpeed = sampleRate / this.worklet.sampleRate;
    }

    effect(raw) {
        // Add one line at the beginning
        this.arpeggio = false;

        // In the switch statement:
        switch (id) {
            case ARPEGGIO:
                this.arpeggio = [0, data >> 4, data & 0x0f];
                break;

一些错误修复

现在,我选择的三首歌几乎应该完美地播放。但是,在听它们时,我意识到需要修复一些错误。第一个是,即使将仪器设置为零,便会在样本开始时从样本的开头重新启动。我通过听现象的歌曲来发现这一点。在图案30 中(歌曲位置45)频道3听起来几乎是沉默的。修复程序是将设置样本索引的代码移至另一个位置:

    play(note) {
        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.volume = this.instrument.volume;
            this.currentVolume = this.volume;
        }

        this.setSampleIndex = false;
        this.setCurrentPeriod = false;

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            this.setCurrentPeriod = true;
            this.setSampleIndex = 0;
        }

        this.effect(note.effect);

        if (this.setCurrentPeriod) {
            this.currentPeriod = this.period;
        }

        if (this.setSampleIndex !== false) {
            this.sampleIndex = this.setSampleIndex;
        }
    }

另一个错误是,音量幻灯片效果的运行太快了。在聆听Livin的精神错乱的开放时,在模式2和4 中(歌曲位置0和1) 降低了零。经过一番谷歌搜索后,我发现不应在每一行的第一滴度上运行音量幻灯片效果。因此,对于每排6个刻度的正常速度设置,音量幻灯片效果不应在tick 0上运行,而应在1、2、3、4和5上运行。

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

结论

目前达到目标的仅有的两个效果是Retigger Note和Delay Note,我将在下一篇文章中实现这些效果。这些效果在《谜》中没有使用,因此现在实际上扮演了正确的效果。到目前为止,这是一个有趣的挑战,我对Mod格式的内部运作有了很多了解。

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

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