go-fmml
An asynchronous FM + PCM sound synthesis engine in Go (Golang), built for embedding game BGM.
https://github.com/megaak-soft/go-fmml
What is this?
go-fmml combines an original FM synthesis core inspired by the design philosophy of classic YAMAHA FM chips with a PCM core that plays back WAV samples, and lets you author and play sequence data using an original MML (Music Macro Language) text format, as a Go library. It is designed to embed directly into a game project and play BGM asynchronously (never blocking the caller).
This is not chip emulation and not a synthesis-accuracy reproduction project - it is an original implementation focused on "sounding like FM synthesis, letting you write BGM in MML, and being easy to embed in a game." It is not a port of, or derived from, any specific hardware's register map, emulator, or existing open-source implementation.
This project was built with Claude
Code, using a "Spec-Driven Development" approach: the engine spec, MML spec, and phase plan were
written out in detail in a specification document (CLAUDE.md), and the AI implemented
the code from it.
AI-generated code carries a known risk of unintentional "license contamination" - copyleft (e.g. GPL) code patterns leaking in from the model's training data. go-fmml's specification document defines explicit rules to avoid this kind of contamination, and the AI is required to self-check against those rules on every code generation pass. go-fmml's source code itself is provided under the MIT License, and can be used freely, including for commercial use, redistribution, and embedding into games. See the License page for details.
What it can do (feature list)
4 operators × 8 algorithms, 8 selectable basic waveforms (W1-W8), an LFO (vibrato), up to 16 parts, up to 16-voice polyphony per part.
WAV sample playback. Two types: drum-kit style
oneShot (up to 16 samples per part) and pitch-shiftable long
(loop/envelope/LFO support), up to 16 parts.
Plays up to 32 parts (FM + PCM combined) in sample-accurate sync using go-fmml's own MML spec, at a timing resolution of 1/192 of a 4/4 measure.
A stereo reverb selectable between a Schroeder type (lightweight) and an FDN type (higher quality), with per-part and per-oneShot-note send levels.
Portamento, pitch-bend up/down, part mute/solo, transpose, and a conductor part (tempo changes, loop jump points).
Play/Pause/Resume/Stop/Rewind/SkipPlay, master volume (instant or smooth), fade-in/fade-out/fade-in-and-out playback.
File-based loading (absolute path or embed.FS),
plus passing MML/FM voice/PCM voice data directly as Go string literals.
The bundled Smf2GoFMML tool reads a
Standard MIDI File (Format 1) and converts it to go-fmml's MML format.
Requirements
| Item | Requirement |
|---|---|
| Go | 1.24.0 or later (matches the minimum required by ebitengine/oto/v3,
which go-fmml depends on) |
| Supported OS | Windows / macOS / Linux / Android / iOS / WebAssembly (matches the
platforms supported by ebitengine/oto/v3, used for audio output) |
Try the demo
A minimal working example that actually plays sound is included at
cmd/mmldemo. It demonstrates the full flow from loading FM voice/MML files to
playback.
cd cmd/mmldemo
go run .
Performance characteristics
Audio rendering is sample-by-sample software synthesis. A few things worth knowing:
- The theoretical worst case is up to 16 voices per part across all 16 FM parts + 16 PCM parts (32 parts total) sounding simultaneously. In practice, real songs rarely have every part and every voice sounding at once, so typical use (a handful to a dozen parts, a few simultaneous voices per part) keeps the audio callback's CPU load light.
- Only one reverb instance ever exists (a single unit on the master bus). The Schroeder type uses
a small number of delay lines and allpass filters; the FDN type uses a feedback delay network and
is somewhat more expensive to compute than the Schroeder type. When reverb is disabled
(
reverb: false) it is fully bypassed and costs nothing. Reverb's internal math usesfloat32to keep it lightweight. - PCM waveforms are held in memory as decoded
float32sample arrays. The same WAV file is never decoded twice - once loaded, its waveform data is cached and reused (shared acrossLoadPCMVoiceFile/LoadPCMVoiceFileFS/SetPCMVoiceData). - Memory allocation mainly happens when registering voices or loading a sequence (i.e. when switching songs); the note-rendering path itself is designed to avoid new allocations on the hot path.
Relationship to ebitengine/oto/v3
Audio output uses the
Ebitengine project's github.com/ebitengine/oto/v3. go-fmml's
fmcore.NewEngine internally initializes an oto audio context, and the resulting
*fmcore.Engine itself implements the io.Reader
(Read([]byte) (int, error)) that oto's oto.Player expects, so it can be
handed directly to an oto player.
For apps that already manage their own oto context (for example, one that is also doing other audio
playback through Ebitengine at the same time), fmcore.NewEngineWithoutOutput is also
provided: go-fmml does not open an audio device itself, and your app keeps owning the oto context.
See the code sample below.
go-fmml depends on a few OSS libraries internally (including indirect dependencies). Copyright in each library belongs to its own authors, and each library's own license terms apply independently of go-fmml's MIT license. The full dependency list and go-fmml's own usage terms (MIT License) are collected on the License page.
Code samples (Go)
Basic: go-fmml manages audio output itself
package main
import (
"log"
"github.com/megaak-soft/go-fmml/fileio"
"github.com/megaak-soft/go-fmml/fmcore"
"github.com/megaak-soft/go-fmml/player"
)
func main() {
if err := fileio.LoadFMVoiceFile("voice_fm.yaml"); err != nil {
log.Fatal(err)
}
if err := fileio.LoadPCMVoiceFile("voice_pcm.yaml"); err != nil {
log.Fatal(err)
}
if err := fileio.LoadMMLFile("bgm1.mml"); err != nil {
log.Fatal(err)
}
// Opens an audio device at 44.1kHz and initializes an oto/v3 context internally.
engine, err := fmcore.NewEngine(44100)
if err != nil {
log.Fatal(err)
}
defer engine.Close()
onComplete := func() { log.Println("playback finished") }
if err := player.Play(engine, "bgm1", onComplete); err != nil {
log.Fatal(err)
}
select {} // replace with your app's own wait/game loop
}
Sharing Ebitengine's own oto/v3 context
package main
import (
"github.com/ebitengine/oto/v3"
"github.com/megaak-soft/go-fmml/fileio"
"github.com/megaak-soft/go-fmml/fmcore"
"github.com/megaak-soft/go-fmml/player"
)
func setupBGM(otoCtx *oto.Context) (*fmcore.Engine, error) {
if err := fileio.LoadFMVoiceFile("voice_fm.yaml"); err != nil {
return nil, err
}
if err := fileio.LoadMMLFile("bgm1.mml"); err != nil {
return nil, err
}
// Doesn't open an audio device - go-fmml is used purely as an io.Reader.
engine := fmcore.NewEngineWithoutOutput(44100)
// Registers go-fmml's engine as a player on your existing oto context.
p := otoCtx.NewPlayer(engine)
p.Play()
return engine, player.Play(engine, "bgm1", nil)
}
Loading MML/voice data directly from a string literal (Phase 7)
const mml = `
[GoFMML File]
[global]
tempo: 128
sequenceID: demo
loop: true
[partSetting]
part:
- partNo: 1
voiceID: 0
volume: 100
pan: 0
[part1]
O4 L4 C D E F G A B O5 C
[part1End]
`
fileio.SetMMLTextData(mml)
For the detailed syntax and valid ranges of each parameter, see the spec pages linked in the left navigation.
About the Author
MEGAAK SOFT is an indie solo game development project, making retro-style 2D games.