Trucs et astuces
24 décembre 2025

Recréer les radios de GTA V avec Python

Image de couverture - Recréer les radios de GTA V avec Python

Vous aussi vous en avez assez de ces musiques qui passent à la radio ? Vous aussi vous dites que ce serait quand même beaucoup plus stylé d’écouter Non-Stop Pop FM dans votre voiture ? Et bien sachez que vous êtes au bon endroit ! Dans cet article, nous verrons comment recréer la logique de base du système de radio de GTA V à partir des fichiers du jeu. 😀

Mais avant toute chose, ça va de soit mais il faut posséder le jeu. Sans lui vous ne pourrez pas réaliser notre simulateur, je n’inclurai pas les musiques et bandes sons dans le dépôt Git du projet. prof

Pré-requis

Pour réaliser ce projet, nous aurons besoin de plusieurs outils que vous devrez installer. Si vous souhaitez créer une version “embedded” du projet, que vous installerez par exemple sur une Raspberry Pi, les dépendances marquées d’un astérisque seront également à installer sur le système embarqué.

  • OpenIV - Permet d’ouvrir les fichiers du jeu afin d’extraire les fichiers sons
  • VLC Media Player* - Permet de lire les fichiers WAV exportés, sera en quelque sorte le backend de notre script
  • Python 3.13* - Langage utilisé, vous pouvez prendre une version supérieur ou inférieur, mais il faudra s’assurer que les paquets utilisés soient compatibles

Note : Dans cet article je vais utiliser la version Legacy de GTA V, car c’est avec cette version que j’ai initialement créé ce script. C’est également avec cette version qu’est configuré mon OpenIV. Mais il n’y a normalement pas de problème concernant l’usage de la version Enhanced.

Récupération des fichiers son

La première étape est d’extraire les fichiers du jeu. Pour ça allons utiliser OpenIV.

OpenIV ouvert à la racine du jeu

Une fois ouvert, OpenIV se présente sous la forme d’un gestionnaire de fichier. - Notez cette délicieuse apparence à la Windows 7. pandaluv - Ce qui nous intéresse c’est le dossier \x64\audio\sfx car c’est lui qui contient les fichiers audio qui nous intéresse.

Contenu du dossier SFX

Comme vous le constatez, le dossier sfx contient des archives au format propriétaire .rpf, qui contiennent eux, les fichiers sons du jeu. Chaque archive représente une catégorie de fichier son. On y retrouve par exemple les fichiers son des animaux, du scanner de la police, mais aussi et surtout les fichiers sons des radios. Ces derniers sont préfixés par le texte RADIO_. Pour les besoins de ce projet, nous aurons besoin d’extraire le contenu des archives suivantes :

  • RADIO_ADVERTS.rpf - Contient les coupures publicitaires
  • RADIO_NEWS.rpf - Contient les coupures d’actualité

Leur extraction est obligatoire. Notre script ne fera pas de vérification sur la présence de leur fichier son, seule la ceux de la station de radio seront vérifiés. D’ailleurs, vous pouvez choisir celles qui vous intéressent le plus. Le nombre importe peu. Pour ma part, je vais choisir Non-Stop Pop FM car c’est la plus complète (et ma préférée :D).

Miniature - Extraction des fichiers sons avec OpenIV
Extraction des fichiers son avec OpenIV

Lors de l’extraction, faites bien attention à choisir l’option “Export to WAV (.wav)”, et non pas “Extract” ou encore "Export to openFormat (.oac). Aussi, pensez bien à séparer les exports dans différents dossiers.

Ce qui est intéressant au travers de ces fichier extraits, c’est de voir la structure des dossiers. Si les archives des pubs et actualités n’ont rien de particulier, celles des stations de radio révèlent assez bien comment le jeu gère leur lecture.

Analyse de la structure des fichiers

Tout d’abord, chaque fichier son est placé dans un dossier du même nom, en lettres minuscules snake_case. On devine assez bien les dossiers représentants des musiques grâce à leur nom (adult_education, cooler_than_me, feel_good_inc, etc), et les jingle de la station grâce au prefix “id_”.

Mais ce n’est pas tout. Nous avons également un dossier “intro” qui contient des séquences audio avec le nom des musiques en lettres majuscules suivies d’un numéro (01, 02). Ces fichiers intro sont exactement ce qu’ils décrivent, c’est-à-dire les introduction du DJ de la station de radio à la musique qui va se jouer. Toutes les musiques n’ont pas obligatoirement une séquence d’introduction. D’ailleurs, cela varie beaucoup selon les stations de radio. On imagine assez bien que Non-Stop Pop a dû être une de celles qui reçu le plus d’attention.

