Skip to content

Chiptune

An in-editor chiptune tracker and retro SFX generator for Godot 4.7, in pure GDScript. One synth engine drives both the SFX and Tracker tabs.

Output is standard Godot audio. A sound or a song exports to .wav, and nothing in a shipped game depends on this plugin.

Or skip the export. Easiest is the node: Add Child Node and type “chiptune”. There are two kinds.

  • A sound. ChiptunePlayer, or ChiptunePlayer2D and ChiptunePlayer3D for audio positioned in a scene. One property to set, document, whose picker lists your .sfx.tres files.
  • Music. ChiptuneMusicPlayer takes a .music.tres and has a loop, on by default: the last bar runs into the first with no pause, because what is rendered is the song played round until the audio stops changing, so every bar carries the tail of the bar before it. Turn loop off for a one-shot — a jingle or a stinger — and it plays the song once, last note and all, then emits finished. Music is not positional, so there is no 2D or 3D version.

Either way the node builds and keeps the stream for you. A song on a sound player still plays, once through, and says in the scene tree where the loop lives.

What it costs. A node renders its document when the scene loads, on the main thread. A sound is milliseconds. A song is a stall of some hundreds of milliseconds, and a looping song is rendered until the loop settles, which for a song whose last note dies inside one pass is twice that. For long music in a shipped game, export a .wav: Godot’s importer compresses it and it loads instead of rendering.

The resource underneath is ChiptuneAudioStream. It holds a .sfx.tres or .music.tres and renders it, so editing the document updates every node playing it. It is an ordinary AudioStream and drops into any node with a stream, ours or not. Four ways to get one:

  • From the document. With a sound or song open and saved, Add a ChiptunePlayer to the scene in the File menu drops a ChiptunePlayer, or a ChiptuneMusicPlayer for a song, into the open scene.
  • From a node playing an export. Select a player whose stream is a .wav this addon exported and press Sync to the document in the inspector.
  • Back out again. Unsync on that node returns it to the .wav, so it stops needing this addon at runtime. The .wav has to exist.
  • By hand. The node’s stream property, New › ChiptuneAudioStream, then set its document.

Each is one editor undo step.

Changing a sound or a song while the game runs

A document is an ordinary resource, so game code can change one. Nothing watches it, so ask the node for its audio again with rerender(). It is on ChiptunePlayer, ChiptunePlayer2D, ChiptunePlayer3D and ChiptuneMusicPlayer, renders into the stream the node already holds, and keeps the music node’s loop.

Calling it on a node that is already playing is allowed. The node is stopped, rendered, and started again where it had got to, or at the end of the new audio if that is sooner. There is a seam where the sound changes, because the sound changed.

Vary a sound:

var laser := $Laser as ChiptunePlayer
var spec := laser.document as ChiptuneSfxSpec
spec.pitch += 3
laser.rerender()
laser.play()

ChiptuneSfx.generate(action, seed) makes a new sound for an action, Tick, Confirm, Deny, Pickup, Jump, Hit, Boom, Swell, Warn or Drop, the picker on the SFX tab: the action sets the shape and the seed picks the rest. ChiptuneSfx.generate_feel(feel, seed) makes one with a feel, Bright, Calm, Tense, Sad, Fast or Big, the way the tracker’s Generated instruments are made; a feel’s name given to generate still answers. ChiptuneSfx.mutate(spec, seed, amount) hands back a copy of a sound with its parameters nudged, and ChiptuneSfx.generate_free(seed) makes one from anywhere in the range. All of them answer a new ChiptuneSfxSpec and leave the one they were given alone, so handing one to a node is an assignment and needs no rerender(): setting document renders it.

laser.document = ChiptuneSfx.mutate(spec, randi(), 0.2)

rerender() is for the other way round — a field changed on the document the node already holds, as above. That document is the loaded resource, so every node holding the same .sfx.tres is holding the same object and hears the change on its own next rerender(). Give one node a sound of its own with spec.duplicate().

What that costs. One render of a short sound is most of a frame. So for variety on every shot, set pitch_scale on the node instead, which is free. A re-render is for a sound whose parameters have actually changed.

Change the music:

var music := $Music as ChiptuneMusicPlayer
var song := music.document as ChiptuneSong
song.bpm = 90.0
song.mixer.set_muted(2, true)
music.rerender()

What that costs. A song is hundreds of milliseconds per few seconds of audio, and a looping render pays that again for each pass the loop takes to settle. That is a stall rather than a hitch: re-render music where the game can afford one, at a level change or a pause, and never mid-bar. Changing music that is already playing without a gap wants streaming playback, which this addon does not do yet.

A save reaches the running game. Run the game from the editor, edit a sound in the SFX tab, Ctrl+S: every node whose stream holds that .sfx.tres re-renders it, and the next shot plays the new sound. A .sfx.tres that changes on disk some other way reaches the game once the editor has noticed the file. Songs do not yet: a .music.tres saved while the game runs is played at the next run, and the game’s log says so. It rides the debugger connection the editor already has to the game, so an exported build carries the listener and nothing arrives. To turn it off, the project setting Jamchip ▸ Live ▸ Push To Game.

