Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ jobs:
-Dnfs=disabled \
-Dnlohmann_json=enabled \
-Dsidplay=disabled \
-Dpsgplay=disabled \
-Dudisks=disabled \
-Dupnp=disabled \
-Dwavpack=disabled \
Expand Down
12 changes: 12 additions & 0 deletions doc/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,18 @@ C64 SID decoder based on `libsidplayfp <https://sourceforge.net/projects/sidplay
* - **basic**
- Only libsidplayfp. Absolute path to basic rom image file.

psgplay
-------

Decodes Atari SNDH files using `psgplay <https://github.com/frno7/psgplay>`_.

* - Setting
- Description
* - **default_songlength SECONDS**
- This is the default playing time in seconds, for songs without a duration. A value of 0 means play indefinitely.
* - **default_genre GENRE**
- Optional default genre for SNDH songs.

sndfile
-------

Expand Down
1 change: 1 addition & 0 deletions meson_options.txt
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ option('mpcdec', type: 'feature', description: 'Musepack decoder plugin')
option('mpg123', type: 'feature', description: 'MP3 decoder using libmpg123')
option('opus', type: 'feature', description: 'Opus decoder plugin')
option('sidplay', type: 'feature', description: 'C64 SID support via libsidplayfp or libsidplay2')
option('psgplay', type: 'feature', description: 'Atari SNDH support via PSG play')
option('sndfile', type: 'feature', description: 'libsndfile decoder plugin')
option('tremor', type: 'feature', description: 'Fixed-point vorbis decoder plugin')
option('vgmstream', type: 'feature', description: 'vgmstream decoder plugin')
Expand Down
4 changes: 4 additions & 0 deletions src/decoder/DecoderList.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
#include "plugins/MpcdecDecoderPlugin.hxx"
#include "plugins/FluidsynthDecoderPlugin.hxx"
#include "plugins/SidplayDecoderPlugin.hxx"
#include "plugins/PsgplayDecoderPlugin.hxx"
#include "Log.hxx"
#include "PluginUnavailable.hxx"

Expand Down Expand Up @@ -84,6 +85,9 @@ constinit const struct DecoderPlugin *const decoder_plugins[] = {
#ifdef ENABLE_SIDPLAY
&sidplay_decoder_plugin,
#endif
#ifdef ENABLE_PSGPLAY
&psgplay_decoder_plugin,
#endif
#ifdef ENABLE_WILDMIDI
&wildmidi_decoder_plugin,
#endif
Expand Down
334 changes: 334 additions & 0 deletions src/decoder/plugins/PsgplayDecoderPlugin.cxx
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright The Music Player Daemon Project

#include "PsgplayDecoderPlugin.hxx"
#include "decoder/Features.h"
#include "../DecoderAPI.hxx"
#include "tag/Handler.hxx"
#include "tag/Builder.hxx"
#include "song/DetachedSong.hxx"
#include "fs/NarrowPath.hxx"
#include "fs/Path.hxx"
#include "fs/AllocatedPath.hxx"
#include "lib/fmt/PathFormatter.hxx"
#include "lib/fmt/RuntimeError.hxx"
#include "io/FileReader.hxx"
#include "util/AllocatedArray.hxx"
#include "util/Domain.hxx"
#include "util/ScopeExit.hxx"
#include "util/StringCompare.hxx"

extern "C" {
#include <psgplay/psgplay.h>
#include <psgplay/sndh.h>
#include <psgplay/stereo.h>
}

#include <fmt/format.h>

#define SUBTUNE_PREFIX "tune_"

/*
* The Atari ST family of computers has a 24-bit bus,
* so 16 MiB is more than enough for any SNDH file.
*/
#define MAX_SNDH_FILE_SIZE 0x1000000

static constexpr Domain psgplay_domain("psgplay");

struct PsgplayGlobal {
unsigned default_songlength;
std::string default_genre;

explicit PsgplayGlobal(const ConfigBlock &block);
};

static PsgplayGlobal *psgplay_global;

inline
PsgplayGlobal::PsgplayGlobal(const ConfigBlock &block)
{
default_songlength = block.GetPositiveValue("default_songlength", 0U);

default_genre = block.GetBlockValue("default_genre", "");
}

static bool
psgplay_init(const ConfigBlock &block)
{
psgplay_global = new PsgplayGlobal(block);
return true;
}

static void
psgplay_finish() noexcept
{
delete psgplay_global;
}

struct PsgplayContainerPath {
AllocatedPath path;
unsigned track;
};

static AllocatedArray<std::byte>
psgplay_read_file(const Path &path_fs)
{
FileReader file(path_fs);

const size_t size = file.GetSize();

if (size > MAX_SNDH_FILE_SIZE)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve tested the file size limit:

2026-03-29T18:51:17 exception: File larger than 16777216 bytes: waytoolarge.sndh
terminate called after throwing an instance of 'std::runtime_error'
  what():  File larger than 16777216 bytes: waytoolarge.sndh

“Terminating” in this context apparently means that the whole MPD program quits. :-)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's because you call it from a noexcept function. This problem is entirely within your new code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed noexcept from psgplay_scan_file(), but MPD terminates anyway, so it doesn’t seem to work regardless. Oh, well.

I fixed another problem though: with SignedSongTime::Negative() it can play tunes with unspecified durations (exactly like the SID play plugin does).

throw FmtRuntimeError("File larger than {} bytes: {}",
MAX_SNDH_FILE_SIZE, path_fs);

AllocatedArray<std::byte> data(size);

file.ReadFull(data);

return data;
}

