diff --git a/ffmpeg-audio/CMakeLists.txt b/ffmpeg-audio/CMakeLists.txt index 6814231..664b1a0 100644 --- a/ffmpeg-audio/CMakeLists.txt +++ b/ffmpeg-audio/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(ffmpeg_audio SHARED ffmpeg_audio.cpp ffmpeg_audio.h ../ffi_version.h + ffmpeg_audio_refactored.cpp ) add_executable(demo_ffmpeg_audio diff --git a/ffmpeg-audio/ffmpeg_audio_refactored.cpp b/ffmpeg-audio/ffmpeg_audio_refactored.cpp new file mode 100644 index 0000000..e1edd18 --- /dev/null +++ b/ffmpeg-audio/ffmpeg_audio_refactored.cpp @@ -0,0 +1,900 @@ +/* + * Audio-only FFmpeg wrapper with a plain C ABI. + * + * This implementation intentionally hides FFmpeg concepts from the public API: + * + * - no stream_index in the API; + * - no AVPacket/package object in the API; + * - no explicit decoder object in the API; + * - no metadata/tag API exposed to C callers. + * + * Internally the instance owns everything needed to decode one selected audio + * stream. The caller simply opens a file and repeatedly calls fmpg_decode_next(). + */ + +#include "ffmpeg_audio.h" +#include "../ffi_version.h" + +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +} + +#define MSG0(type, msg) fprintf(stderr, type ":");fprintf(stderr, "%s", __FUNCTION__);fprintf(stderr, ": %s\n", msg) +#define MSG1(type, msg, a) fprintf(stderr, type ":");fprintf(stderr, "%s", __FUNCTION__);fprintf(stderr, ": " msg "\n", a) +#define MSG2(type, msg, a, b) fprintf(stderr, type ":");fprintf(stderr, "%s", __FUNCTION__);fprintf(stderr, ": " msg "\n", a, b) +#define MSG3(type, msg, a, b, c) fprintf(stderr, type ":");fprintf(stderr, "%s", __FUNCTION__);fprintf(stderr, ": " msg "\n", a, b, c) + +#define INFO0(msg) MSG0("info", msg) +#define INFO1(msg, a) MSG1("info", msg, a) +#define INFO2(msg, a, b) MSG2("info", msg, a, b) +#define INFO3(msg, a, b, c) MSG3("info", msg, a, b, c) + +#define ERROR0(msg) MSG0("error", msg) +#define ERROR1(msg, a) MSG1("error", msg, a) +#define ERROR2(msg, a, b) MSG2("error", msg, a, b) +#define ERROR3(msg, a, b, c) MSG3("error", msg, a, b, c) + +static constexpr int FMPG_OUTPUT_BITS = 32; +static constexpr int FMPG_OUTPUT_BYTES = 4; +static constexpr AVSampleFormat FMPG_OUTPUT_FMT = AV_SAMPLE_FMT_S32; + +struct audio_info_storage { + int audio_stream_count = 0; + int selected_stream_index = -1; /* Internal FFmpeg stream index. */ + int sample_rate = 0; + int channels = 0; + int64_t duration_ms = -1; + int64_t duration_samples = -1; /* Output sample frames. */ + + void clear() + { + audio_stream_count = 0; + selected_stream_index = -1; + sample_rate = 0; + channels = 0; + duration_ms = -1; + duration_samples = -1; + } +}; + +struct decoder_storage { + const AVCodec *codec = nullptr; + AVCodecContext *codec_ctx = nullptr; + AVFrame *frame = nullptr; + SwrContext *swr_ctx = nullptr; + + std::vector pcm; + + bool eof_seen = false; + bool decoder_drained = false; + + double timecode = 0.0; + + int64_t last_samples = 0; + int64_t buffer_start_sample = 0; + int64_t next_sample_position = 0; + + /* >= 0 while a seek has requested us to discard decoded pre-roll samples. */ + int64_t discard_until_sample = -1; + + void clear_output() + { + pcm.clear(); + last_samples = 0; + buffer_start_sample = next_sample_position; + } + + void free_ffmpeg() + { + avcodec_free_context(&codec_ctx); + av_frame_free(&frame); + swr_free(&swr_ctx); + codec = nullptr; + pcm.clear(); + eof_seen = false; + decoder_drained = false; + timecode = 0.0; + last_samples = 0; + buffer_start_sample = 0; + next_sample_position = 0; + discard_until_sample = -1; + } + + ~decoder_storage() + { + free_ffmpeg(); + } +}; + +struct __fmpg_instance__ { + bool opened = false; + AVFormatContext *format_ctx = nullptr; + audio_info_storage audio_info; + decoder_storage decoder; + + ~__fmpg_instance__() + { + if (format_ctx) { + avformat_close_input(&format_ctx); + } + } +}; + +static int count_audio_streams(const AVFormatContext *ctx) +{ + if (!ctx) { + return 0; + } + + int count = 0; + for (unsigned i = 0; i < ctx->nb_streams; ++i) { + const AVCodecParameters *par = ctx->streams[i]->codecpar; + if (par && par->codec_type == AVMEDIA_TYPE_AUDIO) { + ++count; + } + } + return count; +} + +static int64_t milliseconds_from_seconds(double seconds) +{ + if (seconds < 0.0) { + return -1; + } + return static_cast(seconds * 1000.0 + 0.5); +} + +static int64_t samples_from_seconds(double seconds, int sample_rate) +{ + if (seconds < 0.0 || sample_rate <= 0) { + return -1; + } + return static_cast(seconds * static_cast(sample_rate) + + 0.5); +} + +static double stream_duration_seconds(const AVStream *stream) +{ + if (!stream || stream->duration == AV_NOPTS_VALUE) { + return -1.0; + } + return static_cast(stream->duration) * av_q2d(stream->time_base); +} + +static double format_duration_seconds(const AVFormatContext *ctx) +{ + if (!ctx || ctx->duration == AV_NOPTS_VALUE) { + return -1.0; + } + return static_cast(ctx->duration) / + static_cast(AV_TIME_BASE); +} + +static int64_t timestamp_to_samples(int64_t timestamp, + const AVStream *stream, + int sample_rate) +{ + if (!stream || timestamp == AV_NOPTS_VALUE || sample_rate <= 0) { + return -1; + } + + const double seconds = static_cast(timestamp) * + av_q2d(stream->time_base); + return samples_from_seconds(seconds, sample_rate); +} + +static bool fill_audio_info(fmpg_instance *self) +{ + AVFormatContext *ctx = self->format_ctx; + + self->audio_info.clear(); + self->audio_info.audio_stream_count = count_audio_streams(ctx); + + const int best = av_find_best_stream(ctx, + AVMEDIA_TYPE_AUDIO, + -1, + -1, + nullptr, + 0); + if (best < 0) { + return false; + } + + AVStream *stream = ctx->streams[best]; + const AVCodecParameters *par = stream->codecpar; + + if (!par || par->codec_type != AVMEDIA_TYPE_AUDIO || + par->sample_rate <= 0 || par->ch_layout.nb_channels <= 0) { + return false; + } + + self->audio_info.selected_stream_index = best; + self->audio_info.sample_rate = par->sample_rate; + self->audio_info.channels = par->ch_layout.nb_channels; + + double seconds = stream_duration_seconds(stream); + if (seconds < 0.0) { + seconds = format_duration_seconds(ctx); + } + + self->audio_info.duration_ms = milliseconds_from_seconds(seconds); + self->audio_info.duration_samples = + samples_from_seconds(seconds, self->audio_info.sample_rate); + + return true; +} + +static bool instance_ready(const fmpg_instance *instance) +{ + return instance && instance->opened && instance->format_ctx && + instance->audio_info.selected_stream_index >= 0 && + instance->decoder.codec_ctx && instance->decoder.swr_ctx; +} + +static bool init_codec_context(fmpg_instance *self) +{ + decoder_storage &dec = self->decoder; + const int stream_index = self->audio_info.selected_stream_index; + const AVCodecParameters *par = + self->format_ctx->streams[stream_index]->codecpar; + + dec.codec = avcodec_find_decoder(par->codec_id); + if (!dec.codec) { + return false; + } + + dec.codec_ctx = avcodec_alloc_context3(dec.codec); + if (!dec.codec_ctx) { + return false; + } + + if (avcodec_parameters_to_context(dec.codec_ctx, par) < 0) { + return false; + } + + if (avcodec_open2(dec.codec_ctx, dec.codec, nullptr) < 0) { + return false; + } + + dec.frame = av_frame_alloc(); + return dec.frame != nullptr; +} + +static bool init_resampler(fmpg_instance *self) +{ + decoder_storage &dec = self->decoder; + const AVChannelLayout *layout = &dec.codec_ctx->ch_layout; + + if (layout->nb_channels <= 0 || dec.codec_ctx->sample_rate <= 0) { + return false; + } + + if (swr_alloc_set_opts2(&dec.swr_ctx, + layout, + FMPG_OUTPUT_FMT, + dec.codec_ctx->sample_rate, + layout, + dec.codec_ctx->sample_fmt, + dec.codec_ctx->sample_rate, + 0, + nullptr) < 0) { + return false; + } + + return swr_init(dec.swr_ctx) >= 0; +} + +static bool init_decoder(fmpg_instance *self) +{ + self->decoder.free_ffmpeg(); + return init_codec_context(self) && init_resampler(self); +} + +int fmpg_compatible_ffmpeg() +{ + int compiled_avformat_major = LIBAVFORMAT_VERSION_MAJOR; + int compiled_avcodec_major = LIBAVCODEC_VERSION_MAJOR; + int compiled_swresample_major = LIBSWRESAMPLE_VERSION_MAJOR; + int compiled_avutil_major = LIBAVUTIL_VERSION_MAJOR; + + int current_avformat_major = AV_VERSION_MAJOR(avformat_version()); + int current_avcodec_major = AV_VERSION_MAJOR(avcodec_version()); + int current_swresample_major = AV_VERSION_MAJOR(swresample_version()); + int current_avutil_major = AV_VERSION_MAJOR(avutil_version()); + + auto comp = [](const char *lib, int cv, int rv) { + if (cv != rv) { + ERROR3("FFMPEG %s Major versions not equal. Compile time: %d, runtime: %d", lib, cv, rv); + } + }; + + comp("AVFormat", compiled_avformat_major, current_avformat_major); + comp("AVCodec", compiled_avcodec_major, current_avcodec_major); + comp("SWResample", compiled_swresample_major, current_swresample_major); + comp("AVUtil", compiled_avutil_major, current_avutil_major); + + int compatible = (compiled_avformat_major == current_avformat_major) && + (compiled_avcodec_major == current_avcodec_major) && + (compiled_swresample_major == current_swresample_major) && + (compiled_avutil_major == current_avutil_major); + + return compatible; +} + +fmpg_instance *fmpg_init(void) +{ + if (!fmpg_compatible_ffmpeg()) { + ERROR0("Compiled major ffmpeg version ≃ runtime major version, not compatible."); + return nullptr; + } + + try { + return new fmpg_instance(); + } catch (...) { + return nullptr; + } +} + +void fmpg_free(fmpg_instance *instance) +{ + delete instance; +} + +int fmpg_open_file(fmpg_instance *instance, const char *filename) +{ + if (!instance || instance->opened || !filename) { + return 0; + } + + if (instance->format_ctx != nullptr) { + return 0; + } + + int r = avformat_open_input(&instance->format_ctx, + filename, + nullptr, + nullptr); + if (r < 0) { + fmpg_close(instance); + return 0; + } + + + if (avformat_find_stream_info(instance->format_ctx, nullptr) < 0) { + fmpg_close(instance); + return 0; + } + + + if (!fill_audio_info(instance) || !init_decoder(instance)) { + fmpg_close(instance); + return 0; + } + + instance->opened = true; + return 1; +} + +void fmpg_close(fmpg_instance *instance) +{ + if (!instance) { + return; + } + + instance->decoder.free_ffmpeg(); + + if (instance->format_ctx) { + avformat_close_input(&instance->format_ctx); + } + + instance->opened = false; + instance->audio_info.clear(); +} + +int fmpg_is_open(fmpg_instance *instance) +{ + return instance_ready(instance) ? 1 : 0; +} + +int fmpg_audio_stream_count(fmpg_instance *instance) +{ + return instance && instance->opened ? instance->audio_info.audio_stream_count + : 0; +} + +int fmpg_audio_sample_rate(fmpg_instance *instance) +{ + return instance_ready(instance) ? instance->audio_info.sample_rate : 0; +} + +int fmpg_audio_channels(fmpg_instance *instance) +{ + return instance_ready(instance) ? instance->audio_info.channels : 0; +} + +int fmpg_audio_bits_per_sample(fmpg_instance *) +{ + return FMPG_OUTPUT_BITS; +} + +int fmpg_audio_bytes_per_sample(fmpg_instance *) +{ + return FMPG_OUTPUT_BYTES; +} + +int64_t fmpg_duration_ms(fmpg_instance *instance) +{ + return instance_ready(instance) ? instance->audio_info.duration_ms : -1; +} + +int64_t fmpg_duration_samples(fmpg_instance *instance) +{ + return instance_ready(instance) ? instance->audio_info.duration_samples : -1; +} + +static bool append_bytes(decoder_storage &dec, + const uint8_t *src, + size_t bytes) +{ + if (!bytes) { + return true; + } + + if (bytes > static_cast(std::numeric_limits::max()) - + dec.pcm.size()) { + return false; + } + + try { + const size_t old_size = dec.pcm.size(); + dec.pcm.resize(old_size + bytes); + std::memcpy(dec.pcm.data() + old_size, src, bytes); + return true; + } catch (...) { + return false; + } +} + +static bool append_converted_frame(fmpg_instance *self, + const AVFrame *frame) +{ + decoder_storage &dec = self->decoder; + const int channels = self->audio_info.channels; + const int sample_rate = self->audio_info.sample_rate; + + if (channels <= 0 || frame->nb_samples <= 0) { + return true; + } + + const int max_out_samples = swr_get_out_samples(dec.swr_ctx, + frame->nb_samples); + if (max_out_samples <= 0) { + return false; + } + + const int max_bytes = av_samples_get_buffer_size(nullptr, + channels, + max_out_samples, + FMPG_OUTPUT_FMT, + 1); + if (max_bytes <= 0) { + return false; + } + + std::vector tmp(static_cast(max_bytes)); + uint8_t *out_planes[1] = { tmp.data() }; + + const int out_samples = swr_convert(dec.swr_ctx, + out_planes, + max_out_samples, + const_cast(frame->data), + frame->nb_samples); + if (out_samples < 0) { + return false; + } + + const int used_bytes = av_samples_get_buffer_size(nullptr, + channels, + out_samples, + FMPG_OUTPUT_FMT, + 1); + if (used_bytes < 0) { + return false; + } + + const int stream_index = self->audio_info.selected_stream_index; + const AVStream *stream = self->format_ctx->streams[stream_index]; + + int64_t frame_start = timestamp_to_samples(frame->best_effort_timestamp, + stream, + sample_rate); + if (frame_start < 0) { + frame_start = dec.next_sample_position; + } + + int64_t keep_start = frame_start; + int keep_samples = out_samples; + size_t byte_offset = 0; + + /* + * After seeking, FFmpeg may first return decoded samples from before the + * requested position. Discard them so public sample positions refer to the + * actual music position requested by the caller. + */ + if (dec.discard_until_sample >= 0) { + const int64_t target = dec.discard_until_sample; + const int64_t frame_end = frame_start + out_samples; + + if (frame_end <= target) { + dec.next_sample_position = frame_end; + return true; + } + + if (frame_start < target) { + const int64_t drop = target - frame_start; + if (drop > 0 && drop < out_samples) { + byte_offset = static_cast(drop) * + static_cast(channels) * + FMPG_OUTPUT_BYTES; + keep_samples = static_cast(out_samples - drop); + keep_start = target; + } + } + + dec.discard_until_sample = -1; + } + + if (keep_samples <= 0) { + dec.next_sample_position = frame_start + out_samples; + return true; + } + + if (dec.pcm.empty()) { + dec.buffer_start_sample = keep_start; + dec.timecode = static_cast(keep_start) / + static_cast(sample_rate); + } + + const size_t keep_bytes = static_cast(keep_samples) * + static_cast(channels) * + FMPG_OUTPUT_BYTES; + + if (!append_bytes(dec, tmp.data() + byte_offset, keep_bytes)) { + return false; + } + + dec.last_samples += keep_samples; + dec.next_sample_position = keep_start + keep_samples; + return true; +} + +static int receive_available_frames(fmpg_instance *self) +{ + decoder_storage &dec = self->decoder; + int produced = 0; + + for (;;) { + const int ret = avcodec_receive_frame(dec.codec_ctx, dec.frame); + + if (ret == AVERROR(EAGAIN)) { + return produced; + } + + if (ret == AVERROR_EOF) { + dec.decoder_drained = true; + return produced; + } + + if (ret < 0) { + return -1; + } + + if (!append_converted_frame(self, dec.frame)) { + av_frame_unref(dec.frame); + return -1; + } + + produced = dec.last_samples > 0 ? 1 : produced; + av_frame_unref(dec.frame); + } +} + +static bool read_selected_audio_packet(fmpg_instance *self, AVPacket *pkt) +{ + const int wanted_stream = self->audio_info.selected_stream_index; + + for (;;) { + const int ret = av_read_frame(self->format_ctx, pkt); + if (ret < 0) { + return false; + } + + if (pkt->stream_index == wanted_stream) { + return true; + } + + av_packet_unref(pkt); + } +} + +static int drain_resampler(fmpg_instance *self) +{ + decoder_storage &dec = self->decoder; + const int channels = self->audio_info.channels; + const int sample_rate = self->audio_info.sample_rate; + int produced = 0; + + for (;;) { + const int delay = static_cast(swr_get_delay(dec.swr_ctx, + sample_rate)); + if (delay <= 0) { + break; + } + + const int max_bytes = av_samples_get_buffer_size(nullptr, + channels, + delay, + FMPG_OUTPUT_FMT, + 1); + if (max_bytes <= 0) { + break; + } + + std::vector tmp(static_cast(max_bytes)); + uint8_t *out_planes[1] = { tmp.data() }; + + const int out_samples = swr_convert(dec.swr_ctx, + out_planes, + delay, + nullptr, + 0); + if (out_samples <= 0) { + break; + } + + const int used_bytes = av_samples_get_buffer_size(nullptr, + channels, + out_samples, + FMPG_OUTPUT_FMT, + 1); + if (used_bytes < 0) { + break; + } + + if (dec.pcm.empty()) { + dec.buffer_start_sample = dec.next_sample_position; + dec.timecode = static_cast(dec.buffer_start_sample) / + static_cast(sample_rate); + } + + if (!append_bytes(dec, tmp.data(), static_cast(used_bytes))) { + return -1; + } + + dec.last_samples += out_samples; + dec.next_sample_position += out_samples; + produced = 1; + } + + return produced; +} + +int fmpg_decode_next(fmpg_instance *instance) +{ + if (!instance_ready(instance)) { + return 0; + } + + decoder_storage &dec = instance->decoder; + dec.clear_output(); + + /* First return any frames that are already pending in the decoder. */ + int produced = receive_available_frames(instance); + if (produced < 0) { + return 0; + } + if (produced > 0 && !dec.pcm.empty()) { + return 1; + } + + AVPacket *pkt = av_packet_alloc(); + if (!pkt) { + return 0; + } + + while (!dec.eof_seen) { + if (!read_selected_audio_packet(instance, pkt)) { + dec.eof_seen = true; + av_packet_unref(pkt); + break; + } + + int ret = avcodec_send_packet(dec.codec_ctx, pkt); + av_packet_unref(pkt); + + if (ret == AVERROR(EAGAIN)) { + produced = receive_available_frames(instance); + if (produced < 0) { + av_packet_free(&pkt); + return 0; + } + if (produced > 0 && !dec.pcm.empty()) { + av_packet_free(&pkt); + return 1; + } + continue; + } + + if (ret < 0) { + av_packet_free(&pkt); + return 0; + } + + produced = receive_available_frames(instance); + if (produced < 0) { + av_packet_free(&pkt); + return 0; + } + if (produced > 0 && !dec.pcm.empty()) { + av_packet_free(&pkt); + return 1; + } + } + + av_packet_free(&pkt); + + if (!dec.decoder_drained) { + const int ret = avcodec_send_packet(dec.codec_ctx, nullptr); + if (ret < 0 && ret != AVERROR_EOF) { + return 0; + } + + produced = receive_available_frames(instance); + if (produced < 0) { + return 0; + } + if (produced > 0 && !dec.pcm.empty()) { + return 1; + } + } + + produced = drain_resampler(instance); + return produced > 0 && !dec.pcm.empty() ? 1 : 0; +} + +int fmpg_seek_ms(fmpg_instance *instance, int64_t target_pos_ms) +{ + if (!instance_ready(instance) || target_pos_ms < 0) { + return 0; + } + + const int stream_index = instance->audio_info.selected_stream_index; + AVStream *stream = instance->format_ctx->streams[stream_index]; + + const int64_t pos_us = av_rescale(target_pos_ms, AV_TIME_BASE, 1000); + const int64_t stream_ts = av_rescale_q(pos_us, + AV_TIME_BASE_Q, + stream->time_base); + + if (av_seek_frame(instance->format_ctx, + stream_index, + stream_ts, + AVSEEK_FLAG_BACKWARD) < 0) { + return 0; + } + + decoder_storage &dec = instance->decoder; + const int64_t target_samples = samples_from_seconds(target_pos_ms / 1000.0, + instance->audio_info.sample_rate); + + avcodec_flush_buffers(dec.codec_ctx); + swr_close(dec.swr_ctx); + if (swr_init(dec.swr_ctx) < 0) { + return 0; + } + + dec.pcm.clear(); + dec.last_samples = 0; + dec.buffer_start_sample = target_samples >= 0 ? target_samples : 0; + dec.next_sample_position = target_samples >= 0 ? target_samples : 0; + dec.discard_until_sample = target_samples; + dec.timecode = target_pos_ms / 1000.0; + dec.eof_seen = false; + dec.decoder_drained = false; + + return 1; +} + +const uint8_t *fmpg_buffer(fmpg_instance *instance) +{ + return instance && !instance->decoder.pcm.empty() + ? instance->decoder.pcm.data() + : nullptr; +} + +int fmpg_buffer_size(fmpg_instance *instance) +{ + if (!instance || instance->decoder.pcm.size() > + static_cast(std::numeric_limits::max())) { + return 0; + } + return static_cast(instance->decoder.pcm.size()); +} + +int64_t fmpg_buffer_samples(fmpg_instance *instance) +{ + return instance ? instance->decoder.last_samples : 0; +} + +int64_t fmpg_buffer_start_sample(fmpg_instance *instance) +{ + return instance ? instance->decoder.buffer_start_sample : 0; +} + +int64_t fmpg_buffer_end_sample(fmpg_instance *instance) +{ + if (!instance) { + return 0; + } + return instance->decoder.buffer_start_sample + instance->decoder.last_samples; +} + +int64_t fmpg_sample_position(fmpg_instance *instance) +{ + return instance ? instance->decoder.next_sample_position : 0; +} + +double fmpg_timecode(fmpg_instance *instance) +{ + return instance ? instance->decoder.timecode : 0.0; +} + +const char *fmpg_ffmpeg_version() +{ + static char *version = nullptr; + + if (version == nullptr) { + version = static_cast(malloc(1024)); + } + sprintf(version, "avformat: %u.%u.%u (%d), avcodec: %u.%u.%u (%d), swresample: %u.%u.%u (%d), avutil: %u.%u.%u (%d)", + LIBAVFORMAT_VERSION_MAJOR, LIBAVFORMAT_VERSION_MINOR, LIBAVFORMAT_VERSION_MICRO, LIBAVFORMAT_VERSION_INT, + LIBAVCODEC_VERSION_MAJOR, LIBAVCODEC_VERSION_MINOR, LIBAVCODEC_VERSION_MICRO, LIBAVCODEC_VERSION_INT, + LIBSWRESAMPLE_VERSION_MAJOR, LIBSWRESAMPLE_VERSION_MINOR, LIBSWRESAMPLE_VERSION_MICRO, LIBSWRESAMPLE_VERSION_INT, + LIBAVUTIL_VERSION_MAJOR, LIBAVUTIL_VERSION_MINOR, LIBAVUTIL_VERSION_MICRO, LIBAVUTIL_VERSION_INT + ); + return version; +} + +const char *fmpg_int_version2string(int ver) +{ + static char *version = nullptr; + + if (version == nullptr) { + version = static_cast(malloc(1024)); + } + + int major = AV_VERSION_MAJOR(ver); + int minor = AV_VERSION_MINOR(ver); + int micro = AV_VERSION_MICRO(ver); + sprintf(version, "%u.%u.%u", major, minor, micro); + + return version; +} + +int fmpg_version() +{ + return ffi_version(); +}