Skip to content

Chiptune

Make sound effects for a Godot 4.7 game without leaving the editor, in pure GDScript. One synth drives the SFX tab and the Tracker’s songs.

Output is standard Godot audio. Export in a tab’s File menu, the document’s name on the strip, opens one dialog: pick WAV, then where it goes, and a sound or a song is written as a .wav, which plays in a game with no plugin at all. A song exported from the Tracker into the project loops when Godot imports it, from the end of its last row back to the top, or back to its loop point when it has one. In a browser the last row is Download as, and Sound file or Song file sends the saved document itself.

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

  • A sound. ChiptuneSfxPlayer takes a .sfx.tres, and ChiptuneSfxPlayer2D is the same positioned in 2D. One property to set, document, whose picker lists your sounds. For 3D, put a ChiptuneAudioStream on Godot’s own AudioStreamPlayer3D.
  • Music. ChiptuneSongPlayer takes a .music.tres, a Tracker song, and ChiptuneSongPlayer2D is the same positioned in 2D. Each has a loop, which is the song’s own loop until you set it on the node. On, the last bar runs into the first with no pause, and the last note rings on over the first bar. Off, for a one-shot — a jingle or a stinger — it plays the song once, last note and all, then emits finished. Set it back to the song’s, or revert it in the Inspector, and the node follows the song again. A song can have an intro: its loop_from is the entry of its order the loop comes round to, song edit FILE --loop-from 3 or loop_from = 3 in its text, and a looping node plays the entries before it once. A game can change the music while it plays; see below.
Node Document For What a game uses, by need
ChiptuneSfxPlayer .sfx.tres A sound the game fires, like a jump, a hit or a coin. Fire it: play, play_at(pitch). Repeats that differ: variants, pitch_variance. The same variety every run: seed. One node’s own copy: unique.
ChiptuneSfxPlayer2D .sfx.tres The same, from a place on screen, panned and quieter with distance. The same as ChiptuneSfxPlayer.
ChiptuneSongPlayer, Tracker .music.tres Music the game steers while it plays. Loop or stinger: loop, finished. On the beat: beat, bar, looped, position_bar, pattern_index, pattern_row. Sections: jump_to_pattern, jump_to_row. Mood: tempo, transpose. Layers: set_channel_muted, set_channel_volume, set_channel_solo. A part’s sound: set_instrument, clear_instrument. Echo: set_send.
ChiptuneSongPlayer2D, Tracker .music.tres The same, from a place on screen, panned and quieter with distance. The same as ChiptuneSongPlayer.
ChiptuneAudioStream, a resource either Any other slot: an AnimationPlayer audio track, an AudioStreamRandomizer, an AudioStreamPlayer3D. document. A song renders whole: it plays, with no beat signals and nothing to steer it.

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 sound node renders its document when the scene loads, on the main thread, in milliseconds. A music node renders nothing when the scene loads. It plays the song a fifth of a second ahead of what you hear, rendered on a worker thread while it plays: about 0.4 s of one core for each second of a five-channel song, and almost none of the frame. In the editor, and in a web export without thread support, that render is on the main thread, about 6 ms a frame at 60 frames a second, so two songs at once there can slow the game. A song on any other player is rendered whole when the scene loads, a stall of some hundreds of milliseconds, and twice that looping. For that case, export a .wav: Godot’s importer compresses it and it loads instead of rendering.

Rendering behind a loading screen. ChiptuneAudioStream.prerendered_for(document) makes a stream that renders on another thread instead of when it is made. Wait for its prerendered signal, then give it to a node’s stream. Until then it plays silence.

var music := ChiptuneAudioStream.prerendered_for(boss_song, true)
await music.prerendered
$Music.stream = music
$Music.play()

On a stream you already have, prerender() does the same and is_prerendering() says whether it is still going. Leave the document alone until the signal comes.

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 ChiptuneSfxPlayer to scene or Add ChiptuneSongPlayer to scene in its File menu drops that node 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

RUNTIME.md at the addon’s root has both modules’ runtime classes, EXAMPLE-GAME.md the example game node by node; this is the sound half.

A document is an ordinary resource, so game code can change one, and the node follows it. Set a field and the node renders again on the next idle frame, once however many fields you set in that frame. A knob on the sound’s instrument counts, and so does a strip on the song’s mixer.

A node that is already playing 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.

To hear the change in the same frame, call rebuild() on the node or on ChiptuneAudioStream; each emits document_changed once the change is in. A sound renders in place. A song on ChiptuneSongPlayer renders nothing and doesn’t stop: it plays the change from the next pattern; see Change the music, below.

Vary a sound:

var laser := $Laser as ChiptuneSfxPlayer
var spec := laser.document as ChiptuneSfxSpec
spec.pitch += 3
laser.rebuild()
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.nudge(spec, seed, amount) hands back a copy of a sound with every number moved a little and the wave kept, as the SFX tab’s Nudge does, 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: setting document renders it.

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

A field changed on the document the node already holds is the other way round, 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 too. Turn on unique to give one node a copy of its own, made when the document is assigned: an edit to that node’s document is heard on it alone. The scene still stores the file, and a save of the file copies it again, over the node’s edits. Both song players have unique too.

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.

One sound, many shots. ChiptuneSfxPlayer and ChiptuneSfxPlayer2D vary a sound without rendering it again for each shot:

  • play_at(pitch) plays at a MIDI note, 60 being middle C, by setting pitch_scale. $Pickup.play() or the sound ending puts it back.
  • pitch_variance moves each play up to that many semitones either way. A semitone is one key on a piano, and 12 is an octave.
  • variants renders that many nudges of the sound, when it loads and when it changes, and each play takes the next. seed picks which.