[[gnu::pure]]
static unsigned
psgplay_subtune_track(const char *base) noexcept
{
base = StringAfterPrefix(base, SUBTUNE_PREFIX);
if (base == nullptr)
return 0;

char *endptr;
auto track = strtoul(base, &endptr, 10);
if (endptr == base || *endptr != '.')
return 0;

return track;
}

/**
* Returns the file path stripped of any /tune_xxx.* subtune suffix
* and the track number (or 1 if no "tune_xxx" suffix is present).
*/
static PsgplayContainerPath
psgplay_container_from_path(Path path_fs) noexcept
{
const NarrowPath base = NarrowPath(path_fs.GetBase());
unsigned track;
if (!base || (track = psgplay_subtune_track(base)) < 1)
return { AllocatedPath(path_fs), 1 };

return { path_fs.GetDirectoryName(), track };
}

static SignedSongTime
psgplay_subtune_duration(int subtune, const AllocatedArray<std::byte> &tune) noexcept
{
float duration;

if (sndh_tag_subtune_time(&duration, subtune, tune.data(), tune.size()))
return SignedSongTime::FromS(duration);

return SignedSongTime::Negative();
}

static void
psgplay_file_decode(DecoderClient &client, Path path_fs)
{
static constexpr AudioFormat audio_format(44100, SampleFormat::S16, 2);
assert(audio_format.IsValid());

const auto container = psgplay_container_from_path(path_fs);

const AllocatedArray<std::byte> tune = psgplay_read_file(container.path);
if (tune.empty())
return;

SignedSongTime duration = psgplay_subtune_duration(container.track, tune);
if (duration.IsNegative() && psgplay_global->default_songlength > 0)
duration = SignedSongTime::FromS(psgplay_global->default_songlength);

struct psgplay *pp = psgplay_init(tune.data(), tune.size(),
container.track,
audio_format.sample_rate);
if (!pp)
return;
if (!duration.IsNegative())
psgplay_stop_at_time(pp, duration.ToDoubleS());

AtScopeExit(pp) { psgplay_free(pp); };

client.Ready(audio_format, true, duration);

size_t t_frames = 0;
DecoderCommand cmd;

do {
enum { N = 4096 };
struct psgplay_stereo buffer[N];

/* psgplay_read_stereo returns the number of (stereo) frames */
const ssize_t n_frames = psgplay_read_stereo(pp, buffer, N);
if (n_frames <= 0)
break;

cmd = client.SubmitAudio(nullptr,
std::span{buffer, (size_t)n_frames},
0);
t_frames += n_frames;

if (cmd == DecoderCommand::SEEK) {
const uint64_t s_frames = client.GetSeekFrame();

if (s_frames < t_frames) {
psgplay_free(pp);

pp = psgplay_init(tune.data(), tune.size(),
container.track,
audio_format.sample_rate);
if (!pp)
return;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls psgplay_free(nullptr) which is illegal according to your API documentation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls psgplay_free(nullptr) which is illegal according to your API documentation.

Hmm... where does it say NULL is invalid? NULL is actually very much permitted, and handled accordingly. This is exactly the way the C standard library handles NULL in free(void *p): If p is NULL, no operation is performed. However, I can certainly add this statement to the validity of NULL to the API documentation of psgplay_free.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/**
 * psgplay_free - free a PSG play object previously initialised
 * @pp: PSG play object to free
 */
void psgplay_free(struct psgplay *pp);

The null pointer is not "a PSG play object previously initialised".
Even if your code handles it, your API documentation disallows it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, indeed. I will add the validity of NULL to the documentation of psgplay_free, thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit frno7/psgplay@58e451a.

if (!duration.IsNegative())
psgplay_stop_at_time(pp, duration.ToDoubleS());

t_frames = 0;
}

if (s_frames > t_frames) {
const ssize_t k_frames =
psgplay_read_stereo(pp, nullptr,
s_frames -
t_frames);
if (k_frames <= 0)
break;
t_frames += k_frames;
}

if (t_frames != s_frames)
client.SeekError();

client.CommandFinished();
}
} while (cmd != DecoderCommand::STOP);
}

