播放一首歌,几乎
#javascript #webaudio #retrocomputing

在上一篇文章中,我描述了如何从mod文件中提取原始示例数据以及如何播放它。下一步是将歌曲数据从mod文件中输出并进入更有用的数据结构。

根据我发现(see the previous article)的来源,歌曲数据是使用“模式” 1024个字节存储的。图案是4个通道中的64个音符的序列,每个音符是条形杆的1/16。原始mod文件包含四个通道,这意味着可以同时播放四个样本或仪器。每个音符是4个字节,每一行包含4个音符,每个图案包含64行,每个模式的1024个字节。

对于每个音符,仪器索引,周期(稍后解释)和效果(也稍后解释)被存储。仪器索引存储在字节0的前4位,并存储了字节2的前4位。该周期存储在字节0的最后4位和字节1的所有8位。效果存储在最后4位字节2和所有8位字节3的位。使用此信息,我可以编写三个新类来存储歌曲数据,PatternRowNote

class Note {
    constructor(noteData) {
        this.instrument = (noteData[0] & 0xf0) | (noteData[2] >> 4);
        this.period = (noteData[0] & 0x0f) * 256 + noteData[1];
        this.effect = (noteData[2] & 0x0f) * 256 + noteData[3];
    }
}

class Row {
    constructor(rowData) {
        this.notes = [];

        // Each note is 4 bytes
        for (let i = 0; i < 16; i += 4) {
            const noteData = rowData.slice(i, i + 4);
            this.notes.push(new Note(noteData));
        }
    }
}

class Pattern {
    constructor(modfile, index) {
        // Each pattern is 1024 bytes long
        const data = new Uint8Array(modfile, 1084 + index * 1024, 1024);
        this.rows = [];

        // Each pattern is made up of 64 rows
        for (let i = 0; i < 64; ++i) {
            const rowData = data.slice(i * 16, i * 16 + 16);
            this.rows.push(new Row(rowData));
        }
    }
}

还必须更新Mod类的构造函数,以通过调用每个模式的Pattern构造函数读取歌曲数据。

export class Mod {
    constructor(modfile) {
        // Store the pattern table
        this.patternTable = new Uint8Array(modfile, 952, 128);

        // Find the highest pattern number
        const maxPatternIndex = Math.max(...this.patternTable);

        // Extract all instruments
        this.instruments = [];
        let sampleStart = 1084 + (maxPatternIndex + 1) * 1024;
        for (let i = 0; i < 31; ++i) {
            const instr = new Instrument(modfile, i, sampleStart);
            this.instruments.push(instr);
            sampleStart += instr.length;
        }

        // Extract the pattern data
        this.patterns = [];
        for (let i = 0; i <= maxPatternIndex; ++i) {
            const pattern = new Pattern(modfile, i);
            this.patterns.push(pattern);
        }
    }
}

播放一种模式的频道

现在,歌曲数据以更有用的格式存储,现在该播放它了。第一步是播放一个模式的一个频道。播放是由Worklet处理器类的process方法驱动的。对于每次播放的样本,根据音频上下文的采样率,时间将在一秒钟内向前移动1/48 000或1/44。因此,我们可以将样品用作时间源,并基于样本的进展。

音符的periodAmiga Paula co-processor的时钟脉冲数量,该脉冲的数量是主CPU时钟的一半。在PAL Amiga上,主CPU时钟频率为7 093â789.2Hz,因此,协作处理器频率为3-546 894.6 Hz。播放214 的样本(注释C-3的周期)的播放将以3-546的894.6/214 = 16-574.3 Hz播放。对于样本速率为48 000 Hz的音频上下文,这意味着每个样本将在输出下一个样品之前输出48亿/16 574.3 = 2.896次。这些计算必须每次更改时进行。

要向前迈进,我还需要跟踪歌曲中的当前位置,当前的行播放以及在移到下一行之前还可以播放多少个样本。目前,使用BPM为125和音频上下文的采样率计算每行样本数量。

const PAULA_FREQUENCY = 3546894.6;

class PlayerWorklet extends AudioWorkletProcessor {
    constructor() {
        super();
        this.port.onmessage = this.onmessage.bind(this);
        this.mod = null;
    }

    onmessage(e) {
        if (e.data.type === 'play') {
            this.mod = e.data.mod;
            this.sampleRate = e.data.sampleRate;

            this.bpm = 125;
            this.outputsPerRow = this.sampleRate * 60 / this.bpm / 4;

            // Start at the last row of the pattern "before the first pattern"
            this.position = -1;
            this.rowIndex = 63;

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

            this.instrument = null;
            this.period = null;
        }
    }

    nextRow() {
        ++this.rowIndex;
        if (this.rowIndex == 64) {
            this.rowIndex = 0;
            ++this.position;
        }

        const patternIndex = this.mod.patternTable[this.position];
        const pattern = this.mod.patterns[patternIndex];
        const row = pattern.rows[this.rowIndex];
        const note = row.notes[0];

        if (note.instrument) {
            this.instrument = this.mod.instruments[note.instrument - 1];
            this.sampleIndex = 0;
        }

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

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

        if (this.outputsUntilNextRow <= 0) {
            this.nextRow();
            this.outputsUntilNextRow += this.outputsPerRow;
        }

        this.outputsUntilNextRow--;

        if (!this.instrument || !this.period) return 0.0;
        if (!this.period) return 0.0;

        if (this.sampleIndex >= this.instrument.bytes.length) {
            return 0.0;
        }

        const sample = this.instrument.bytes[this.sampleIndex | 0];
        const sampleRate = PAULA_FREQUENCY / this.period;
        this.sampleIndex += sampleRate / this.sampleRate;

        return sample / 256.0;
    }

