Added: FFmpeg based audio decoder
--HG-- branch : develop
This commit is contained in:
parent
c93d8ffd50
commit
e994c12983
7 changed files with 761 additions and 25 deletions
173
code/CMakeModules/FindFFmpeg.cmake
Normal file
173
code/CMakeModules/FindFFmpeg.cmake
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
# vim: ts=2 sw=2
|
||||||
|
# - Try to find the required ffmpeg components(default: AVFORMAT, AVUTIL, AVCODEC)
|
||||||
|
#
|
||||||
|
# Once done this will define
|
||||||
|
# FFMPEG_FOUND - System has the all required components.
|
||||||
|
# FFMPEG_INCLUDE_DIRS - Include directory necessary for using the required components headers.
|
||||||
|
# FFMPEG_LIBRARIES - Link these to use the required ffmpeg components.
|
||||||
|
# FFMPEG_DEFINITIONS - Compiler switches required for using the required ffmpeg components.
|
||||||
|
#
|
||||||
|
# For each of the components it will additionaly set.
|
||||||
|
# - AVCODEC
|
||||||
|
# - AVDEVICE
|
||||||
|
# - AVFORMAT
|
||||||
|
# - AVUTIL
|
||||||
|
# - POSTPROC
|
||||||
|
# - SWSCALE
|
||||||
|
# - SWRESAMPLE
|
||||||
|
# the following variables will be defined
|
||||||
|
# <component>_FOUND - System has <component>
|
||||||
|
# <component>_INCLUDE_DIRS - Include directory necessary for using the <component> headers
|
||||||
|
# <component>_LIBRARIES - Link these to use <component>
|
||||||
|
# <component>_DEFINITIONS - Compiler switches required for using <component>
|
||||||
|
# <component>_VERSION - The components version
|
||||||
|
#
|
||||||
|
# Copyright (c) 2006, Matthias Kretz, <kretz@kde.org>
|
||||||
|
# Copyright (c) 2008, Alexander Neundorf, <neundorf@kde.org>
|
||||||
|
# Copyright (c) 2011, Michael Jansen, <kde@michael-jansen.biz>
|
||||||
|
#
|
||||||
|
# Redistribution and use is allowed according to the terms of the BSD license.
|
||||||
|
|
||||||
|
include(FindPackageHandleStandardArgs)
|
||||||
|
|
||||||
|
if(NOT FFmpeg_FIND_COMPONENTS)
|
||||||
|
set(FFmpeg_FIND_COMPONENTS AVFORMAT AVCODEC AVUTIL)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
#
|
||||||
|
### Macro: set_component_found
|
||||||
|
#
|
||||||
|
# Marks the given component as found if both *_LIBRARIES AND *_INCLUDE_DIRS is present.
|
||||||
|
#
|
||||||
|
macro(set_component_found _component)
|
||||||
|
if(${_component}_LIBRARIES AND ${_component}_INCLUDE_DIRS)
|
||||||
|
# message(STATUS " - ${_component} found.")
|
||||||
|
set(${_component}_FOUND TRUE)
|
||||||
|
else()
|
||||||
|
# message(STATUS " - ${_component} not found.")
|
||||||
|
endif()
|
||||||
|
endmacro()
|
||||||
|
|
||||||
|
#
|
||||||
|
### Macro: find_component
|
||||||
|
#
|
||||||
|
# Checks for the given component by invoking pkgconfig and then looking up the libraries and
|
||||||
|
# include directories.
|
||||||
|
#
|
||||||
|
macro(find_component _component _pkgconfig _library _header)
|
||||||
|
if(NOT WIN32)
|
||||||
|
# use pkg-config to get the directories and then use these values
|
||||||
|
# in the FIND_PATH() and FIND_LIBRARY() calls
|
||||||
|
find_package(PkgConfig)
|
||||||
|
if(PKG_CONFIG_FOUND)
|
||||||
|
pkg_check_modules(PC_${_component} ${_pkgconfig})
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_path(${_component}_INCLUDE_DIRS ${_header}
|
||||||
|
HINTS
|
||||||
|
${FFMPEGSDK_INC}
|
||||||
|
${PC_LIB${_component}_INCLUDEDIR}
|
||||||
|
${PC_LIB${_component}_INCLUDE_DIRS}
|
||||||
|
PATH_SUFFIXES
|
||||||
|
ffmpeg
|
||||||
|
)
|
||||||
|
|
||||||
|
find_library(${_component}_LIBRARIES NAMES ${_library}
|
||||||
|
HINTS
|
||||||
|
${FFMPEGSDK_LIB}
|
||||||
|
${PC_LIB${_component}_LIBDIR}
|
||||||
|
${PC_LIB${_component}_LIBRARY_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
STRING(REGEX REPLACE "/.*" "/version.h" _ver_header ${_header})
|
||||||
|
if(EXISTS "${${_component}_INCLUDE_DIRS}/${_ver_header}")
|
||||||
|
file(STRINGS "${${_component}_INCLUDE_DIRS}/${_ver_header}" version_str REGEX "^#define[\t ]+LIB${_component}_VERSION_M.*")
|
||||||
|
|
||||||
|
foreach(_str "${version_str}")
|
||||||
|
if(NOT version_maj)
|
||||||
|
string(REGEX REPLACE "^.*LIB${_component}_VERSION_MAJOR[\t ]+([0-9]*).*$" "\\1" version_maj "${_str}")
|
||||||
|
endif()
|
||||||
|
if(NOT version_min)
|
||||||
|
string(REGEX REPLACE "^.*LIB${_component}_VERSION_MINOR[\t ]+([0-9]*).*$" "\\1" version_min "${_str}")
|
||||||
|
endif()
|
||||||
|
if(NOT version_mic)
|
||||||
|
string(REGEX REPLACE "^.*LIB${_component}_VERSION_MICRO[\t ]+([0-9]*).*$" "\\1" version_mic "${_str}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
unset(version_str)
|
||||||
|
|
||||||
|
set(${_component}_VERSION "${version_maj}.${version_min}.${version_mic}" CACHE STRING "The ${_component} version number.")
|
||||||
|
unset(version_maj)
|
||||||
|
unset(version_min)
|
||||||
|
unset(version_mic)
|
||||||
|
endif(EXISTS "${${_component}_INCLUDE_DIRS}/${_ver_header}")
|
||||||
|
set(${_component}_VERSION ${PC_${_component}_VERSION} CACHE STRING "The ${_component} version number.")
|
||||||
|
set(${_component}_DEFINITIONS ${PC_${_component}_CFLAGS_OTHER} CACHE STRING "The ${_component} CFLAGS.")
|
||||||
|
|
||||||
|
set_component_found(${_component})
|
||||||
|
|
||||||
|
mark_as_advanced(
|
||||||
|
${_component}_INCLUDE_DIRS
|
||||||
|
${_component}_LIBRARIES
|
||||||
|
${_component}_DEFINITIONS
|
||||||
|
${_component}_VERSION)
|
||||||
|
endmacro()
|
||||||
|
|
||||||
|
|
||||||
|
set(FFMPEGSDK $ENV{FFMPEG_HOME})
|
||||||
|
if(FFMPEGSDK)
|
||||||
|
set(FFMPEGSDK_INC "${FFMPEGSDK}/include")
|
||||||
|
set(FFMPEGSDK_LIB "${FFMPEGSDK}/lib")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Check for all possible components.
|
||||||
|
find_component(AVCODEC libavcodec avcodec libavcodec/avcodec.h)
|
||||||
|
find_component(AVFORMAT libavformat avformat libavformat/avformat.h)
|
||||||
|
find_component(AVDEVICE libavdevice avdevice libavdevice/avdevice.h)
|
||||||
|
find_component(AVUTIL libavutil avutil libavutil/avutil.h)
|
||||||
|
find_component(SWSCALE libswscale swscale libswscale/swscale.h)
|
||||||
|
find_component(SWRESAMPLE libswresample swresample libswresample/swresample.h)
|
||||||
|
find_component(POSTPROC libpostproc postproc libpostproc/postprocess.h)
|
||||||
|
|
||||||
|
# Check if the required components were found and add their stuff to the FFMPEG_* vars.
|
||||||
|
foreach(_component ${FFmpeg_FIND_COMPONENTS})
|
||||||
|
if(${_component}_FOUND)
|
||||||
|
# message(STATUS "Required component ${_component} present.")
|
||||||
|
set(FFMPEG_LIBRARIES ${FFMPEG_LIBRARIES} ${${_component}_LIBRARIES})
|
||||||
|
set(FFMPEG_DEFINITIONS ${FFMPEG_DEFINITIONS} ${${_component}_DEFINITIONS})
|
||||||
|
list(APPEND FFMPEG_INCLUDE_DIRS ${${_component}_INCLUDE_DIRS})
|
||||||
|
else()
|
||||||
|
# message(STATUS "Required component ${_component} missing.")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
# Build the include path and library list with duplicates removed.
|
||||||
|
if(FFMPEG_INCLUDE_DIRS)
|
||||||
|
list(REMOVE_DUPLICATES FFMPEG_INCLUDE_DIRS)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(FFMPEG_LIBRARIES)
|
||||||
|
list(REMOVE_DUPLICATES FFMPEG_LIBRARIES)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# cache the vars.
|
||||||
|
set(FFMPEG_INCLUDE_DIRS ${FFMPEG_INCLUDE_DIRS} CACHE STRING "The FFmpeg include directories." FORCE)
|
||||||
|
set(FFMPEG_LIBRARIES ${FFMPEG_LIBRARIES} CACHE STRING "The FFmpeg libraries." FORCE)
|
||||||
|
set(FFMPEG_DEFINITIONS ${FFMPEG_DEFINITIONS} CACHE STRING "The FFmpeg cflags." FORCE)
|
||||||
|
|
||||||
|
mark_as_advanced(FFMPEG_INCLUDE_DIRS FFMPEG_LIBRARIES FFMPEG_DEFINITIONS)
|
||||||
|
|
||||||
|
# Now set the noncached _FOUND vars for the components.
|
||||||
|
foreach(_component AVCODEC AVDEVICE AVFORMAT AVUTIL POSTPROCESS SWRESAMPLE SWSCALE)
|
||||||
|
set_component_found(${_component})
|
||||||
|
endforeach ()
|
||||||
|
|
||||||
|
# Compile the list of required vars
|
||||||
|
set(_FFmpeg_REQUIRED_VARS FFMPEG_LIBRARIES FFMPEG_INCLUDE_DIRS)
|
||||||
|
foreach(_component ${FFmpeg_FIND_COMPONENTS})
|
||||||
|
list(APPEND _FFmpeg_REQUIRED_VARS ${_component}_LIBRARIES ${_component}_INCLUDE_DIRS)
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
# Give a nice error message if some of the required vars are missing.
|
||||||
|
find_package_handle_standard_args(FFmpeg DEFAULT_MSG ${_FFmpeg_REQUIRED_VARS})
|
|
@ -20,6 +20,7 @@ ENDIF()
|
||||||
IF(WITH_SOUND)
|
IF(WITH_SOUND)
|
||||||
FIND_PACKAGE(Ogg)
|
FIND_PACKAGE(Ogg)
|
||||||
FIND_PACKAGE(Vorbis)
|
FIND_PACKAGE(Vorbis)
|
||||||
|
FIND_PACKAGE(FFmpeg COMPONENTS AVCODEC AVFORMAT AVUTIL SWRESAMPLE)
|
||||||
|
|
||||||
IF(WITH_DRIVER_OPENAL)
|
IF(WITH_DRIVER_OPENAL)
|
||||||
FIND_PACKAGE(OpenAL)
|
FIND_PACKAGE(OpenAL)
|
||||||
|
|
108
code/nel/include/nel/sound/audio_decoder_ffmpeg.h
Normal file
108
code/nel/include/nel/sound/audio_decoder_ffmpeg.h
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
// NeL - MMORPG Framework <http://dev.ryzom.com/projects/nel/>
|
||||||
|
// Copyright (C) 2018 Winch Gate Property Limited
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as
|
||||||
|
// published by the Free Software Foundation, either version 3 of the
|
||||||
|
// License, or (at your option) any later version.
|
||||||
|
//
|
||||||
|
// 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 Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
#ifndef NLSOUND_AUDIO_DECODER_FFMPEG_H
|
||||||
|
#define NLSOUND_AUDIO_DECODER_FFMPEG_H
|
||||||
|
#include <nel/misc/types_nl.h>
|
||||||
|
|
||||||
|
#include <nel/sound/audio_decoder.h>
|
||||||
|
|
||||||
|
struct AVCodecContext;
|
||||||
|
struct AVFormatContext;
|
||||||
|
struct AVIOContext;
|
||||||
|
struct AVPacket;
|
||||||
|
struct SwrContext;
|
||||||
|
|
||||||
|
namespace NLSOUND {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* \brief CAudioDecoderFfmpeg
|
||||||
|
* \date 2018-10-21 08:08GMT
|
||||||
|
* \author Meelis Mägi (Nimetu)
|
||||||
|
* CAudioDecoderFfmpeg
|
||||||
|
* Create trough IAudioDecoder
|
||||||
|
*/
|
||||||
|
class CAudioDecoderFfmpeg : public IAudioDecoder
|
||||||
|
{
|
||||||
|
protected:
|
||||||
|
NLMISC::IStream *_Stream;
|
||||||
|
|
||||||
|
bool _IsSupported;
|
||||||
|
bool _Loop;
|
||||||
|
bool _IsMusicEnded;
|
||||||
|
sint32 _StreamOffset;
|
||||||
|
sint32 _StreamSize;
|
||||||
|
|
||||||
|
AVIOContext *_AvioContext;
|
||||||
|
AVFormatContext *_FormatContext;
|
||||||
|
AVCodecContext *_AudioContext;
|
||||||
|
SwrContext *_SwrContext;
|
||||||
|
|
||||||
|
// selected stream
|
||||||
|
sint32 _AudioStreamIndex;
|
||||||
|
|
||||||
|
// output buffer for decoded frame
|
||||||
|
SwrContext *_ConvertContext;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// called from constructor if ffmpeg fails to initialize
|
||||||
|
// or from destructor to cleanup ffmpeg pointers
|
||||||
|
void release();
|
||||||
|
|
||||||
|
public:
|
||||||
|
CAudioDecoderFfmpeg(NLMISC::IStream *stream, bool loop);
|
||||||
|
virtual ~CAudioDecoderFfmpeg();
|
||||||
|
|
||||||
|
inline NLMISC::IStream *getStream() { return _Stream; }
|
||||||
|
inline sint32 getStreamSize() { return _StreamSize; }
|
||||||
|
inline sint32 getStreamOffset() { return _StreamOffset; }
|
||||||
|
|
||||||
|
// Return true if ffmpeg is able to decode the stream
|
||||||
|
bool isFormatSupported() const;
|
||||||
|
|
||||||
|
/// Get information on a music file (only artist and title at the moment).
|
||||||
|
static bool getInfo(NLMISC::IStream *stream, std::string &artist, std::string &title, float &length);
|
||||||
|
|
||||||
|
/// Get how many bytes the music buffer requires for output minimum.
|
||||||
|
virtual uint32 getRequiredBytes();
|
||||||
|
|
||||||
|
/// Get an amount of bytes between minimum and maximum (can be lower than minimum if at end).
|
||||||
|
virtual uint32 getNextBytes(uint8 *buffer, uint32 minimum, uint32 maximum);
|
||||||
|
|
||||||
|
/// Get the amount of channels (2 is stereo) in output.
|
||||||
|
virtual uint8 getChannels();
|
||||||
|
|
||||||
|
/// Get the samples per second (often 44100) in output.
|
||||||
|
virtual uint getSamplesPerSec();
|
||||||
|
|
||||||
|
/// Get the bits per sample (often 16) in output.
|
||||||
|
virtual uint8 getBitsPerSample();
|
||||||
|
|
||||||
|
/// Get if the music has ended playing (never true if loop).
|
||||||
|
virtual bool isMusicEnded();
|
||||||
|
|
||||||
|
/// Get the total time in seconds.
|
||||||
|
virtual float getLength();
|
||||||
|
|
||||||
|
/// Set looping
|
||||||
|
virtual void setLooping(bool loop);
|
||||||
|
}; /* class CAudioDecoderFfmpeg */
|
||||||
|
|
||||||
|
} /* namespace NLSOUND */
|
||||||
|
|
||||||
|
#endif // NLSOUND_AUDIO_DECODER_FFMPEG_H
|
||||||
|
|
||||||
|
/* end of file */
|
|
@ -58,6 +58,7 @@ FILE(GLOB STREAM
|
||||||
FILE(GLOB STREAM_FILE
|
FILE(GLOB STREAM_FILE
|
||||||
audio_decoder.cpp ../../include/nel/sound/audio_decoder.h
|
audio_decoder.cpp ../../include/nel/sound/audio_decoder.h
|
||||||
audio_decoder_vorbis.cpp ../../include/nel/sound/audio_decoder_vorbis.h
|
audio_decoder_vorbis.cpp ../../include/nel/sound/audio_decoder_vorbis.h
|
||||||
|
audio_decoder_ffmpeg.cpp ../../include/nel/sound/audio_decoder_ffmpeg.h
|
||||||
stream_file_sound.cpp ../../include/nel/sound/stream_file_sound.h
|
stream_file_sound.cpp ../../include/nel/sound/stream_file_sound.h
|
||||||
stream_file_source.cpp ../../include/nel/sound/stream_file_source.h
|
stream_file_source.cpp ../../include/nel/sound/stream_file_source.h
|
||||||
)
|
)
|
||||||
|
@ -95,6 +96,12 @@ IF(WITH_STATIC)
|
||||||
TARGET_LINK_LIBRARIES(nelsound ${OGG_LIBRARY})
|
TARGET_LINK_LIBRARIES(nelsound ${OGG_LIBRARY})
|
||||||
ENDIF()
|
ENDIF()
|
||||||
|
|
||||||
|
IF(FFMPEG_FOUND)
|
||||||
|
ADD_DEFINITIONS(-DFFMPEG_ENABLED)
|
||||||
|
INCLUDE_DIRECTORIES(${FFMPEG_INCLUDE_DIRS})
|
||||||
|
TARGET_LINK_LIBRARIES(nelsound ${FFMPEG_LIBRARIES})
|
||||||
|
ENDIF()
|
||||||
|
|
||||||
|
|
||||||
INCLUDE_DIRECTORIES(${LIBXML2_INCLUDE_DIR})
|
INCLUDE_DIRECTORIES(${LIBXML2_INCLUDE_DIR})
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,10 @@
|
||||||
// Project includes
|
// Project includes
|
||||||
#include <nel/sound/audio_decoder_vorbis.h>
|
#include <nel/sound/audio_decoder_vorbis.h>
|
||||||
|
|
||||||
|
#ifdef FFMPEG_ENABLED
|
||||||
|
#include <nel/sound/audio_decoder_ffmpeg.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
using namespace std;
|
using namespace std;
|
||||||
using namespace NLMISC;
|
using namespace NLMISC;
|
||||||
|
|
||||||
|
@ -82,6 +86,17 @@ IAudioDecoder *IAudioDecoder::createAudioDecoder(const std::string &type, NLMISC
|
||||||
nlwarning("Stream is NULL");
|
nlwarning("Stream is NULL");
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
#ifdef FFMPEG_ENABLED
|
||||||
|
try {
|
||||||
|
CAudioDecoderFfmpeg *decoder = new CAudioDecoderFfmpeg(stream, loop);
|
||||||
|
return static_cast<IAudioDecoder *>(decoder);
|
||||||
|
}
|
||||||
|
catch(const Exception &e)
|
||||||
|
{
|
||||||
|
nlwarning("Exception %s during ffmpeg setup", e.what());
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
#else
|
||||||
std::string type_lower = toLower(type);
|
std::string type_lower = toLower(type);
|
||||||
if (type_lower == "ogg")
|
if (type_lower == "ogg")
|
||||||
{
|
{
|
||||||
|
@ -92,6 +107,7 @@ IAudioDecoder *IAudioDecoder::createAudioDecoder(const std::string &type, NLMISC
|
||||||
nlwarning("Music file type unknown: '%s'", type_lower.c_str());
|
nlwarning("Music file type unknown: '%s'", type_lower.c_str());
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IAudioDecoder::getInfo(const std::string &filepath, std::string &artist, std::string &title, float &length)
|
bool IAudioDecoder::getInfo(const std::string &filepath, std::string &artist, std::string &title, float &length)
|
||||||
|
@ -102,6 +118,14 @@ bool IAudioDecoder::getInfo(const std::string &filepath, std::string &artist, st
|
||||||
nlwarning("Music file %s does not exist!", filepath.c_str());
|
nlwarning("Music file %s does not exist!", filepath.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef FFMPEG_ENABLED
|
||||||
|
CIFile ifile;
|
||||||
|
ifile.setCacheFileOnOpen(false);
|
||||||
|
ifile.allowBNPCacheFileOnOpen(false);
|
||||||
|
if (ifile.open(lookup))
|
||||||
|
return CAudioDecoderFfmpeg::getInfo(&ifile, artist, title, length);
|
||||||
|
#else
|
||||||
std::string type = CFile::getExtension(filepath);
|
std::string type = CFile::getExtension(filepath);
|
||||||
std::string type_lower = NLMISC::toLower(type);
|
std::string type_lower = NLMISC::toLower(type);
|
||||||
|
|
||||||
|
@ -119,6 +143,7 @@ bool IAudioDecoder::getInfo(const std::string &filepath, std::string &artist, st
|
||||||
{
|
{
|
||||||
nlwarning("Music file type unknown: '%s'", type_lower.c_str());
|
nlwarning("Music file type unknown: '%s'", type_lower.c_str());
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
artist.clear(); title.clear();
|
artist.clear(); title.clear();
|
||||||
return false;
|
return false;
|
||||||
|
@ -132,6 +157,11 @@ void IAudioDecoder::getMusicExtensions(std::vector<std::string> &extensions)
|
||||||
{
|
{
|
||||||
extensions.push_back("ogg");
|
extensions.push_back("ogg");
|
||||||
}
|
}
|
||||||
|
#ifdef FFMPEG_ENABLED
|
||||||
|
extensions.push_back("mp3");
|
||||||
|
extensions.push_back("flac");
|
||||||
|
extensions.push_back("aac");
|
||||||
|
#endif
|
||||||
|
|
||||||
// extensions.push_back("wav"); // TODO: Easy.
|
// extensions.push_back("wav"); // TODO: Easy.
|
||||||
}
|
}
|
||||||
|
@ -139,7 +169,11 @@ void IAudioDecoder::getMusicExtensions(std::vector<std::string> &extensions)
|
||||||
/// Return if a music extension is supported by the nel sound library.
|
/// Return if a music extension is supported by the nel sound library.
|
||||||
bool IAudioDecoder::isMusicExtensionSupported(const std::string &extension)
|
bool IAudioDecoder::isMusicExtensionSupported(const std::string &extension)
|
||||||
{
|
{
|
||||||
|
#ifdef FFMPEG_ENABLED
|
||||||
|
return (extension == "ogg" || extension == "mp3" || extension == "flac" || extension == "aac");
|
||||||
|
#else
|
||||||
return (extension == "ogg");
|
return (extension == "ogg");
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
} /* namespace NLSOUND */
|
} /* namespace NLSOUND */
|
||||||
|
|
430
code/nel/src/sound/audio_decoder_ffmpeg.cpp
Normal file
430
code/nel/src/sound/audio_decoder_ffmpeg.cpp
Normal file
|
@ -0,0 +1,430 @@
|
||||||
|
// NeL - MMORPG Framework <http://dev.ryzom.com/projects/nel/>
|
||||||
|
// Copyright (C) 2018 Winch Gate Property Limited
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as
|
||||||
|
// published by the Free Software Foundation, either version 3 of the
|
||||||
|
// License, or (at your option) any later version.
|
||||||
|
//
|
||||||
|
// 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 Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
#include "stdsound.h"
|
||||||
|
|
||||||
|
#include <nel/sound/audio_decoder_ffmpeg.h>
|
||||||
|
|
||||||
|
#define __STDC_CONSTANT_MACROS
|
||||||
|
extern "C"
|
||||||
|
{
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libavformat/avformat.h>
|
||||||
|
#include <libswresample/swresample.h>
|
||||||
|
#include <libavutil/opt.h>
|
||||||
|
};
|
||||||
|
|
||||||
|
using namespace std;
|
||||||
|
using namespace NLMISC;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const std::string av_err2string(sint err)
|
||||||
|
{
|
||||||
|
char buf[AV_ERROR_MAX_STRING_SIZE];
|
||||||
|
av_strerror(err, buf, AV_ERROR_MAX_STRING_SIZE);
|
||||||
|
return (std::string)buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
void nel_logger(void *ptr, int level, const char *fmt, va_list vargs)
|
||||||
|
{
|
||||||
|
static char msg[1024];
|
||||||
|
|
||||||
|
const char *module = NULL;
|
||||||
|
|
||||||
|
// AV_LOG_DEBUG, AV_LOG_TRACE
|
||||||
|
if (level >= AV_LOG_DEBUG) return;
|
||||||
|
|
||||||
|
if (ptr)
|
||||||
|
{
|
||||||
|
AVClass *avc = *(AVClass**) ptr;
|
||||||
|
module = avc->item_name(ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
vsnprintf(msg, sizeof(msg), fmt, vargs);
|
||||||
|
msg[sizeof(msg)-1] = '\0';
|
||||||
|
|
||||||
|
switch(level)
|
||||||
|
{
|
||||||
|
case AV_LOG_PANIC:
|
||||||
|
// ffmpeg is about to crash so lets throw
|
||||||
|
nlerror("FFMPEG(P): (%s) %s", module, msg);
|
||||||
|
break;
|
||||||
|
case AV_LOG_FATAL:
|
||||||
|
// ffmpeg had unrecoverable error, corrupted stream or such
|
||||||
|
nlerrornoex("FFMPEG(F): (%s) %s", module, msg);
|
||||||
|
break;
|
||||||
|
case AV_LOG_ERROR:
|
||||||
|
nlwarning("FFMPEG(E): (%s) %s", module, msg);
|
||||||
|
break;
|
||||||
|
case AV_LOG_WARNING:
|
||||||
|
nlwarning("FFMPEG(W): (%s) %s", module, msg);
|
||||||
|
break;
|
||||||
|
case AV_LOG_INFO:
|
||||||
|
nlinfo("FFMPEG(I): (%s) %s", module, msg);
|
||||||
|
break;
|
||||||
|
case AV_LOG_VERBOSE:
|
||||||
|
nldebug("FFMPEG(V): (%s) %s", module, msg);
|
||||||
|
break;
|
||||||
|
case AV_LOG_DEBUG:
|
||||||
|
nldebug("FFMPEG(D): (%s) %s", module, msg);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
nlinfo("FFMPEG: invalid log level:%d (%s) %s", level, module, msg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CFfmpegInstance
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CFfmpegInstance()
|
||||||
|
{
|
||||||
|
av_log_set_level(AV_LOG_DEBUG);
|
||||||
|
av_log_set_callback(nel_logger);
|
||||||
|
|
||||||
|
av_register_all();
|
||||||
|
|
||||||
|
//avformat_network_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual ~CFfmpegInstance()
|
||||||
|
{
|
||||||
|
//avformat_network_deinit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CFfmpegInstance ffmpeg;
|
||||||
|
|
||||||
|
// Send bytes to ffmpeg
|
||||||
|
int avio_read_packet(void *opaque, uint8 *buf, int buf_size)
|
||||||
|
{
|
||||||
|
NLSOUND::CAudioDecoderFfmpeg *decoder = static_cast<NLSOUND::CAudioDecoderFfmpeg *>(opaque);
|
||||||
|
NLMISC::IStream *stream = decoder->getStream();
|
||||||
|
nlassert(stream->isReading());
|
||||||
|
|
||||||
|
uint32 available = decoder->getStreamSize() - stream->getPos();
|
||||||
|
if (available == 0) return 0;
|
||||||
|
|
||||||
|
buf_size = FFMIN(buf_size, available);
|
||||||
|
stream->serialBuffer((uint8 *)buf, buf_size);
|
||||||
|
return buf_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
sint64 avio_seek(void *opaque, sint64 offset, int whence)
|
||||||
|
{
|
||||||
|
NLSOUND::CAudioDecoderFfmpeg *decoder = static_cast<NLSOUND::CAudioDecoderFfmpeg *>(opaque);
|
||||||
|
NLMISC::IStream *stream = decoder->getStream();
|
||||||
|
nlassert(stream->isReading());
|
||||||
|
|
||||||
|
NLMISC::IStream::TSeekOrigin origin;
|
||||||
|
switch(whence)
|
||||||
|
{
|
||||||
|
case SEEK_SET:
|
||||||
|
origin = NLMISC::IStream::begin;
|
||||||
|
break;
|
||||||
|
case SEEK_CUR:
|
||||||
|
origin = NLMISC::IStream::current;
|
||||||
|
break;
|
||||||
|
case SEEK_END:
|
||||||
|
origin = NLMISC::IStream::end;
|
||||||
|
break;
|
||||||
|
case AVSEEK_SIZE:
|
||||||
|
return decoder->getStreamSize();
|
||||||
|
default:
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream->seek((sint32) offset, origin);
|
||||||
|
return stream->getPos();
|
||||||
|
}
|
||||||
|
|
||||||
|
}//ns
|
||||||
|
|
||||||
|
namespace NLSOUND {
|
||||||
|
|
||||||
|
// swresample will convert audio to this format
|
||||||
|
#define FFMPEG_SAMPLE_RATE 44100
|
||||||
|
#define FFMPEG_CHANNELS 2
|
||||||
|
#define FFMPEG_CHANNEL_LAYOUT AV_CH_LAYOUT_STEREO
|
||||||
|
#define FFMPEG_BITS_PER_SAMPLE 16
|
||||||
|
#define FFMPEG_SAMPLE_FORMAT AV_SAMPLE_FMT_S16
|
||||||
|
|
||||||
|
CAudioDecoderFfmpeg::CAudioDecoderFfmpeg(NLMISC::IStream *stream, bool loop)
|
||||||
|
: IAudioDecoder(),
|
||||||
|
_Stream(stream), _Loop(loop), _IsMusicEnded(false), _StreamSize(0), _IsSupported(false),
|
||||||
|
_AvioContext(NULL), _FormatContext(NULL),
|
||||||
|
_AudioContext(NULL), _AudioStreamIndex(0),
|
||||||
|
_SwrContext(NULL)
|
||||||
|
{
|
||||||
|
_StreamOffset = stream->getPos();
|
||||||
|
stream->seek(0, NLMISC::IStream::end);
|
||||||
|
_StreamSize = stream->getPos();
|
||||||
|
stream->seek(_StreamOffset, NLMISC::IStream::begin);
|
||||||
|
|
||||||
|
try {
|
||||||
|
_FormatContext = avformat_alloc_context();
|
||||||
|
if (!_FormatContext)
|
||||||
|
throw Exception("Can't create AVFormatContext");
|
||||||
|
|
||||||
|
// avio_ctx_buffer can be reallocated by ffmpeg and assigned to avio_ctx->buffer
|
||||||
|
uint8 *avio_ctx_buffer = NULL;
|
||||||
|
size_t avio_ctx_buffer_size = 4096;
|
||||||
|
avio_ctx_buffer = static_cast<uint8 *>(av_malloc(avio_ctx_buffer_size));
|
||||||
|
if (!avio_ctx_buffer)
|
||||||
|
throw Exception("Can't allocate avio context buffer");
|
||||||
|
|
||||||
|
_AvioContext = avio_alloc_context(avio_ctx_buffer, avio_ctx_buffer_size, 0, this, &avio_read_packet, NULL, &avio_seek);
|
||||||
|
if (!_AvioContext)
|
||||||
|
throw Exception("Can't allocate avio context");
|
||||||
|
|
||||||
|
_FormatContext->pb = _AvioContext;
|
||||||
|
sint ret = avformat_open_input(&_FormatContext, NULL, NULL, NULL);
|
||||||
|
if (ret < 0)
|
||||||
|
throw Exception("avformat_open_input: %d", ret);
|
||||||
|
|
||||||
|
// find stream and then audio codec to see if ffmpeg supports this
|
||||||
|
_IsSupported = false;
|
||||||
|
if (avformat_find_stream_info(_FormatContext, NULL) >= 0)
|
||||||
|
{
|
||||||
|
_AudioStreamIndex = av_find_best_stream(_FormatContext, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);
|
||||||
|
if (_AudioStreamIndex >= 0)
|
||||||
|
{
|
||||||
|
_AudioContext = _FormatContext->streams[_AudioStreamIndex]->codec;
|
||||||
|
av_opt_set_int(_AudioContext, "refcounted_frames", 1, 0);
|
||||||
|
|
||||||
|
AVCodec *codec = avcodec_find_decoder(_AudioContext->codec_id);
|
||||||
|
if (codec != NULL && avcodec_open2(_AudioContext, codec, NULL) >= 0)
|
||||||
|
{
|
||||||
|
_IsSupported = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(...)
|
||||||
|
{
|
||||||
|
release();
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_IsSupported)
|
||||||
|
{
|
||||||
|
nlwarning("FFMPEG: Decoder created, unknown stream format / codec");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CAudioDecoderFfmpeg::~CAudioDecoderFfmpeg()
|
||||||
|
{
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CAudioDecoderFfmpeg::release()
|
||||||
|
{
|
||||||
|
if (_SwrContext)
|
||||||
|
swr_free(&_SwrContext);
|
||||||
|
|
||||||
|
if (_AudioContext)
|
||||||
|
avcodec_close(_AudioContext);
|
||||||
|
|
||||||
|
if (_FormatContext)
|
||||||
|
avformat_close_input(&_FormatContext);
|
||||||
|
|
||||||
|
if (_AvioContext && _AvioContext->buffer)
|
||||||
|
av_freep(&_AvioContext->buffer);
|
||||||
|
|
||||||
|
if (_AvioContext)
|
||||||
|
av_freep(&_AvioContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CAudioDecoderFfmpeg::isFormatSupported() const
|
||||||
|
{
|
||||||
|
return _IsSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get information on a music file.
|
||||||
|
bool CAudioDecoderFfmpeg::getInfo(NLMISC::IStream *stream, std::string &artist, std::string &title, float &length)
|
||||||
|
{
|
||||||
|
CAudioDecoderFfmpeg ffmpeg(stream, false);
|
||||||
|
if (!ffmpeg.isFormatSupported())
|
||||||
|
{
|
||||||
|
title.clear();
|
||||||
|
artist.clear();
|
||||||
|
length = 0.f;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVDictionaryEntry *tag = NULL;
|
||||||
|
while((tag = av_dict_get(ffmpeg._FormatContext->metadata, "", tag, AV_DICT_IGNORE_SUFFIX)))
|
||||||
|
{
|
||||||
|
if (!strcmp(tag->key, "artist"))
|
||||||
|
{
|
||||||
|
artist = tag->value;
|
||||||
|
}
|
||||||
|
else if (!strcmp(tag->key, "title"))
|
||||||
|
{
|
||||||
|
title = tag->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ffmpeg._FormatContext->duration != AV_NOPTS_VALUE)
|
||||||
|
{
|
||||||
|
length = ffmpeg._FormatContext->duration * av_q2d(AV_TIME_BASE_Q);
|
||||||
|
}
|
||||||
|
else if (ffmpeg._FormatContext->streams[ffmpeg._AudioStreamIndex]->duration != AV_NOPTS_VALUE)
|
||||||
|
{
|
||||||
|
length = ffmpeg._FormatContext->streams[ffmpeg._AudioStreamIndex]->duration * av_q2d(ffmpeg._FormatContext->streams[ffmpeg._AudioStreamIndex]->time_base);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
length = 0.f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 CAudioDecoderFfmpeg::getRequiredBytes()
|
||||||
|
{
|
||||||
|
return 0; // no minimum requirement of bytes to buffer out
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 CAudioDecoderFfmpeg::getNextBytes(uint8 *buffer, uint32 minimum, uint32 maximum)
|
||||||
|
{
|
||||||
|
if (_IsMusicEnded) return 0;
|
||||||
|
nlassert(minimum <= maximum); // can't have this..
|
||||||
|
|
||||||
|
// TODO: CStreamFileSource::play() will stall when there is no frames on warmup
|
||||||
|
// supported can be set false if there is an issue creating converter
|
||||||
|
if (!_IsSupported)
|
||||||
|
{
|
||||||
|
_IsMusicEnded = true;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 bytes_read = 0;
|
||||||
|
|
||||||
|
AVFrame frame = {0};
|
||||||
|
AVPacket packet = {0};
|
||||||
|
|
||||||
|
if (!_SwrContext)
|
||||||
|
{
|
||||||
|
sint64 in_channel_layout = av_get_default_channel_layout(_AudioContext->channels);
|
||||||
|
_SwrContext = swr_alloc_set_opts(NULL,
|
||||||
|
// output
|
||||||
|
FFMPEG_CHANNEL_LAYOUT, FFMPEG_SAMPLE_FORMAT, FFMPEG_SAMPLE_RATE,
|
||||||
|
// input
|
||||||
|
in_channel_layout, _AudioContext->sample_fmt, _AudioContext->sample_rate,
|
||||||
|
0, NULL);
|
||||||
|
swr_init(_SwrContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
sint ret;
|
||||||
|
while(bytes_read < minimum)
|
||||||
|
{
|
||||||
|
// read packet from stream
|
||||||
|
if ((ret = av_read_frame(_FormatContext, &packet)) < 0)
|
||||||
|
{
|
||||||
|
_IsMusicEnded = true;
|
||||||
|
// TODO: looping
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.stream_index == _AudioStreamIndex)
|
||||||
|
{
|
||||||
|
// packet can contain multiple frames
|
||||||
|
AVPacket first = packet;
|
||||||
|
int got_frame = 0;
|
||||||
|
do {
|
||||||
|
got_frame = 0;
|
||||||
|
ret = avcodec_decode_audio4(_AudioContext, &frame, &got_frame, &packet);
|
||||||
|
if (ret < 0)
|
||||||
|
{
|
||||||
|
nlwarning("FFMPEG: error decoding audio frame: %s", av_err2string(ret).c_str());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
packet.size -= ret;
|
||||||
|
packet.data += ret;
|
||||||
|
|
||||||
|
if (got_frame)
|
||||||
|
{
|
||||||
|
uint32 out_bps = av_get_bytes_per_sample(FFMPEG_SAMPLE_FORMAT) * FFMPEG_CHANNELS;
|
||||||
|
uint32 max_samples = (maximum - bytes_read) / out_bps;
|
||||||
|
|
||||||
|
uint32 out_samples = av_rescale_rnd(swr_get_delay(_SwrContext, _AudioContext->sample_rate) + frame.nb_samples,
|
||||||
|
FFMPEG_SAMPLE_RATE, _AudioContext->sample_rate, AV_ROUND_UP);
|
||||||
|
|
||||||
|
if (max_samples > out_samples)
|
||||||
|
max_samples = out_samples;
|
||||||
|
|
||||||
|
uint32 converted = swr_convert(_SwrContext, &buffer, max_samples, (const uint8 **)frame.extended_data, frame.nb_samples);
|
||||||
|
uint32 size = out_bps * converted;
|
||||||
|
|
||||||
|
bytes_read += size;
|
||||||
|
buffer += size;
|
||||||
|
|
||||||
|
av_frame_unref(&frame);
|
||||||
|
}
|
||||||
|
} while (got_frame && packet.size > 0);
|
||||||
|
|
||||||
|
av_packet_unref(&first);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ret = 0;
|
||||||
|
av_packet_unref(&packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes_read;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8 CAudioDecoderFfmpeg::getChannels()
|
||||||
|
{
|
||||||
|
return FFMPEG_CHANNELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint CAudioDecoderFfmpeg::getSamplesPerSec()
|
||||||
|
{
|
||||||
|
return FFMPEG_SAMPLE_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8 CAudioDecoderFfmpeg::getBitsPerSample()
|
||||||
|
{
|
||||||
|
return FFMPEG_BITS_PER_SAMPLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CAudioDecoderFfmpeg::isMusicEnded()
|
||||||
|
{
|
||||||
|
return _IsMusicEnded;
|
||||||
|
}
|
||||||
|
|
||||||
|
float CAudioDecoderFfmpeg::getLength()
|
||||||
|
{
|
||||||
|
printf(">> CAudioDecoderFfmpeg::getLength\n");
|
||||||
|
// TODO: return (float)ov_time_total(&_OggVorbisFile, -1);
|
||||||
|
return 0.f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CAudioDecoderFfmpeg::setLooping(bool loop)
|
||||||
|
{
|
||||||
|
_Loop = loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
} /* namespace NLSOUND */
|
||||||
|
|
||||||
|
/* end of file */
|
|
@ -372,22 +372,10 @@ public:
|
||||||
// no format supported
|
// no format supported
|
||||||
if (extensions.empty()) return;
|
if (extensions.empty()) return;
|
||||||
|
|
||||||
bool oggSupported = false;
|
|
||||||
bool mp3Supported = false;
|
|
||||||
|
|
||||||
std::string message;
|
std::string message;
|
||||||
for(uint i = 0; i < extensions.size(); ++i)
|
for(uint i = 0; i < extensions.size(); ++i)
|
||||||
{
|
{
|
||||||
if (extensions[i] == "ogg")
|
message += " " + extensions[i];
|
||||||
{
|
|
||||||
oggSupported = true;
|
|
||||||
message += " ogg";
|
|
||||||
}
|
|
||||||
else if (extensions[i] == "mp3")
|
|
||||||
{
|
|
||||||
mp3Supported = true;
|
|
||||||
message += " mp3";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
message += " m3u m3u8";
|
message += " m3u m3u8";
|
||||||
nlinfo("Media player supports: '%s'", message.substr(1).c_str());
|
nlinfo("Media player supports: '%s'", message.substr(1).c_str());
|
||||||
|
@ -404,15 +392,9 @@ public:
|
||||||
for (i = 0; i < filesToProcess.size(); ++i)
|
for (i = 0; i < filesToProcess.size(); ++i)
|
||||||
{
|
{
|
||||||
std::string ext = toLower(CFile::getExtension(filesToProcess[i]));
|
std::string ext = toLower(CFile::getExtension(filesToProcess[i]));
|
||||||
if (ext == "ogg")
|
if (std::find(extensions.begin(), extensions.end(), ext) != extensions.end())
|
||||||
{
|
{
|
||||||
if (oggSupported)
|
filenames.push_back(filesToProcess[i]);
|
||||||
filenames.push_back(filesToProcess[i]);
|
|
||||||
}
|
|
||||||
else if (ext == "mp3" || ext == "mp2" || ext == "mp1")
|
|
||||||
{
|
|
||||||
if (mp3Supported)
|
|
||||||
filenames.push_back(filesToProcess[i]);
|
|
||||||
}
|
}
|
||||||
else if (ext == "m3u" || ext == "m3u8")
|
else if (ext == "m3u" || ext == "m3u8")
|
||||||
{
|
{
|
||||||
|
@ -448,6 +430,7 @@ public:
|
||||||
|
|
||||||
CMusicPlayer::CSongs song;
|
CMusicPlayer::CSongs song;
|
||||||
song.Filename = filenames[i];
|
song.Filename = filenames[i];
|
||||||
|
// TODO: cache the result for next refresh
|
||||||
SoundMngr->getMixer()->getSongTitle(filenames[i], song.Title, song.Length);
|
SoundMngr->getMixer()->getSongTitle(filenames[i], song.Title, song.Length);
|
||||||
if (song.Length > 0)
|
if (song.Length > 0)
|
||||||
songs.push_back (song);
|
songs.push_back (song);
|
||||||
|
|
Loading…
Reference in a new issue