static void
psgplay_tag(enum TagType tag_type, TagHandler &th,
bool (*tag)(char *text, size_t length,
const void *data, const size_t size),
const AllocatedArray<std::byte> &tune) noexcept
{
char text[256];

if (tag(text, sizeof(text), tune.data(), tune.size()))
th.OnTag(tag_type, text);
}

static void
psgplay_tag_subtune_name(unsigned track, unsigned n_tracks,
TagHandler &th,
const AllocatedArray<std::byte> &tune) noexcept
{
char text[256];

if (sndh_tag_subtune_name(text, sizeof(text), track,
tune.data(), tune.size())) {
th.OnTag(TAG_TITLE, text);
return;
}

if (!sndh_tag_title(text, sizeof(text), tune.data(), tune.size()))
text[0] = '\0';

if (n_tracks == 1 && text[0] != '\0') {
th.OnTag(TAG_TITLE, text);
return;
}

const auto album_track = fmt::format("{} ({}/{})",
text, track, n_tracks);

th.OnTag(TAG_TITLE, album_track.c_str());
}

static int
psgplay_tracks(const AllocatedArray<std::byte> &tune) noexcept
{
int n_tracks;

if (sndh_tag_subtune_count(&n_tracks, tune.data(), tune.size()))
return n_tracks;

return 1;
}

static void
psgplay_on_tag(unsigned track, unsigned n_tracks, TagHandler &th,
const AllocatedArray<std::byte> &tune) noexcept
{
psgplay_tag(TAG_ALBUM, th, sndh_tag_title, tune);
psgplay_tag_subtune_name(track, n_tracks, th, tune);
psgplay_tag(TAG_ARTIST, th, sndh_tag_composer, tune);
psgplay_tag(TAG_DATE, th, sndh_tag_year, tune);

if (!psgplay_global->default_genre.empty())
th.OnTag(TAG_GENRE,
psgplay_global->default_genre.c_str());

const SignedSongTime duration = psgplay_subtune_duration(track, tune);
if (!duration.IsNegative())
th.OnDuration(SongTime(duration));

th.OnTag(TAG_TRACK, fmt::format_int{track}.c_str());
}

static bool
psgplay_scan_file(Path path_fs, TagHandler &th)
{
const auto container = psgplay_container_from_path(path_fs);

const AllocatedArray<std::byte> tune = psgplay_read_file(container.path);
if (tune.empty())
return false;

psgplay_on_tag(container.track, psgplay_tracks(tune), th, tune);

return true;
}

static std::forward_list<DetachedSong>
psgplay_container_scan(Path path_fs)
{
std::forward_list<DetachedSong> list;

const AllocatedArray<std::byte> tune = psgplay_read_file(path_fs);

const int n_tracks = psgplay_tracks(tune);

TagBuilder tag_builder;

auto tail = list.before_begin();
for (int i = 1; i <= n_tracks; ++i) {
AddTagHandler th(tag_builder);

psgplay_on_tag(i, n_tracks, th, tune);

/* Construct container/tune path names, for example
Delta.sndh/tune_001.sndh */
tail = list.emplace_after(tail,
fmt::format(SUBTUNE_PREFIX "{:03}.sndh", i),
tag_builder.Commit());
}

return list;
}

static const char *const psgplay_suffixes[] = {
"sndh",
nullptr
};

constexpr DecoderPlugin psgplay_decoder_plugin =
DecoderPlugin("psgplay", psgplay_file_decode, psgplay_scan_file)
.WithInit(psgplay_init, psgplay_finish)
.WithContainer(psgplay_container_scan)
.WithSuffixes(psgplay_suffixes);
9 changes: 9 additions & 0 deletions src/decoder/plugins/PsgplayDecoderPlugin.hxx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright The Music Player Daemon Project

#ifndef MPD_DECODER_PSGPLAY_HXX
#define MPD_DECODER_PSGPLAY_HXX

extern const struct DecoderPlugin psgplay_decoder_plugin;

#endif
Loading
Loading