    process(inputs, outputs) {
        const output = outputs[0];
        const channel = output[0];

        for (let i = 0; i < channel.length; ++i) {
            const value = this.nextOutput();
            channel[i] = value;
        }

        return true;
    }
}

registerProcessor('player-worklet', PlayerWorklet);

最终播放歌曲的第一个频道,我需要将完整的mod数据发送到工作场所,而不仅仅是一种乐器。我还需要发送音频上下文的采样率,以便工作点可以正确计算所有时间。这是主线程代​​码的更改:

// Load Elekfunk from api.modarchive.org
const url = 'https://api.modarchive.org/downloads.php?moduleid=41529';
const mod = await loadMod(url);

// Play a sample when the user clicks
window.addEventListener('click', () => {
    audio.resume();
    player.port.postMessage({
        type: 'play',
        mod: mod,
        sampleRate: audio.sampleRate
    });
});

这些更改后,当您单击“浏览器”窗口时,歌曲的第一个频道开始播放。但是,时间安排有点错,有些音符的播放也不正确。这是因为尚未实施效果。 Elekfunk歌曲使用当前未完成的“设定速度”效果来加快播放的速度,这就是为什么时机是错误的。另外,要真正欣赏这首歌,所有四个频道都需要混合在一起。我在这篇文章中所做的最后更改是创建一个四通道混合器,并实现“设置速度”效果。

混合多个通道

要将歌曲的四个通道混合在一起,我需要跟踪每个频道的仪器和周期。每个通道都需要具有自己的样本索引,并独立于其他渠道中向前移动。我创建一个Channel类来跟踪此信息,然后在工作点中创建了此类的四个实例。现在,nextOutput函数在所有四个通道上循环,并将每个通道的输出添加在一起。为了将输出限制为-1.0至1.0的范围,我使用Math.tanh函数,这是双曲线切线函数。它具有一个不错的属性,即输出始终在-1.0和1.0之间,并且输出也缩放到输入。

class Channel {
    constructor(worklet) {
        this.worklet = worklet;
        this.instrument = null;
        this.period = 0;
        this.sampleSpeed = 0.0;
        this.sampleIndex = 0;
    }

    nextOutput() {
        if (!this.instrument || !this.period) return 0.0;
        const sample = this.instrument.bytes[this.sampleIndex | 0];

        this.sampleIndex += this.sampleSpeed;
        if (this.sampleIndex >= this.instrument.length) {
            return 0.0;
        }

        return sample / 256.0;
    }

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

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            const sampleRate = PAULA_FREQUENCY / this.period;
            this.sampleSpeed = sampleRate / this.worklet.sampleRate;
        }
    }
}
class PlayerWorklet extends AudioWorkletProcessor {
    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) ];
    }

    onmessage(e) {
        if (e.data.type === 'play') {
            this.mod = e.data.mod;
            this.sampleRate = e.data.sampleRate;

            this.bpm = 125;
            this.outputsPerRow = this.sampleRate * 60 / this.bpm / 4;

            // Start at the last row of the pattern "before the first pattern"
            this.position = -1;
            this.rowIndex = 63;

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

    nextRow() {
        ++this.rowIndex;
        if (this.rowIndex == 64) {
            this.rowIndex = 0;
            ++this.position;
        }

        const patternIndex = this.mod.patternTable[this.position];
        const pattern = this.mod.patterns[patternIndex];
        const row = pattern.rows[this.rowIndex];

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

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

        if (this.outputsUntilNextRow <= 0) {
        if (this.outputsUntilNextRow <= 0) {
            this.nextRow();
        }

        this.outputsUntilNextRow--;

        const rawOutput = this.channels.reduce((acc, channel) => acc + channel.nextOutput(), 0.0);
        return Math.tanh(rawOutput);
    }

    process(inputs, outputs) {
        const output = outputs[0];
        const channel = output[0];

        for (let i = 0; i < channel.length; ++i) {
            const value = this.nextOutput();
            channel[i] = value;
        }

        return true;
    }
}

添加第一个效果

我要实现的第一个效果是“设置速度”效果。这种效果可以通过两种不同的方式使用。如果该值小于32,则将设置每行“ ticks”的数量。如果该值大于或等于32,则将设置每分钟节拍的数量。 “ tick”是mod格式的概念,通常是行的1/6。大多数旧的mod文件永远不会更改BPM,而是更改每行的刻度数量以加快或减慢歌曲的速度。许多效果根据每行的刻度数量执行计算,因此实施此效果很重要。首先,我将在Channel类中添加effect方法,我可以用它来实现所有效果。我还将在PlayerWorklet类中添加一个setTicksPerRow方法和一个setBpm方法,我可以用它来更新每行的刻度数量。

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

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            const sampleRate = PAULA_FREQUENCY / this.period;
            this.sampleSpeed = sampleRate / this.worklet.sampleRate;
        }

        if (note.effect) {
            this.effect(note.effect);
        }
    }

    effect(raw) {
        const id = raw >> 8;
        const data = raw & 0xff;
        if (id == 0x0f) {
            if (data >= 1 && data <= 31) {
                this.worklet.setTicksPerRow(data)
            }
            else {
                this.worklet.setBpm(data);
            }
        }
    }
    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 row of the pattern "before the first pattern"
            this.position = -1;
            this.rowIndex = 63;

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

    setTicksPerRow(ticksPerRow) {
        this.ticksPerRow = ticksPerRow;
        this.outputsPerRow = this.sampleRate * 60 / this.bpm / 4 * this.ticksPerRow / 6;
    }

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

结论

通过这些更改,玩家现在可以从Arte Demo中呈现出Moby的Elekfunk的声音有些不错的版本。乐器播放正确,歌曲以正确的速度播放,但是仍然缺少很多效果。这些将是本系列的下几个部分的主题。

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

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