Nous avons également les dossier “mono_solo_” qui représentent des séquences de monologue du DJ, “time” qui représente les annonces de l’heure sous forme de “good morning”, “good evening”. Puis nous avons le dossier “to” qui annonce l’arrivée des pubs et actualités, et enfin le dossier “general” qui comporte des courts monologues du DJ.

En résumé, voici ce que ça donne :

  • id_XX/ID_XX.wav : Jingles
  • intro/NOM_MUSIQUE_XX.wav : Introduction
  • mono_solo/MONO_SOLO_XX.wav : Longs monologues
  • general/GENERAL_XX.wav : Courts monologues
  • time/EVENING_XX.wav | MORNING_XX.wav : Annonce de l’heure approximative
  • to/TO_AD_XX.wav | TO_NEWS_XX.wav : Annonce des coupures pubs et actualité
  • nom_musique/NOM_MUSIQUE.wav : Musiques

À partir de cette analyse, il est assez facile de déduire des règles regex qui vont nous permettre de détecter ces fichiers automatiquement.

  • Jingles : ((id)|(ID))_([0-9]){2}
  • Longs monolgues : ((mono)|(MONO))_((solo)|(SOLO))
  • Courts monologues : (general)|(GENERAL_([0-9]){2})

Notez que ces regex sont volontairement permissives afin d’inclure les différentes casses. En ce qui concerne les musiques et leurs séquences d’introduction, c’est un peu plus compliqué car nous avons besoin de faire de la concaténation de variable. Comme pour les musiques on doit globalement chercher toutes les correspondances qui exclues les fichiers cités ci-dessus, cela donnerait ça : ^(?:(?!(" + idPattern + "|" + monoSoloPattern + "|" + generalPattern + "|(.*([A-Z])_([0-9]){2})|intro|to|time)).)+$. Très simplement, on inclue les regex précédentes dans celle-ci afin de les exclure de nos résultats de recherche.

Enfin, pour les introductions des musiques, nous avons besoin du nom de la musique en question. Donc on va dire REPLACEMEWITHMUSICTITLE_([0-9]){2}.

Pour l’instant, nous n’inclurons pas les séquences “time” et “to” à notre script. :)

Conception de notre script

Maintenant que nous connaissons la structure des fichiers, nous pouvons commencer à concevoir notre algorithme/script.

Paternes de lecture

Tout d’abord, comme notre but est de reproduire le comportement d’une station de radio, nous devons intégrer la notion de paternes de lecture. Qu’est-ce que cela signifie ? Et bien, si nous nous contentons dons de lire aléatoirement les musiques récupérées, cela ne serait pas une radio mais une playlist. Nous ne pouvons pas non plus lire tous les fichiers de façon aléatoire car cela n’aurai aucun sens. Une bonne solution est de suivre des paternes de lecture prédéfinies qui seront eux, sélectionnés aléatoirement.

Pour garder les choses simples, nous allons créer 2 séries de paternes de lecture. Un pour les musiques et un autre pour les coupures publicitaires et d’actualité. Ainsi, nous pourrons plus facilement définir la portion de musique par rapport aux coupures. Voici ce que ça donne :

Paternes de musique :

  • ID, GENERAL, MUSIC
  • ID, MUSIC
  • GENERAL, MUSIC
  • ID, GENERAL, MUSIC

Paternes de coupures :

  • AD, AD, NEWS
  • AD, AD
  • NEWS
  • MONO_SOLO, AD
  • AD, MONO_SOLO
  • MONO_SOLO, NEWS
  • NEWS, MONO_SOLO

Ce n’est pas une science exacte, ce sont des paternes que j’ai déduis avec l’habitude d’écouter les stations du jeu.

Variables dynamiques (ou non)

En plus des paternes de lecture, nous devons également intégrer la notion de variables dynamiques qui nous permettrons de rendre la lecture un peu plus naturelle. Un très bon exemple de variables dynamique est le délais qui se joue avant la lecture de l’introduction d’une musique par le DJ de la station. Nous pourrions utiliser des délais fixes, mais cela retirerai un peu du caractère réaliste de notre simulateur.

