Mercurial > touhou
changeset 783:ec1e06402a97
Replace SDL2_mixer with the kira crate
| author | Link Mauve <linkmauve@linkmauve.fr> |
|---|---|
| date | Fri, 21 Nov 2025 10:21:59 +0100 |
| parents | a30ce01b9154 |
| children | 1f152ca95658 |
| files | README formats/src/th06/mod.rs formats/src/th06/pos.rs python/Cargo.toml python/src/audio.rs python/src/lib.rs pytouhou/formats/music.py pytouhou/game/game.pxd pytouhou/game/music.pxd pytouhou/game/music.py pytouhou/games/eosd/interface.py pytouhou/lib/_sdl.pxd pytouhou/lib/sdl.pxd pytouhou/lib/sdl.pyx pytouhou/resource/loader.py pytouhou/ui/gamerunner.pyx pytouhou/ui/music.pyx pytouhou/vm/msgrunner.py scripts/pytouhou setup.py |
| diffstat | 20 files changed, 165 insertions(+), 316 deletions(-) [+] |
line wrap: on
line diff
--- a/README +++ b/README @@ -17,7 +17,7 @@ * A working OpenGL driver * libepoxy * SDL2 - * SDL2_image, SDL2_mixer, SDL2_ttf + * SDL2_image, SDL2_ttf * A TTF font file, placed as “font.ttf” in the game directory.
--- a/formats/src/th06/mod.rs +++ b/formats/src/th06/mod.rs @@ -5,3 +5,4 @@ pub mod ecl; pub mod std; pub mod msg; +pub mod pos;
new file mode 100644 --- /dev/null +++ b/formats/src/th06/pos.rs @@ -0,0 +1,21 @@ +//! POS music track format support. + +use nom::{IResult, Parser, number::complete::le_u32}; + +/// A struct describing the loop points of Touhou background music. +#[derive(Debug, Clone)] +pub struct LoopPoints { + /// Time to which the music should loop after reaching end. + pub start: u32, + + /// Time at which to loop back to start. + pub end: u32, +} + +impl LoopPoints { + /// Parse a slice of bytes into a `LoopPoints` struct. + pub fn from_slice(input: &[u8]) -> IResult<&[u8], LoopPoints> { + let (i, (start, end)) = (le_u32, le_u32).parse(input)?; + Ok((i, LoopPoints { start, end })) + } +}
--- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -17,6 +17,7 @@ pyo3 = "0.27" image = { version = "0.25", default-features = false, features = ["png"], optional = true } glob = "0.3.3" +kira = "0.11.0" [features] default = []
new file mode 100644 --- /dev/null +++ b/python/src/audio.rs @@ -0,0 +1,101 @@ +use kira::sound::static_sound::StaticSoundData; +use kira::sound::streaming::{StreamingSoundData, StreamingSoundHandle}; +use kira::sound::FromFileError; +use kira::{AudioManager, AudioManagerSettings, Tween}; +use pyo3::prelude::*; +use std::collections::HashMap; +use std::io::Cursor; +use std::path::PathBuf; + +#[pyclass(module = "libtouhou")] +pub struct Audio { + loader: Py<super::Loader>, + manager: AudioManager, + cache: HashMap<String, StaticSoundData>, + bgms: [Option<(String, String)>; 4], + current_music: Option<StreamingSoundHandle<FromFileError>>, +} + +#[pymethods] +impl Audio { + #[new] + fn new(loader: Py<super::Loader>, bgms: [Option<(String, String)>; 4]) -> Audio { + let manager = + AudioManager::<kira::DefaultBackend>::new(AudioManagerSettings::default()).unwrap(); + let cache = HashMap::new(); + Audio { + loader, + manager, + cache, + bgms, + current_music: None, + } + } + + fn play_bgm(&mut self, py: Python, number: usize) { + let Some((_name, filename)) = &self.bgms[number] else { + eprintln!("Unspecified bgm number {number}"); + return; + }; + + // Load the loop points corresponding to this bgm. + let mut filename = PathBuf::from(filename); + filename.set_extension("pos"); + let loader = self.loader.borrow(py); + let loop_points = loader + .get_loop_points(filename.file_name().unwrap().to_str().unwrap()) + .unwrap(); + + // Then try to open the music file. + filename.set_extension("*"); + let path = loader + .game_dir + .clone() + .and_then(|dir| Some(dir.join(&filename))) + .unwrap_or(filename); + let mut music = None; + for path in glob::glob(path.to_str().unwrap()) + .unwrap() + .map(Result::unwrap) + .map(PathBuf::from) + { + match StreamingSoundData::from_file(&path) { + Ok(sound) => { + music = Some(sound); + break; + } + Err(err) => { + eprintln!("Error while opening {path:?} as a music file: {err:?}"); + continue; + } + } + } + let Some(music) = music else { + eprintln!("Unable to find bgm, let’s keep the previous one playing…"); + return; + }; + + // Stop the previous playing one. + if let Some(current_music) = &mut self.current_music { + current_music.stop(Tween::default()); + } + + // And now we can start playing the new one! + let mut current_music = self.manager.play(music).unwrap(); + // TODO: Fetch the sample rate from the file, instead of hardcoding it. + current_music.set_loop_region( + (loop_points.start as f64 / 44100.0)..(loop_points.end as f64 / 44100.0), + ); + self.current_music = Some(current_music); + } + + fn play(&mut self, py: Python, name: &str) { + let sound = self.cache.entry(name.to_string()).or_insert_with(|| { + let loader = self.loader.borrow(py); + let bytes = loader.get_file_internal(name).unwrap(); + let cursor = Cursor::new(bytes); + StaticSoundData::from_cursor(cursor).unwrap() + }); + self.manager.play(sound.clone()).unwrap(); + } +}
--- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -1,6 +1,7 @@ use pyo3::exceptions::{PyIOError, PyKeyError}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyTuple}; +use touhou_formats::th06::pos::LoopPoints; use touhou_formats::th06::pbg3; use touhou_formats::th06::std as stage; use touhou_formats::th06::msg; @@ -13,6 +14,8 @@ #[cfg(feature = "glide")] mod glide; +mod audio; + #[pyclass(module = "libtouhou")] struct PyModel { inner: stage::Model, @@ -96,7 +99,7 @@ msg::Instruction::SpawnEnemySprite() => (6, ().into_pyobject(py)?), msg::Instruction::ChangeMusic(track) => (7, (track,).into_pyobject(py)?), msg::Instruction::DisplayDescription(side, index, text) => (8, (side, index, text).into_pyobject(py)?), - msg::Instruction::ShowScores(unk1) => (8, (unk1,).into_pyobject(py)?), + msg::Instruction::ShowScores(unk1) => (9, (unk1,).into_pyobject(py)?), msg::Instruction::Freeze() => (10, ().into_pyobject(py)?), msg::Instruction::NextStage() => (11, ().into_pyobject(py)?), msg::Instruction::Unk2() => (12, ().into_pyobject(py)?), @@ -205,11 +208,22 @@ } } +impl Loader { + fn get_loop_points(&self, name: &str) -> Result<LoopPoints, ()> { + let vec = self.get_file_internal(name).unwrap(); + let (_, inner) = LoopPoints::from_slice(&vec).unwrap(); + Ok(inner) + } +} + #[pymodule] mod libtouhou { #[pymodule_export] use super::Loader; + #[pymodule_export] + use crate::audio::Audio; + #[cfg(feature = "glide")] #[pymodule_export] use super::glide::module;
deleted file mode 100644 --- a/pytouhou/formats/music.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- encoding: utf-8 -*- -## -## Copyright (C) 2012 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> -## -## This program is free software; you can redistribute it and/or modify -## it under the terms of the GNU General Public License as published -## by the Free Software Foundation; version 3 only. -## -## This program is distributed in the hope that it will be useful, -## but WITHOUT ANY WARRANTY; without even the implied warranty of -## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -## GNU General Public License for more details. -## - - -from struct import unpack - - -class Track: - def __init__(self): - self.start = 0 - self.end = 0 - - - @classmethod - def read(cls, file): - self = cls() - self.start, self.end = unpack('<II', file.read(8)) - return self
--- a/pytouhou/game/game.pxd +++ b/pytouhou/game/game.pxd @@ -1,7 +1,6 @@ from pytouhou.game.effect cimport Effect from pytouhou.game.player cimport Player from pytouhou.game.text cimport Text, NativeText -from pytouhou.game.music cimport MusicPlayer from pytouhou.utils.random cimport Random cdef class Game: @@ -9,7 +8,7 @@ cdef public list bullet_types, laser_types, item_types, players, enemies, effects, bullets, lasers, cancelled_bullets, players_bullets, players_lasers, items, labels, faces, hints, bonus_list cdef public object interface, boss, msg_runner cdef public dict texts - cdef public MusicPlayer sfx_player + cdef public object sfx_player, music cdef public Random prng cdef public double continues cdef public Effect spellcard_effect
deleted file mode 100644 --- a/pytouhou/game/music.pxd +++ /dev/null @@ -1,3 +0,0 @@ -cdef class MusicPlayer: - cpdef play(self, name) - cpdef set_volume(self, name, float volume)
deleted file mode 100644 --- a/pytouhou/game/music.py +++ /dev/null @@ -1,6 +0,0 @@ -class MusicPlayer: - def play(self, name): - pass - - def set_volume(self, name, volume): - pass
--- a/pytouhou/games/eosd/interface.py +++ b/pytouhou/games/eosd/interface.py @@ -150,7 +150,6 @@ else: timeout_label.set_color('red') if (boss.timeout - boss.frame) % 60 == 0 and boss.timeout != 0: - self.game.sfx_player.set_volume('timeout.wav', 1.) self.game.sfx_player.play('timeout.wav') timeout_label.set_text('%02d' % (timeout if timeout >= 0 else 0)) timeout_label.changed = True
--- a/pytouhou/lib/_sdl.pxd +++ b/pytouhou/lib/_sdl.pxd @@ -165,40 +165,6 @@ SDL_Surface *IMG_LoadPNG_RW(SDL_RWops *src) -cdef extern from "SDL_mixer.h" nogil: - ctypedef enum: - MIX_DEFAULT_FORMAT - - ctypedef struct Mix_Music: - pass - - ctypedef struct Mix_Chunk: - pass - - int Mix_Init(int flags) - void Mix_Quit() - - int Mix_OpenAudio(int frequency, Uint16 format_, int channels, int chunksize) - void Mix_CloseAudio() - - int Mix_AllocateChannels(int numchans) - - Mix_Music *Mix_LoadMUS(const char *filename) - Mix_Chunk *Mix_LoadWAV_RW(SDL_RWops *src, int freesrc) - - void Mix_FreeMusic(Mix_Music *music) - void Mix_FreeChunk(Mix_Chunk *chunk) - - int Mix_PlayMusic(Mix_Music *music, int loops) - #int Mix_SetLoopPoints(Mix_Music *music, double start, double end) - - int Mix_Volume(int channel, int volume) - int Mix_VolumeChunk(Mix_Chunk *chunk, int volume) - int Mix_VolumeMusic(int volume) - - int Mix_PlayChannel(int channel, Mix_Chunk *chunk, int loops) - - cdef extern from "SDL_pixels.h" nogil: ctypedef struct SDL_Color: Uint8 r, g, b, a
--- a/pytouhou/lib/sdl.pxd +++ b/pytouhou/lib/sdl.pxd @@ -95,20 +95,6 @@ cdef void set_alpha(self, Surface alpha_surface) nogil -cdef class Music: - cdef Mix_Music *music - - cdef void play(self, int loops) nogil - cdef void set_loop_points(self, double start, double end) nogil - - -cdef class Chunk: - cdef Mix_Chunk *chunk - - cdef void play(self, int channel, int loops) nogil - cdef void set_volume(self, float volume) nogil - - cdef class Font: cdef TTF_Font *font @@ -117,17 +103,10 @@ cdef bint init(Uint32 flags) except True cdef bint img_init(int flags) except True -cdef bint mix_init(int flags) except True cdef bint ttf_init() except True cdef bint gl_set_attribute(SDL_GLattr attr, int value) except True cdef Surface load_png(file_) cdef Surface create_rgb_surface(int width, int height, int depth, Uint32 rmask=*, Uint32 gmask=*, Uint32 bmask=*, Uint32 amask=*) -cdef bint mix_open_audio(int frequency, Uint16 format_, int channels, int chunksize) except True -cdef bint mix_allocate_channels(int numchans) except True -cdef int mix_volume(int channel, float volume) nogil -cdef int mix_volume_music(float volume) nogil -cdef Music load_music(str filename) -cdef Chunk load_chunk(file_) cdef Uint32 get_ticks() nogil cdef void delay(Uint32 ms) nogil cpdef bint show_simple_message_box(unicode message) except True
--- a/pytouhou/lib/sdl.pyx +++ b/pytouhou/lib/sdl.pyx @@ -62,8 +62,7 @@ class SDL: - def __init__(self, *, video=True, sound=True): - self.sound = sound + def __init__(self, *, video=True): self.video = video def __enter__(self): @@ -77,23 +76,7 @@ keyboard_state = SDL_GetKeyboardState(NULL) - if self.sound: - mix_init(0) - try: - mix_open_audio(44100, MIX_DEFAULT_FORMAT, 2, 4096) - except SDLError as error: - logger.error(u'Impossible to set up audio subsystem: %s', error) - self.sound = False - else: - # TODO: make it dependent on the number of sound files in the - # archives. - mix_allocate_channels(MAX_SOUNDS) - def __exit__(self, *args): - if self.sound: - Mix_CloseAudio() - Mix_Quit() - TTF_Quit() IMG_Quit() SDL_Quit() @@ -278,31 +261,6 @@ image[3+4*i] = alpha[3*i] -cdef class Music: - def __dealloc__(self): - if self.music != NULL: - Mix_FreeMusic(self.music) - - cdef void play(self, int loops) nogil: - Mix_PlayMusic(self.music, loops) - - cdef void set_loop_points(self, double start, double end) nogil: - #Mix_SetLoopPoints(self.music, start, end) - pass - - -cdef class Chunk: - def __dealloc__(self): - if self.chunk != NULL: - Mix_FreeChunk(self.chunk) - - cdef void play(self, int channel, int loops) nogil: - Mix_PlayChannel(channel, self.chunk, loops) - - cdef void set_volume(self, float volume) nogil: - Mix_VolumeChunk(self.chunk, int(volume * 128)) - - cdef class Font: def __init__(self, str filename, int ptsize): path = filename.encode() @@ -335,11 +293,6 @@ raise SDLError() -cdef bint mix_init(int flags) except True: - if Mix_Init(flags) != flags: - raise SDLError() - - cdef bint ttf_init() except True: if TTF_Init() < 0: raise SDLError() @@ -369,44 +322,6 @@ return surface -cdef bint mix_open_audio(int frequency, Uint16 format_, int channels, int chunksize) except True: - if Mix_OpenAudio(frequency, format_, channels, chunksize) < 0: - raise SDLError() - - -cdef bint mix_allocate_channels(int numchans) except True: - if Mix_AllocateChannels(numchans) != numchans: - raise SDLError() - - -cdef int mix_volume(int channel, float volume) nogil: - return Mix_Volume(channel, int(volume * 128)) - - -cdef int mix_volume_music(float volume) nogil: - return Mix_VolumeMusic(int(volume * 128)) - - -cdef Music load_music(str filename): - music = Music() - path = filename.encode() - music.music = Mix_LoadMUS(path) - if music.music == NULL: - raise SDLError() - return music - - -cdef Chunk load_chunk(file_): - cdef SDL_RWops *rwops - chunk = Chunk() - data = file_.read() - rwops = SDL_RWFromConstMem(<char*>data, len(data)) - chunk.chunk = Mix_LoadWAV_RW(rwops, 1) - if chunk.chunk == NULL: - raise SDLError() - return chunk - - cdef Uint32 get_ticks() nogil: return SDL_GetTicks()
--- a/pytouhou/resource/loader.py +++ b/pytouhou/resource/loader.py @@ -18,7 +18,6 @@ from pytouhou.formats.msg import MSG from pytouhou.formats.sht import SHT from pytouhou.formats.exe import SHT as EoSDSHT, InvalidExeException -from pytouhou.formats.music import Track from pytouhou.formats.fmt import FMT from pytouhou.utils.helpers import get_logger @@ -65,12 +64,6 @@ logger.error("Required game exe not found!") - def get_track(self, name): - posname = name.replace('bgm/', '').replace('.mid', '.pos') - file = self.get_file(posname) - return Track.read(file) #TODO: modular - - def get_fmt(self, name): file = self.get_file(name) return FMT.read(file) #TODO: modular
--- a/pytouhou/ui/gamerunner.pyx +++ b/pytouhou/ui/gamerunner.pyx @@ -17,9 +17,16 @@ from pytouhou.lib.gui cimport EXIT, PAUSE, SCREENSHOT, RESIZE, FULLSCREEN from .window cimport Window, Runner -from .music import BGMPlayer, SFXPlayer +from libtouhou import Audio from pytouhou.game.game cimport Game -from pytouhou.game.music cimport MusicPlayer + + +class DummyAudio: + def play(self, name): + pass + + def play_bgm(self, track): + pass cdef class GameRunner(Runner): @@ -27,14 +34,14 @@ cdef Game game cdef Window window cdef list save_keystates - cdef bint skip + cdef bint skip, audio # Since we want to support multiple renderers, don’t specify its type. #TODO: find a way to still specify its interface. cdef object renderer def __init__(self, Window window, renderer, common, resource_loader, - bint skip=False, con=None): + bint skip=False, con=None, bint audio=True): self.renderer = renderer self.common = common self.resource_loader = resource_loader @@ -43,6 +50,7 @@ self.replay_level = None self.skip = skip self.con = con + self.audio = audio self.width = common.interface.width self.height = common.interface.height @@ -66,14 +74,14 @@ self.save_keystates = save_keystates - null_player = MusicPlayer() + if self.audio: + game.music = Audio(self.resource_loader, bgms) + else: + game.music = DummyAudio() if bgms is not None: - game.music = BGMPlayer(self.resource_loader, bgms) - game.music.play(0) - else: - game.music = null_player + game.music.play_bgm(0) - game.sfx_player = SFXPlayer(self.resource_loader) if not self.skip else null_player + game.sfx_player = game.music if not self.skip else DummyAudio() cdef bint set_input(self, replay=None) except True: @@ -144,7 +152,7 @@ if self.skip: self.set_input() self.skip = False - self.game.sfx_player = SFXPlayer(self.resource_loader) + self.game.sfx_player = self.game.music if self.save_keystates is not None: self.save_keystates.append(keystate)
deleted file mode 100644 --- a/pytouhou/ui/music.pyx +++ /dev/null @@ -1,109 +0,0 @@ -# -*- encoding: utf-8 -*- -## -## Copyright (C) 2012 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> -## -## This program is free software; you can redistribute it and/or modify -## it under the terms of the GNU General Public License as published -## by the Free Software Foundation; version 3 only. -## -## This program is distributed in the hope that it will be useful, -## but WITHOUT ANY WARRANTY; without even the implied warranty of -## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -## GNU General Public License for more details. -## - - -from os.path import join -from glob import glob -from pytouhou.lib import sdl -from pytouhou.lib.sdl cimport load_music, Music, load_chunk, Chunk -from pytouhou.utils.helpers import get_logger -from pytouhou.game.music cimport MusicPlayer - -logger = get_logger(__name__) - - -cdef class BGMPlayer(MusicPlayer): - cdef list bgms - - def __init__(self, resource_loader, bgms): - self.bgms = [] - for bgm in bgms: - if not bgm: - self.bgms.append(None) - continue - posname = bgm[1].replace('bgm/', '').replace('.mid', '.pos') - try: - track = resource_loader.get_track(posname) - except KeyError: - track = None - logger.warning('Music description “%s” not found, continuing without looping data.', posname) - globname = join(resource_loader.game_dir, bgm[1]).replace('.mid', '.*') - filenames = glob(globname) - for filename in reversed(filenames): - try: - source = load_music(filename) - except sdl.SDLError as error: - logger.debug('Music file “%s” unreadable: %s', filename, error) - continue - else: - if track is not None: - source.set_loop_points(track.start / 44100., track.end / 44100.) #TODO: retrieve the sample rate from the actual track. - self.bgms.append(source) - logger.debug('Music file “%s” opened.', filename) - break - else: - self.bgms.append(None) - logger.warning('No working music file for “%s”, disabling bgm.', globname) - - cpdef play(self, index): - cdef Music bgm - bgm = self.bgms[index] - if bgm is not None: - bgm.play(-1) - - -cdef class SFXPlayer(MusicPlayer): - cdef object loader - cdef dict channels, sounds - cdef float volume - cdef int next_channel - - def __init__(self, loader, volume=.42): - self.loader = loader - self.channels = {} - self.sounds = {} - self.volume = volume - self.next_channel = 0 - - cdef int get_channel(self, name) except -1: - if name not in self.channels: - self.channels[name] = self.next_channel - self.next_channel += 1 - return self.channels[name] - - cdef Chunk get_sound(self, name): - cdef Chunk chunk - if name not in self.sounds: - try: - wave_file = self.loader.get_file(name) - chunk = load_chunk(wave_file) - except (KeyError, sdl.SDLError) as error: - logger.warning('Sound “%s” not found: %s', name, error) - chunk = None - else: - chunk.set_volume(self.volume) - self.sounds[name] = chunk - return self.sounds[name] - - cpdef play(self, name): - sound = self.get_sound(name) - if sound is None: - return - channel = self.get_channel(name) - sound.play(channel, 0) - - cpdef set_volume(self, name, float volume): - sound = self.get_sound(name) - if sound is not None: - sound.set_volume(volume)
--- a/pytouhou/vm/msgrunner.py +++ b/pytouhou/vm/msgrunner.py @@ -143,7 +143,7 @@ @instruction(7) def change_music(self, track): - self._game.music.play(track) + self._game.music.play_bgm(track) @instruction(8)
--- a/scripts/pytouhou +++ b/scripts/pytouhou @@ -200,7 +200,7 @@ interface = Interface(resource_loader, common.players[0]) #XXX common.interface = interface #XXX renderer = GameRenderer(resource_loader, window) if GameRenderer is not None else None - runner = GameRunner(window, renderer, common, resource_loader, skip_replay, con) + runner = GameRunner(window, renderer, common, resource_loader, skip_replay, con, args.no_sound) window.set_runner(runner) while True: @@ -279,7 +279,7 @@ is_sdl = (args.frontend == 'sdl') -with SDL(video=is_sdl, sound=args.no_sound): +with SDL(video=is_sdl): window = Window(backend, Interface.width, Interface.height, fps_limit=args.fps_limit, frameskip=args.frameskip)
--- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ COMMAND = 'pkg-config' GLFW_LIBRARIES = ['glfw3'] -SDL_LIBRARIES = ['sdl2', 'SDL2_image', 'SDL2_mixer', 'SDL2_ttf'] +SDL_LIBRARIES = ['sdl2', 'SDL2_image', 'SDL2_ttf'] GL_LIBRARIES = ['epoxy'] debug = False # True to generate HTML annotations and display infered types. @@ -68,7 +68,6 @@ 'glfw3': '-lglfw', 'sdl2': '-lSDL2', 'SDL2_image': '-lSDL2_image', - 'SDL2_mixer': '-lSDL2_mixer', 'SDL2_ttf': '-lSDL2_ttf', 'epoxy': '-lepoxy' }