Where a sound or a song lives

Each tab holds one document. Its name is the chip on the shell strip; click it for the File menu. A document never saved reads Untitled, and unsaved changes show a seed in the warning colour on the chip. The .sfx.tres or .music.tres suffix is left off; the full path is in the tooltip. Help, last on either strip, lists the tab’s keys, opens this README, and shows About JamChip with the version installed. In the FileSystem dock a sound shows the shape of its first two seconds and a song shows its order as a row of blocks, one colour per pattern.

New and Open ask before replacing unsaved work. Closing the dock only hides the tabs. Quitting the editor names each unsaved sound and song in Godot’s save-before-quit dialog, and Save All writes them. A document never saved anywhere is named but left alone.

Unsaved work is also written to a recovery file every thirty seconds, or as often as the Saving page of Preferences says, under user://jamchip/autosave/: sfx.sfx.res for the SFX tab, tracker.music.res for the Tracker, beside the Pixel tab’s. If the editor is killed, the next start lists every tab with work waiting in one dialog, by name and time, a row per tab. Recover puts that document back as unsaved work on the file it belongs to, or as Untitled when that file is gone; Discard removes that file; Later leaves the rest for next time, and that tab’s autosave waits until it has been answered. Saving, or discarding a document by choice, removes the file. Nothing under res:// is touched.

Editing in the tracker

A new song opens on an empty grid, and two menus give it a start. Generate loop, under the Song menu, writes a four-channel loop in the feel you pick: Bright, Calm, Tense, Sad, Fast or Big. You get two patterns played A A B A, with a lead, a bass, a chord played one note at a time, and drums on six instruments it adds. It sets the song’s key, scale and tempo, plays once through and lands selected, so change what you like. Generate kit, under the Instruments section’s menu, sets the instruments, the key and the tempo and leaves the notes to you. Both list the same six feels. A loop or a kit is one undo step, and pressing again replaces.

Shift with the arrows, Home, End, PageUp and PageDown selects whole cells from where the cursor was; Ctrl+A takes the whole pattern and Esc drops it. The Edit menu holds cut, copy, paste, delete and transpose, each named with its key. Paste lands with its top left at the cursor, writes every cell of the clip including the gaps, and drops whatever falls past the last row or channel; the pasted block is left selected. Ctrl+Up and Ctrl+Down move the selected notes a semitone, and with Shift an octave; a note that would leave the range stays put. The clipboard is the editor’s own, not the system one, and each operation is one undo step. Entries grey out when they cannot apply.

Grid and Roll in the bar are two views of the pattern on screen. Roll is a piano roll: time runs across, pitch runs down, and it shows one channel at a time. Click a channel’s name in the header to switch. Click an empty spot to place a note there, one row long, with the instrument selected in the rack; drag right and it grows, the note-off (===) that ends it moving with the pointer, up to the next note. Click a note to select it: the bar shows its Ins and Vol to edit, where Oct and Step sit for the grid, and Delete removes it. With nothing selected, Ins and Vol are what the next note takes. Grid after Roll shows what you just wrote, and each edit is one undo step.

The rack’s Key section, folded to start, names the key the song is in and a scale over it. With a key set, every note in the grid outside that scale is drawn in the warning colour on a band of it, so a stray reads before it plays; the note is still entered, played, named and transposed the same, and a transpose tints against the same key. In the roll, the pitch rows outside the scale are shaded darker across the lane, the key’s own rows are lined and named in the brighter ink, and a note off the key is a block in the warning colour. Off by default: a song with no key, which is every song until you pick one, looks as it always has. Seven scales, the modes of the major scale from Major to Locrian; the key is saved with the song and setting it is one undo step.

Playing your own resources

A stream asks its document to render rather than asking what class it is, so any Resource with these methods plays through one:

func render(sample_rate: int, stereo: bool) -> PackedFloat32Array
func prefers_stereo() -> bool # optional; absent means mono
func render_loop(sample_rate: int, stereo: bool) -> PackedFloat32Array
# optional; absent means a loop runs the
# whole buffer

Install

Chiptune is one module of JamChip and does not install alone. Copy addons/jamchip/ into your project and enable JamChip under Project › Project Settings › Plugins.

Layout

  • model/: the spec, the song and its patterns, the two stores, the recovery file, the instrument, its palette, the envelope, the presets and the mixer. What a document is made of and what saves it. No editor dependencies.
  • synth/: oscillators, voice, note events, the three renderers and the stream feed. The maths over a document.
  • format/: the WAV writer.
  • tracker/: the tracker panel, its strip, bar, grid, piano roll, arrangement lane, rack and mixer.
  • sfx/: the SFX panel and its waveform stage.
  • host/: what both panels share: the instrument editor, the document chip, the scene link, the shortcuts dialog, the FileSystem previews, and the inspector button the plugin adds for both.
  • runtime/: ChiptuneAudioStream, the three sound players and the music player. What a game opens; an export carries the whole of addons/jamchip/.
  • icons/: the node and resource icons.
  • tests/: the headless suite. Not shipped.

JamChip 0.1.0, by GlitterSnack. MIT licence. Built from the source on every merge, artwork and screenshots included, with JamChip.