Ainsi, en termes de variables dynamiques à définir, nous aurons la liste suivante à prendre en compte :

  • Délais minimum avant l’introduction
  • Délais maximum avant l’introduction
  • Proportion de coupures publicitaire et d’actualité
  • Musiques jouées
  • Publicités jouées
  • Actualités jouées
  • Monologues joués
  • Jingles radio joués

En soit, les 3 premières variables ne sont pas dynamiques. Mais concernant le délais des intro, il sera lui dynamique car déterminé à partir des deux variables d’écart. Avec ces variables, nous aurons donc un suivi complet des lectures, évitant au maximum les répétitions. L’algorithme sera fait de sorte à ce que les musiques devront toutes être jouées avant de les répéter à nouveau.

Passons maintenant à la réalisation !

Réalisation de notre script

Je ne vais pas vous détailler le script ligne par ligne, d’abord car ce serait inintéressant à lire, mais aussi et surtout car c’est Noël et que j’ai chopé une flemmingite aigüe. kappo

Nous allons donc le survoler. Encore une fois, le projet est disponible sur GitHub, le lien est à la fin de cet article. :)

Organisation et dépendances

Afin de garder une structure relativement claire, nous allons découper notre script en 3 fichiers :

  • radio.py - Script principal content la logique de la radio, c’est lui qu’on lancera
  • defs.py - Fichier contenant les fonctions utilitaires non liées à la logique de la radio, c’est une sorte de façade
  • config.py - Fichier de configuration

Comme précisé dans la section pré-requis de cet article, nous allons utiliser VLC comme backend pour jouer la musique.

Script principal - radio.py

Il s’agit là du script principal, c’est lui qui concentre la logique du système de radio. Nous avons basiquement 4 méthodes à l’intérieur :

  • start_radio - Lance la lecture et choisir de jouer une musique ou une coupure pub/news
  • play_music - Joue une musique seule en choisissant un paterne au hasard
  • play_ad_and_news - Joue une coupure publicitaire ou d’actualité en choisissant un paterne au hasard
  • choose_random_unplayed_track - Se charge de retourner le nom d’une piste encore non jouée pour le type de séquence à jouer (musique/pub/news)
import os
import random
from typing import Literal

import config
import defs

TrackType = Literal["music", "ad", "news", "monoSolo"]


class Radio:
    radio_path: str = ""
    ads_path: str = ""
    news_path: str = ""
    radio_name: str = ""
    ids: list[str] = []
    mono_solos: list[str] = []
    generals: list[str] = []
    musics: list[str] = []
    ads: list[str] = []
    news: list[str] = []
    played_ads: list[str] = []
    played_news: list[str] = []
    played_musics: list[str] = []
    played_mono_solos: list[str] = []

    def __init__(self, radio_name: str) -> None:
        self.radio_path = os.getcwd() + "/" + radio_name
        self.ads_path = os.getcwd() + "/radio-adverts"
        self.news_path = os.getcwd() + "/radio-news"
        self.radio_name = radio_name
        print("Playing: " + self.radio_name)

        self.ids = defs.get_files_by_regex(self.radio_path, config.idPattern)
        self.mono_solos = defs.get_files_by_regex(self.radio_path, config.monoSoloPattern)
        self.generals = defs.get_files_by_regex(self.radio_path, config.generalPattern)
        self.musics = defs.get_files_by_regex(self.radio_path, config.musicPatten)
        self.ads = defs.get_files_by_regex(self.ads_path, ".*")
        self.news = defs.get_files_by_regex(self.news_path, ".*")

    def start_radio(self) -> None:
        while True:
            # Check if we will play an ad or news
            if random.random() < config.adsProbability:
                if config.debug:
                    print("Playing ads and news")
                self.play_ad_and_news()

            if config.debug:
                print("Playing music")
            self.play_music()

    def play_music(self) -> None:
        music: str = self.choose_random_unplayed_track("music")
        music_name: str = music.split("/")[-1].split(".")[0]

        # We choose random pattern
        pattern: list[str] = random.choice(config.musicPatterns).split(", ")

        if config.debug:
            print("Pattern: " + str(pattern))

        # We will now play the pattern
        for item in pattern:
            if item == "ID":
                defs.play_sound(random.choice(self.ids))
            elif item == "GENERAL":
                defs.play_sound(random.choice(self.generals))
            elif item == "MONO_SOLO":
                defs.play_sound(random.choice(self.mono_solos))
            elif item == "MUSIC":
                intro_files: list[str] = defs.get_music_intro_files(self.radio_path, music_name)
                if len(intro_files) > 0:
                    intro: str = random.choice(intro_files)
                    defs.play_sound_with_delayed_second_sound(music, intro)
                else:
                    defs.play_sound(music)

    def play_ad_and_news(self) -> None:
        # We choose random pattern
        pattern: list[str] = random.choice(config.adsAndNewsPatterns).split(", ")

        if config.debug:
            print("Pattern: " + str(pattern))

        # We will now play the pattern
        for item in pattern:
            if item == "AD":
                defs.play_sound(self.choose_random_unplayed_track("ad"))
            elif item == "NEWS":
                defs.play_sound(self.choose_random_unplayed_track("news"))
            elif item == "MONO_SOLO":
                defs.play_sound(self.choose_random_unplayed_track("monoSolo"))

    def choose_random_unplayed_track(self, track_type: TrackType) -> str:
        track_map: dict[TrackType, tuple[list[str], list[str]]] = {
            "music": (self.musics, self.played_musics),
            "ad": (self.ads, self.played_ads),
            "news": (self.news, self.played_news),
            "monoSolo": (self.mono_solos, self.played_mono_solos),
        }

        if track_type not in track_map:
            raise Exception("Invalid track type")

        tracks, played = track_map[track_type]

        if len(played) == len(tracks):
            played.clear()

        track: str = random.choice(tracks)
        while track in played:
            track = random.choice(tracks)
        played.append(track)

        return track