var pickup := $Pickup as ChiptuneSfxPlayer
pickup.play_at(72 + coins_in_a_row * 2)

With pitch_variance or variants set, the node’s stream is an AudioStreamRandomizer holding the renders, so every play varies, however it was started.

Change the music. A song player, ChiptuneSongPlayer or ChiptuneSongPlayer2D, plays the song as it goes, so nothing is rendered and the music does not stop. Everything below is on the node and leaves the song alone, so two nodes playing one song can sound different:

var music := $Music as ChiptuneSongPlayer
music.set_channel_muted(2, true) # the drums drop out, from the next buffer
music.tempo = 150.0 # from the next pattern; 0 is the song's own
music.transpose = 2 # semitones, from the next pattern
music.set_instrument(0, preload("res://audio/organ.tres"))
music.jump_to_pattern(3) # at the next bar line
music.jump_to_row(3, 8) # row 8 of that entry, at the next buffer
music.bar.connect(func(_index: int) -> void: $Lights.flash())
  • The mix. set_channel_muted, set_channel_solo and set_channel_volume, and is_channel_muted to ask. They land on the next buffer, a fifth of a second at most, mid-note and without a click.
  • The echo. A song’s echo is mixer.echo, an instrument’s its echo. set_send(1, &"echo", 0.8, 2.0) swells channel 1’s send over two seconds. In the song’s text, what song show prints, the echo is the echo line, none or its settings in pairs, rows 3 feedback 0.3 damp 0.3 level 0.35; sends is each channel’s send to it, 0 0.8 0 0; and an instrument’s own echo is the echo line of its block. song create and song edit take the first two as --echo and --sends.
  • What plays. tempo in beats per minute, transpose in semitones, set_instrument(channel, instrument) and clear_instrument(channel). They land at the start of the next pattern. The instrument can be one the song never had.
  • Where it is. jump_to_pattern(index) goes to an entry of the song’s order at the next bar line, or right away with false as the second argument. jump_to_row(index, row) goes to a row of that entry’s pattern at the next buffer; a row past the end goes on into the next entry, so jump_to_row(pattern_index(), pattern_row() + 1) steps to the next note. Called before play(), either is where play() starts. Jump twice before the first lands and only the second happens. position_rows(), position_bar(), pattern_index() and pattern_row() say what you are hearing now. play(), stop() and stream_paused work as on any player. seek() and play(from) do not move the music; use jump_to_pattern or jump_to_row. pitch_scale still works, and moves the speed with the pitch.
  • The beat. beat(index) on every beat, bar(index) on every bar line and looped as the loop comes round, each as you hear it. A beat is rows_per_beat rows, four beats make a bar, and a pattern always starts a new bar.

Editing the song itself works too: set song.bpm or write a note and the node picks it up at the next pattern, then emits document_changed. That change is heard by every node holding that song.

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. A song on any other player does the same without freezing the game: it renders on another thread, then the music starts again on the bar nearest where it was. A song on a ChiptuneSongPlayer renders nothing: the music carries on and plays the saved song from the next pattern. Either way the node’s document is the saved one as soon as the save arrives. 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/push_to_game, Push To Game under Jamchip.

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 and opens this README; About JamChip, with the version installed, is on the project page’s ≡. 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.

Save As, under Save in the File menu or Ctrl+Shift+S, always asks where. The chip then shows the new file’s name and the first file stays as it was, so you can save the level theme as a boss theme and keep writing. A sound or a song built into a scene gets a file of its own this way, and the scene uses that file once you save it.

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.

A project’s style, the .jcstyle the project setting jamchip/style names, sets the sound chip both tabs keep to. A song may use up to its channel count; a new one starts on four, or fewer when the style allows fewer. A new sound and a new song’s instruments use only its waves, which are all either waveform picker offers. A song or sound made outside those limits still opens and saves, with a note on the status line, and the picker still shows the wave an instrument is on.

The style also sets how loud the music is against the sound effects: music_db for every song and sounds_db for every sound, in the style’s inspector. They’re in decibels: 0 plays it as made, -10 is about half as loud, -60 is silent, and up to +12 boosts it. A boost is kept from clipping. Both tabs play at those levels, and so do every player in the game and every .wav you export, so a jump is as loud against the music in the tab as it’ll be in the game. Save the style and everything already playing changes with it. A node’s own volume_db and bus are still yours, for a fade or a volume slider in your options menu.

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, in the File menu, writes a four-channel loop in the feel you pick: Bright, Calm, Tense, Sad, Fast or Big. You get two or three patterns, played in one of four orders: A A B A, A B A B, A A B B, or A B A C, where C is a new tune over B’s chords. There’s 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. Under a style with a sound palette, both play each part on the palette’s instrument for it, by its role, then by its wave, and start in the style’s home key. A loop may open on one of the style’s motifs, short tunes any song can use: Add a tune from the style, in the same menu as Generate kit, writes one at the cursor in the song’s own key. Vary, in the Edit menu or Ctrl+Right, changes the selected notes a little, in key. The first and last notes, the notes on the beat, the kick and snare, and every instrument stay as they are. Vary into a new pattern, on a pattern’s menu, plays a varied copy right after it.

Space plays and stops the pattern on screen from anywhere on the tab, the grid included, and stops the song too. Shift+Space plays the song from the cursor’s row, even over a playing pattern, and stops a playing song. In a text field Space types a space, and on a button you reached with Tab it presses the button.

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. Drag a note up or down to change its pitch, or its end across to stretch it; a one-row note follows whichever way you drag first. 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, whatever 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
func loop_begin_frames(sample_rate: int) -> int
# optional; where that loop comes round to,
# absent means its first frame

JamChip, by GlitterSnack. MIT licence. Built from the source on every merge. Its artwork and screenshots are made with JamChip.