🌊

lightmixという音声処理ライブラリを書いた

に公開

正確には、[]const f32を編集してWaveファイルに保存できるライブラリ、と言ったら良いのかも。

とりあえずGitHubへのリンクを貼る

https://github.com/haruki7049/lightmix

ここです。

改善案やプルリクエストはGitHubに投げてくれると助かります。単なる質問も、GitHub Discussionsを有効化しているので、そこに投稿してくれたら返信します。

Zigのバージョン

0.14.1を使っています。最新の安定版に追従するつもりです。

それ、どんなライブラリ?

音源は波形データでできているというのは周知の事実だと思うのですが(FM音源等は除く。ここでいう音源はPCM音源の事)、その波形データをZig言語内で処理してWaveファイルを生成しよう、というライブラリです。例としてコードを書くと、以下のようなことを書けるライブラリになります。

const std = @import("std");
const lightmix = @import("lightmix");

pub fn main() !void {
    // 適当なアロケータを使う
    const allocator = std.heap.page_allocator;

    // 配列の長さが3なので、3サンプルの波形データになる
    const wave_data: []const f32 = &[_]f32{ 0.0, 0.0, 0.0 };

    // lightmix.Waveを格納する
    const wave: lightmix.Wave = lightmix.Wave.init(wave_data, allocator, .{
        .sample_rate = 44100,
        .channels = 1,
        .bits = 16,
    });
    defer wave.deinit();

    // ファイルを作る
    var file = try std.fs.cwd().createFile("result.wav", .{});
    defer file.close();

    // ファイルに書き込む
    try wave.write(file);
}

どんな風にサイン波とか鳴らすの?

ちょっと長くなりますが、大体こんな感じです。

サイン波生成のコード
const std = @import("std");
const lightmix = @import("lightmix");

pub fn main() !void {
    // 適当なアロケータを使う
    const allocator = std.heap.page_allocator;

    // Waveを作る
    const wave: lightmix.Wave = generate(allocator, .{
        .length = 44100,
        .frequency = 440.0,
        .amplitude = 1.0,

        .sample_rate = 44100,
        .channels = 1,
        .bits = 16,
    });
    defer wave.deinit();

    // ファイルを作る
    var file = try std.fs.cwd().createFile("result.wav", .{});
    defer file.close();

    // ファイルに書き込む
    try wave.write(file);
}

/// サイン波用のWave生成関数
pub fn generate(allocator: std.mem.Allocator, options: Options) Wave {
    const sample_rate: f32 = @floatFromInt(options.sample_rate);
    const base_data: []const f32 = generate_data(options.frequency, options.amplitude, options.length, sample_rate, allocator);
    defer allocator.free(base_data);

    const result: Wave = Wave.init(base_data, allocator, .{
        .sample_rate = options.sample_rate,
        .channels = options.channels,
        .bits = options.bits,
    });

    return result;
}

const Options = struct {
    length: usize,
    frequency: f32,
    amplitude: f32,

    sample_rate: usize,
    channels: usize,
    bits: usize,
};

/// サイン波のデータ生成関数
fn generate_data(frequency: f32, amplitude: f32, length: usize, sample_rate: f32, allocator: std.mem.Allocator) []const f32 {
    const radians_per_sec: f32 = frequency * 2.0 * std.math.pi;

    var result = std.ArrayList(f32).init(allocator);
    defer result.deinit();

    for (0..length) |i| {
        const v: f32 = std.math.sin(@as(f32, @floatFromInt(i)) * radians_per_sec / sample_rate) * amplitude;
        result.append(v) catch @panic("Out of memory");
    }

    return result.toOwnedSlice() catch @panic("Out of memory");
}

要は、波形([]const f32)を生成すれば作れるわけです。

音名って使えないの?

ここでいう音名とは、2オクターブのドとか、4オクターブのファとかいう感じの、それが決まると音の高さが決まるというものです。名前を間違ってたら優しく指摘してくださると助かります。私の心はナイーブなんです…。

音楽の世界には音律というものが複数あるらしく、lightmixの中で決めてしまうと、例えば十二平均律しか使えないということになってしまいます。これはいけない!! そのために、各々ユーザーが、それぞれの音楽を作るときに、一回一回音律を定義するということにしています。