try:
    radio: Radio = Radio(config.radioStation)
    radio.start_radio()
except KeyboardInterrupt:
    print("Exiting...")
    exit(0)

Script secondaire - defs.py

Ce script ce charge quant à lui de faire le lien entre notre classe Radio et le backend audio, ici VLC. L’idée est que si plus-tard je trouve une meilleur librairie pour jouer la musique, je n’aurais qu’à modifier ces méthodes. J’ai rencontré quelques difficultés concernant la lecture différée des introductions, notamment en souhaitant modifier le volume de la musique pour mieux entendre l’annonce du DJ.

Ainsi, nous avons 4 méthodes :

  • get_files_by_regex - Se charge de récupérer les fichiers selon la regex donnée en paramètre
  • play_sound - Tout est dans son nom xD
  • get_music_intro_files - Idem, sauf qu’ici on remplace notre texte placeholder REPLACEMEWITHMUSICTITLE par le nom de notre musique
  • play_sound_with_delayed_second_sound - Fait la même chose que play_sound, ctd joue une musique, mais avec une introduction
import os
import random
import re
import time
import vlc
import config


def get_files_by_regex(folder_to_scan: str, regex: str) -> list[str]:
    # We will firstly scan for folders, and then for inside those folders
    # for files. We will return a list of files that match the regex.
    compiled_regex: re.Pattern[str] = re.compile(regex)
    audio_files: list[str] = []
    for folder in os.listdir(folder_to_scan):
        folder_path: str = folder_to_scan + "/" + folder
        if compiled_regex.match(folder) and os.path.isdir(folder_path):

            # We will now scan the folder for files
            for file in os.listdir(folder_path):
                if file.endswith(config.filesExtension):
                    audio_files.append(folder_to_scan + "/" + folder + "/" + file)

    return audio_files


def play_sound(sound_file: str) -> None:
    if config.debug:
        print("Playing: " + sound_file)

    # We will play the sound file
    player: vlc.MediaPlayer = vlc.MediaPlayer(sound_file)
    player.audio_set_volume(config.volume)
    player.play()

    # We will wait for the sound to finish
    ended: int = 6
    current_state: int = player.get_state()
    while current_state != ended:
        current_state = player.get_state()

    if config.debug:
        print("Done playing: " + sound_file)


def get_music_intro_files(files_path: str, music_name: str) -> list[str]:
    regex: re.Pattern[str] = re.compile(config.musicIntro.replace("REPLACEMEWITHMUSICTITLE", music_name.upper()))
    audio_files: list[str] = []
    files_path = files_path + "/" + config.introFolder

    for file in os.listdir(files_path):
        if regex.match(file):
            if file.endswith(config.filesExtension):
                audio_files.append(files_path + "/" + file)

    return audio_files


