在上一篇文章中,我描述了如何从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的位。使用此信息,我可以编写三个新类来存储歌曲数据,Pattern
,Row
和Note
。
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。因此,我们可以将样品用作时间源,并基于样本的进展。
音符的period
是Amiga 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中可用。