とは言っても、十二平均律(現代音楽の殆どで使われている音律。1オクターブを12個に等分割します)しか使わない人にとっては、毎回書くのも面倒でしょう。なので、十二平均律に関しては、私が書きました。代わりに書いてくれてありがとう、ChatGPT…。

scale.zig
scale.zig
//! 12 equal temperament

const std = @import("std");
const testing = std.testing;

const Self = @This();

code: Code,
octave: usize,

pub fn add(self: Self, semitones: isize) Self {
    const self_midi_number: isize = @intCast(12 * (self.octave + 1) + @intFromEnum(self.code));
    const result_midi_number: isize = self_midi_number + semitones;

    const result_code: Code = @enumFromInt(@as(u8, @intCast(@mod(result_midi_number, 12))));
    const result_octave: usize = @intCast(@divTrunc(result_midi_number, 12) - 1);

    return Self{
        .code = result_code,
        .octave = result_octave,
    };
}

pub fn generate_freq(scale: Self) f32 {
    const midi_number: isize = @intCast(12 * (scale.octave + 1) + @intFromEnum(scale.code));
    const exp: f32 = @floatFromInt(midi_number - 69);
    const result: f32 = 440.0 * std.math.pow(f32, 2.0, exp / 12.0);
    return result;
}

/// Codes written by English.
/// The `~s` code means the tone with sharp.
pub const Code = enum(u8) {
    c = 0,
    cs = 1,
    d = 2,
    ds = 3,
    e = 4,
    f = 5,
    fs = 6,
    g = 7,
    gs = 8,
    a = 9,
    as = 10,
    b = 11,
};

このscale.zigを使うと、必要な周波数が音名で書けるようになります。こんな感じに。

const Scale = @import("./scale.zig");
const frequency: f32 = Scale.generate_freq(.{ .code = .c, .octave = 4 });

もしかしてBPMって概念も無い?

はい、ありません。ちょっと理由は違いますが、BPMもありません。

これの理由なんですけど、このライブラリの本質を考えてみればすぐに分かります。このライブラリは、浮動小数点数による配列をZig言語内で編集し、それをWaveファイルに出力するライブラリです。それすなわち、音楽専用のライブラリではないんですよね。

まぁまぁ、ちょっと待ってください。BPMを計算することは可能です。なので、ブラウザバックしないで…!

正確に言うと、BPMを直接扱う訳ではなく、BPMが120だとしたら、どのくらいのサンプル数が一拍辺り必要となるかを計算するんです。

const sample_rate: usize = 44100; // 一秒辺り44100サンプル
const bpm: usize = 120; // 一分辺り120拍

const samples_per_beat: usize = @intFromFloat(@as(f32, @floatFromInt(60)) / @as(f32, @floatFromInt(bpm)) * @as(f32, @floatFromInt(sample_rate)));

これで、一拍辺りのサンプル数が取れました!

何で音名やBPMを実装しないか

それは、とにかくシンプル且つパワフルなライブラリにしたかったからです。

音名を実装したら、周波数という概念を使わずに済むでしょう。BPMを実装したら、音楽を作りやすくなるでしょう。しかし、それらはイージーであり、シンプルではないのです。

もしも音名を実装したならば、ユーザーが十二平均律以外を使いたいとなったらどうなるでしょうか。もしもBPMを実装したならば、ユーザーが効果音を作りたいとなったらどうなるでしょうか。きっとそれらには応えられないでしょう。

使い始めたけど、どう書いたら良いかわからなくなった

私にメッセージを飛ばしてください!! 現在の使用者は私だけだと思うので、多分私に聞くのが最速だと思います。この記事のコメントでも良いですが、GitHub Discussionsを開いているので、そちらに投稿されると私が見やすいかも。

そもそもどう書いたら良いかわからない。ドキュメントどこ?

ごめんなさい、今ちょうどドキュメントを整備していっているところです…。GitHub Discussionsなどに書き込んでくだされば、チェックします!

これから実装する点

  1. ステレオ音源を扱えるようにする。
  2. Synth型を作る…かなぁ? github.com/haruki7049/lightmix PR#3
  3. Wave.filter()に引数を受け入れられるようにする。

さいごに

このライブラリを実際に自分で使ったときに、「あぁ私、汎用プログラミング言語で音声処理してる…」ってなりました。まだまだ発展途上のライブラリですが、少しでも気になってくれたらスターを付けてくれると、私のモチベーションアップになります!! 読んでいただきありがとうございました!!

Discussion