def play_sound_with_delayed_second_sound(first_sound: str, second_sound: str) -> None:
    delay: int = random.randint(config.musicMinIntroDelay, config.musicMaxIntroDelay)

    if config.debug:
        print("Playing: " + first_sound + " and " + second_sound + " with a delay of " + str(delay) + " seconds")

    instances: list[vlc.Instance] = [vlc.Instance(), vlc.Instance()]
    players: list[vlc.MediaPlayer] = [instances[0].media_player_new(), instances[1].media_player_new()]

    # We set the first sound
    players[0].set_media(instances[0].media_new(first_sound))

    # We set the second sound
    players[1].set_media(instances[1].media_new(second_sound))

    # We will play the first sound
    players[0].audio_set_volume(config.volume)
    players[0].play()

    # We wait
    time.sleep(delay)

    # We will play the second sound
    players[1].play()

    # We lower the volume of the first sound
    # players[0].audio_set_volume(50)
    # players[1].audio_set_volume(100)

    # We will wait for the second sound to finish
    ended: int = 6
    current_state: int = players[1].get_state()
    while current_state != ended:
        current_state = players[1].get_state()

    if config.debug:
        print("Done playing Intro: " + second_sound)

    # We will now raise the volume of the first sound
    players[0].audio_set_volume(config.volume)

    # We will wait for the first sound to finish
    current_state = players[0].get_state()
    while current_state != ended:
        current_state = players[0].get_state()

    if config.debug:
        print("Done playing Music: " + first_sound)

Configuration - config.py

Puis vient la configuration. C’est ici que nous définissons toutes nos variables (ou presque). :)

debug = False
filesExtension = ".wav"
musicMinIntroDelay = 5
musicMaxIntroDelay = 15
introFolder = "intro"
adsProbability = 0.25
volume = 75

# Radio station to play: "non-stop-pop", "silverlake", "funk", etc.
radioStation: str = "non-stop-pop"

# Regex
idPattern = "((id)|(ID))_([0-9]){2}"
monoSoloPattern = "((mono)|(MONO))_((solo)|(SOLO))"
generalPattern = "(general)|(GENERAL_([0-9]){2})"

musicPatten = "^(?:(?!(" + idPattern + "|" + monoSoloPattern + "|" + generalPattern + "|(.*([A-Z])_([0-9]){2})|intro|to|time)).)+$"
musicIntro = "REPLACEMEWITHMUSICTITLE_([0-9]){2}"

# Radio specific
musicPatterns = ["ID, GENERAL, MUSIC", "ID, MUSIC", "GENERAL, MUSIC",
                 "ID, GENERAL, MUSIC"]

adsAndNewsPatterns = ["AD, AD, NEWS", "AD, AD", "NEWS", "MONO_SOLO, AD", "AD, MONO_SOLO", "MONO_SOLO, NEWS",
                      "NEWS, MONO_SOLO"]

Voici ce que ça donne avec les musiques d’ajoutées. :D

Miniature - Non-Stop Pop FM
Démonstration avec Non-Stop Pop FM
Miniature - Space 103.2
Démonstration avec Space 103.2

Conclusion

Voilà tout pour ce projet !

Cela faisait un moment que je voulais le partager, depuis 2023 pour être exacte. L’idée de base était de créer un script que je pouvais installer sur une Raspberry Pi, que j’embarquerai dans ma voiture et brancherai à mon autoradio. Mais je me suis confronté à d’autres problèmes qui ont fait que j’ai très vite abandonné l’idée. Toutefois, je l’ai toujours gardé de côté et m’en suis servi de nombreuses fois pour écouter les radios de GTA V à la place Spotify. C’est… Original. 😂

Bien sûr, là le programme ne fonctionne qu’en ligne de commande, depuis le terminal. Mais vous pouvez très facilement lui construire une interface graphique, comme je l’ai fais l’année dernière avec Claude Code.

Miniature - GTA V Radio simulator GUI
Même projet, mais avec une interface créée grâce à Tkinter.

Ici par exemple, je lui avait demandé de me créer une interface me permettant de visualiser les musiques en cours de lecture, de changer de station de radio, de programmer les prochains paternes à jouer et d’exporter les stations de façon aléatoire, tout en respectant le système de paterne. C’est assez poussé, très cool à utiliser, mais peut-être un peu trop niche pour vous en parler ici. De plus, ce n’est plus vraiment ma réalisation puisque toute l’interface a été réalisée via Claude Code.

Sur ce, comme je ne vais pas publier d’article avant l’année prochaine, je vous souhaite de très bonnes fêtes de fin d’année, un Joyeux Noël et bonne nouvelle année 2026 ! 🎉

Dépôt Github : https://github.com/SofianeLasri/python-gtav-radios

2025 - Sofiane Lasri-Trienpont, développé sur Laravel.