An internet radio player for the Nintendo GameCube. Point it at an Icecast or SHOUTcast stream — MP3, AAC or Opus, over HTTP or HTTPS — and the console plays it, showing the track title as it changes.
Written in C for devkitPPC/libogc2. One source tree builds two players:
- the console build (
make) — everything drawn in the libogc text console, 1.1 MB, the smallest and longest-tested of the two; - the GX build (
make GUI=1) — a two-panel interface drawn by the GPU on a render thread of its own, 2.2 MB.
About 1.7 MB of RAM out of the console's 24 MB, either way.
The GX interface, with the station list on the left and the player on the right — the list scrolls, and the station playing stays marked while you move the highlight elsewhere:
┌──────────────────────┬─────────────────────────────────────────┐
│ STATIONS 1/25 │ NightRide FM (Opus) │
│ ──────────────────── │ Nightride - Tokyo Rose │
│ ▎NightRide FM (Opus) │ ─────────────────────────────────────── │
│ Chillsynth (Opus) │ Opus 251 kbps 48000 Hz stereo │
│ Datawave (Opus) │ 00:41:07 │
│ Spacesynth (Opus) │ │
│ Darksynth (Opus) │ ▆█▇▅▆▄▅▃▄▃▃▂▃▂▂▁▂▁▁▁▁▁░░ spectrum │
│ Horrorsynth (Opus) │ ████████████████░░░░░░░░ L │
│ Rekt (Opus) │ ███████████████░░░░░░░░░ R │
│ EBSM (Opus) │ ─────────────────────────────────────── │
│ Synthetic FM 256k │ streaming vol 200 │
│ Synthwave (laut.fm) │ net ███░░░░░ audio ███████░ │
│ Nectarine 192k ▌│ 20134 KB 64211 frames attempts 1 │
│ │ stalls 0 dropouts 0 rate changes 0 │
└──────────────────────┴─────────────────────────────────────────┘
UP/DOWN volume Z stations START quit
The bars are a real 2048-point FFT — see "Features" — and the two meters below them are the peak level of each channel. The downward slope is what music actually looks like, not a decoration.
The console build, which shows the same information as text:
gcradio - HTTP audio streaming on GameCube
Station : [1/1] Nectarine Demoscene Radio
IP : 192.168.1.56 DNS : 192.168.1.1
Stream : http://nectarine.from-de.com:80/necta192
State : connected host -> 88.198.25.215
Title : Syphus - Turbochip
Format : 44100 Hz / 192 kbps / 2 ch
Buffers : network 15% audio 98%
Recv : 3696 KB / 5947 frames
Volume : 200
Audio : playing dropouts 0
UP/DOWN : volume Z : stations START : quit
- MP3, AAC and Opus. The format comes from the server's
Content-Typeand, when that is missing or wrong, from the bytes themselves. A stream in a format this build cannot decode says so on screen rather than falling silent. - HE-AAC handled correctly: the sample rate is taken from the decoder, not from the ADTS header, which advertises the core rate and would play SBR content at half speed.
- Ogg demuxing written for this project rather than pulled from libogg, because the pull-style API would have had to block the loop that reads the controller.
- ICY titles, de-interleaved and resynchronised with the audio — the title appears when the track does, not eleven seconds early. Ogg streams carry their titles in-band instead, and those are read too.
- Volume with auto-repeat, and a playing-time counter that only advances while sound is actually going out, so it stays comparable with the dropout count.
- HTTP and HTTPS. TLS 1.2 through a cross-built Mbed TLS, with the trust store read from the SD card so an expired root can be replaced without rebuilding.
- Built-in DNS resolver — libogc ships none on the GameCube.
- HTTP redirects followed, including the single-use tokens that many stations hand out; a reconnect always restarts from the canonical URL rather than from a spent token.
- No call can block for ever. Every read has a deadline, because a TCP peer
that dies without closing sends neither FIN nor RST — the screen names the
phase it is in (
connecting,headers,streaming,quiet Ns,STALLED) and gives up rather than hanging. - Automatic reconnection with a DNS cache flush, so dynamic-DNS names are followed.
- Takes the Broadband Adapter or an ETH2GC, whichever is plugged in, and says on screen which one it found.
- Two builds from one tree: the libogc text console, or a GX interface rendered by the GPU on its own thread — so drawing never paces the playback loop, and the loop never blocks on the vertical retrace.
- Up to 128 stations in a scrolling list, in both builds.
- A station picker that does not stop the music. In the GX build it is a state, not a modal loop: the stream keeps playing and the screen keeps updating while you choose.
- Stations added, edited and deleted on the console, in either build, with an on-screen keyboard. The last station and volume are restored on the next start. No PC needed after the first copy.
- A 24-band spectrum analyser: a 2048-point fixed-point FFT written for this project rather than linked, sampled where the PCM is already contiguous on its way to the DSP. No floating point anywhere.
- A segmented VU meter — in the console build drawn straight into the framebuffer in YUY2, below everything the console prints.
- A start-up log that names each step, so a failed start says where it stopped instead of showing a black screen.
- Reads its configuration from SD Gecko, SD2SP2, a GC Loader or a Memory Expansion Pak, formatted FAT32 or exFAT — so a card above 32 GB, which is exFAT as sold, works as it is.
Three of these are worth understanding before filing a bug, because they are properties of the hardware rather than defects.
This is the one that surprises people. The GameCube's TCP window is 2920 bytes, and a sender cannot have more than one window in flight per round trip. So the absolute ceiling is:
maximum bytes per second = 2920 / RTT
Nothing else about the console enters into it — not the CPU, not the buffer size. A distant station fails at a bitrate a nearby one sustains easily, and buying a faster internet connection changes nothing.
| Ping to the station | Hard ceiling | Comfortable bitrate (2× margin) |
|---|---|---|
| 10 ms | 285 KB/s | up to 320 kbps |
| 25 ms | 114 KB/s | up to 320 kbps |
| 50 ms | 57 KB/s | ~230 kbps |
| 100 ms | 29 KB/s | ~115 kbps |
| 150 ms | 19 KB/s | ~78 kbps |
| 200 ms | 14 KB/s | ~58 kbps |
| 300 ms | 9.5 KB/s | ~39 kbps |
The "comfortable" column stops at 320 kbps because that is the highest rate ever sustained here in practice: 40 KB/s for an hour over a DOL-015. Below about a 2× margin, a single retransmission timeout is enough to empty the buffer.
This was not derived on paper. Two SomaFM mounts — same server, same city, same round trip, only the bitrate different — were played on the console: 128 kbps ran for an hour and a half without a single dropout, and 256 kbps broke up immediately and kept getting worse. See DOC.md §3.
What to do about it: prefer a nearby station, or a lower bitrate, or a more efficient codec. Opus at 128 kbps sounds better than MP3 at 128 kbps and costs the same bytes — on this machine the byte is scarce and the CPU cycle is not.
Transfer-Encoding: chunked |
not decoded. Some reverse proxies answer this way to HTTP/1.1; gcradio asks in HTTP/1.0 to avoid it. |
| TLS 1.3-only servers | Mbed TLS 2.28 LTS speaks TLS 1.2 at the newest. Servers that have dropped 1.2 will not connect. |
Playlist URLs (.pls, .m3u) |
not parsed. Point gcradio at the stream itself, not at the file that lists it. |
| Vorbis in Ogg | not decoded — Opus is. |
| Streams behind a login or a token you must fetch first | no session handling. |
- Booting from Datel's SD Media Launcher does not work. Chain-load Swiss instead, which is a single extra step. If you need that loader specifically, release v1.0.1 predates the change that broke it.
- No hardware random number generator exists on a GameCube. TLS session keys are seeded from a mixture of timebase, RTC, allocator addresses and scheduling jitter. That is honest enough for authenticating a public radio station, where nothing secret is transmitted; it is not good enough for anything carrying credentials, and it must not be described as a hardware RNG.
- A dead clock battery means no date, so certificate expiry cannot be checked. gcradio detects an implausible RTC, skips exactly the two date-related checks, keeps chain, signature and hostname verification, and says on screen which of the two situations you are in.
- The spectrum analyser resolves the bass coarsely. Twenty-four log-spaced bands from 50 Hz need finer resolution at the bottom than 2048 points give, so the lowest two bars share a bin and move together. Raising the transform size would push that boundary down and nothing else would.
- Editing the URL of the station currently playing takes effect on the next reconnection, not immediately. Deleting it is refused — switch away first, or the screen would name one station while the speakers played another.
- An ETH2GC and an SD2SP2 both want Serial Port 2, so with an ETH2GC the
configuration has to live on an SD Gecko. And
if_configex()probes the Broadband Adapter first: on a console with both fitted, the ETH2GC will never be chosen.
- A GameCube with a network adapter, either of:
- a Broadband Adapter DOL-015 on Serial Port 1
- an ETH2GC on Serial Port 2
- An SD Gecko (memory card slot) or SD2SP2 (Serial Port 2) to hold the
.doland its configuration. Note that an ETH2GC and an SD2SP2 both occupy Serial Port 2, so with an ETH2GC the configuration goes on an SD Gecko. A GC Loader or a Memory Expansion Pak will hold the configuration too; any of them may be FAT32 or exFAT, of any size. - A way to launch homebrew, typically Swiss
- An Icecast/SHOUTcast stream over HTTP or HTTPS. TLS 1.2 is spoken through a cross-built Mbed TLS; a server that has gone TLS 1.3-only will not work.
- Copy
gcradio-gui.dol(the GX interface) orgcradio.dol(the text console) onto your SD card. They read the same configuration and play the same streams; pick whichever you prefer, or carry both. - Copy
gcradio.conf.examplenext to it, rename it togcradio.conf, and put your stream URL in it. You can skip this and type the URL on the console instead — it will then be saved for you. - For
https://stations, copygcradio-ca.pemnext to them as well. It is a release asset; it is also just curl's CA bundle, socurl -o gcradio-ca.pem https://curl.se/ca/cacert.pemgets you a current one. Without it, plain HTTP still plays andhttps://is refused with the reason on screen. - Launch
gcradio.dolthrough Swiss. - Pick a station and press A.
The trust store sits on the card rather than in the binary so that an expired root can be replaced without rebuilding anything. It does not go stale quickly: of the 119 roots in the July 2026 bundle, one expires before 2029 and eight before 2030, and the median runs to 2040. What actually matters is the root a given station chains to — NightRide reaches ISRG Root X2, good until 2040; SomaFM reaches Sectigo's R46, good until 2046.
See the user guide for the controls and the configuration file format.
Requires devkitPro with the gamecube-dev package.
source /etc/profile.d/devkit-env.sh
make # -> gcradio.dol, the text console, 1.1 MB
make GUI=1 # -> gcradio.dol, the GX interface, 2.2 MBBoth write gcradio.dol, so rename the one you are keeping. GUI=1 needs
GRRLIB as well: pacman -S libogc2-grrlib.
To make everything a release carries — both builds, with the clean between
them that a shared build/ makes necessary, plus the example configuration
and the trust store — in dist/:
make distThat is the same target GitHub Actions runs
(.github/workflows/release.yml): every push
is built in devkitPro's container, and a v* tag is built and published from
the tree at that tag. The tag has to match VERSION in the Makefile — the
number the console build prints on its first line — or the release is refused.
There is a third build, for when the GX path misbehaves on a console that has no debugger attached:
make GUI=1 GXTRACE=1 # narrates every step of the bring-up on a text
# console, then hands the screen over on STARTIt exists because GRRLIB takes the display on its first call and never gives it back, so any error printed afterwards lands in a framebuffer nobody can see. That, and not the underlying bug, is what made two earlier attempts look like a console that had simply died.
The shipped gcradio.conf opens on both mirrors of Nectarine Demoscene
Radio — [US] and [EUR], the same public programme
at 192 kbps, starting on the American one. With no card to read at all, which is
every run under Dolphin, gcradio falls back on the [US] one alone, compiled in
so that a machine that cannot save a station still plays something. To bake in
your own instead:
make DEFAULT_URL=http://192.168.1.20:8000/stream DEFAULT_NAME="Living room"Host-side tests, which need no GameCube toolchain — they build the real logic with your system gcc against substitute headers:
cd tests && make # 217 checksOn MSYS2 use the MSYS gcc rather than the MinGW one, which ships no
<sys/select.h>: PATH=/usr/bin:$PATH make.
gcradio builds against libogc2, not the libogc that comes with devkitPro — the only supported stack since 2026-08-02. Two reasons, both about hardware:
- its TCP window is
2*TCP_MSS(2920), wide enough to be useful and still inside the DOL-015's 4 KiB receive buffer — going over that overflows the adapter (see DOC.md, §10a) - it carries drivers for the ENC28J60, W5500 and W6100, which is what makes
ETH2GC support free:
if_configex()probes the DOL-015 first, then those three, on Serial Port 1, Serial Port 2 and both card slots. devkitPro's libogc has the BBA driver and nothing else.
Install it alongside libogc, which it does not replace:
# add the [libogc2-devkitpro] repository to /etc/pacman.conf, before [dkp-libs]
pacman -S libogc2-git libogc2-libdvm-gitlibdvm, and not libogc2-libfat-git. The two packages carry the same
name, install the same libfat.a and conflict with each other, so this line
is the only place the choice is made — nothing in the Makefile or the source
can tell which of the two you built against. libdvm adds a partition prober
and an exFAT driver, and gcradio reaches both through fatInitDefault(),
which libfat provides too. Build against libfat and everything still links:
what you lose is silent, and it is every card above 32 GB
(DOC.md, §9b).
Dolphin is fine for the application logic and useless for judging the network stack. Two independent reasons: it does not model the DOL-015's 4 KiB receive buffer at all, so a window that would overflow real hardware looks like a free win; and its emulated adapter has a receive-buffer wrap-around bug that libogc2 runs into and devkitPro's libogc does not, which makes libogc2 look several times slower than it is. Throughput comparisons belong on a console.
Honest summary, because it matters:
| Builds under devkitPPC, no warnings | yes |
| 217 host-bench checks | green |
| Network, DNS, HTTP, ICY, decoding, UI | validated under Dolphin |
| MP3 and AAC playback | confirmed on the console |
| Opus playback (Ogg) | confirmed on the console |
| HTTPS, TLS 1.2 via mbedTLS | confirmed on the console |
| Plays on a real GameCube with a DOL-015 | yes, chain-loaded through Swiss |
| Plays on a real GameCube with an ETH2GC | yes, Serial Port 2, confirmed 2026-08-02 |
| Booting from Datel's SD Media Launcher | broken — chain Swiss; see DOC.md §11a |
| Controller responsiveness on hardware | fixed and confirmed on the console |
| Sustained throughput on hardware | 40 KB/s over an hour on a DOL-015 |
| HTTP redirects, VU meter | confirmed on the console |
Reading gcradio.conf from SD |
confirmed on the console |
| Certificate bundle from SD | confirmed on the console |
| GX interface, rendered on its own thread | confirmed on the console |
| Station picker in the GX build | confirmed on the console |
| Adding, editing and deleting stations in the GX build | confirmed on the console |
| Spectrum analyser | confirmed on the console |
So: it plays on a console, with either adapter, in either build, but only when Swiss is chain-loaded first — booting straight from the SD Media Launcher is unresolved and parked. Since 2026-08-02 there is no libogc branch to fall back on; if you need that loader, take release v1.0.1, which predates the switch.
The throughput figures are real measurements rather than emulator readings, and the rule they produced is the first entry under Limitations above: it is the single most useful thing to know before blaming the console for a station that will not play.
Getting the GX interface onto a thread was the expensive part of v3, and the
lesson generalises: the render thread was given 16 KB of stack, FreeType's
rasteriser builds its state on the caller's stack, and the overrun brought down
malloc much later and somewhere else entirely. An overrun stack never fails
where it happened. It is 64 KB now. The whole episode is in
HISTORY.md.
The analyser produced a smaller version of the same lesson. Its first scale was
anchored on a full-scale sine, which is arithmetically correct and useless: a
sine puts all its energy in one bin where music spreads it over hundreds, so
every real stream crawled along the bottom of the display. The bars were right
and unreadable at the same time — a failure that no amount of staring at the
code would have shown, and that took decoding a real stream on a PC and running
the same fft_bands() over it to see.
Reports from real consoles are very welcome — especially throughput figures with the round trip quoted alongside, since that pair is what the ceiling above is made of.
- USER-GUIDE.md — controls, configuration, troubleshooting
- DOC.md — technical documentation: architecture, concurrency model, ICY synchronisation, libogc API notes
- HISTORY.md — how the project got here, and the three diagnoses that cost the most time
The source is MIT. A built gcradio.dol is GPL-2.0-or-later.
Both are true at once, and the difference matters if you redistribute a binary:
| Component | Licence |
|---|---|
| gcradio itself | MIT |
minimp3, vendored as source/minimp3.h |
CC0 |
| faad2 — AAC | GPL-2.0 |
| libopus — Opus | BSD-3-Clause |
| Mbed TLS — HTTPS | Apache-2.0 or GPL-2.0-or-later |
| GRRLIB — the GX interface | MIT |
| FreeType — text, via GRRLIB | FTL or GPL-2.0-or-later |
| libpng, zlib, brotli, bzip2 — pulled in by FreeType | permissive |
DejaVu Sans Condensed, embedded as source/font_data.c |
Bitstream Vera + Arev, permissive |
| curl's CA bundle, a release asset | MPL-2.0 |
| devkitPro / libogc2 | their own |
Linking faad2 is what does it: any binary containing it is GPL-2.0. Mbed TLS and FreeType are both dual-licensed and are taken here under GPL-2.0-or-later — which is what makes the combination coherent rather than merely convenient, since Apache-2.0 and GPL-2.0 do not mix.
Two of these are actually redistributed, and they are the two that needed choosing carefully:
- the font, because it lives inside the
.doland therefore inside a GPL-2.0-or-later binary. DejaVu descends from Bitstream Vera and Arev, both permissive with no copyleft of their own, so it raises nothing. It is embedded rather than read off the card because without it there is no interface at all, and a missing file would mean a black screen with no way to say why. Regenerate it withsh tools/mkfont.sh. - the trust store,
gcradio-ca.pem, published as a release asset besidegcradio.dol. It is MPL-2.0, being a conversion of Mozilla'scertdata.txt— but it is data read from the SD card at run time, not linked code, so it touches neither the MIT source nor the GPL binary. See "HTTPS" below for how long a given bundle stays good.
Everything else is fetched by the build instructions above and is not redistributed here.
MP3 decoding by minimp3 (lieff, CC0),
vendored as source/minimp3.h — public domain, no attribution required.
AAC decoding by faad2 (GPLv2) and Opus by
libopus (BSD), both linked from the devkitPro
PowerPC portlibs and not redistributed here. Note that linking faad2 makes a
distributed binary subject to the GPL.
Built on devkitPro / libogc, which carry their own licences and are not redistributed here.