From 375e6ba4d3d9909f28fd264d89d5d6278d145557 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 24 Jul 2026 22:06:37 +0200 Subject: [PATCH] feat(audio): add idle timeout to auto-release ALSA device - Added `idle_timeout_s` parameter (via `-t SECS` CLI flag and config file). Disabled by default (`0`) to ensure backward compatibility and avoid pop noise on some DACs. - Track stream stop events and device status in `ArmPlayerListener`. - Evaluate timeout in main loop to cleanly issue `audio_sink.stop()`. - Added startup log block displaying active configuration parameters. --- README.md | 1 + config/sendspin-armv6.conf | 5 +++ src/config.cpp | 13 +++++++ src/config.h | 1 + src/main.cpp | 71 ++++++++++++++++++++++++++++++++++---- 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d2b2fb6..a722c48 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ With this in place the `device` config key can be left unset. | `log_level` | no | `info` | `none`, `error`, `warn`, `info`, `debug`, `verbose` | | `device` | no | Default system audio device (`aplay -L`) | ALSA device string for audio output (e.g. `plughw:1,0`) | | `initial_volume` | no | Server-controlled | Initial hardware volume at startup (0–100) | +| `idle_timeout` | no | `0` (disabled) | Idle timeout in seconds before releasing the audio device | ## Upgrading diff --git a/config/sendspin-armv6.conf b/config/sendspin-armv6.conf index 7545ad7..b703219 100644 --- a/config/sendspin-armv6.conf +++ b/config/sendspin-armv6.conf @@ -33,3 +33,8 @@ device = plughw:0,0 # players in sync across the network. The server may adjust this at runtime. # Leave commented out for no initial static delay. #initial_static_delay = 200 + +# Optional: Idle timeout in seconds before releasing the audio device. +# This allows other applications to use the audio hardware when Sendspin is idle. +# 0 = disabled (device stays open permanently). +#idle_timeout = 60 \ No newline at end of file diff --git a/src/config.cpp b/src/config.cpp index fa06655..f58cc06 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -70,6 +70,19 @@ bool load_config(const std::string& path, Config& config) { fprintf(stderr, "%s:%d: invalid initial_static_delay value '%s', ignoring\n", path.c_str(), line_num, value.c_str()); } + } else if (key == "idle_timeout") { + try { + int v = std::stoi(value); + if (v < 0) { + fprintf(stderr, "%s:%d: idle_timeout must be >= 0, ignoring\n", + path.c_str(), line_num); + } else { + config.idle_timeout_s = v; + } + } catch (const std::exception&) { + fprintf(stderr, "%s:%d: invalid idle_timeout value '%s', ignoring\n", + path.c_str(), line_num, value.c_str()); + } } else { fprintf(stderr, "%s:%d: unknown key '%s'\n", path.c_str(), line_num, key.c_str()); diff --git a/src/config.h b/src/config.h index c20294f..b8cb938 100644 --- a/src/config.h +++ b/src/config.h @@ -10,6 +10,7 @@ struct Config { std::string device; // ALSA device, e.g. plughw:1,0 (empty = system default) int initial_volume = -1; // 0-100 to override hardware volume on startup; -1 = server default int initial_static_delay_ms = -1; // 0-5000 ms; -1 = no initial delay + int idle_timeout_s = 0; // 0 = disabled (device stays open permanently) }; /// Parse a simple key=value config file. diff --git a/src/main.cpp b/src/main.cpp index c6d4fdf..88fb7ae 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -23,7 +23,7 @@ using namespace sendspin; static constexpr const char* DEFAULT_CONFIG_PATH = "/etc/sendspin-armv6.conf"; -static constexpr const char* VERSION = "0.1.0"; +static constexpr const char* VERSION = "0.1.7"; static std::atomic running{true}; @@ -43,6 +43,7 @@ static void print_usage(const char* prog) { fprintf(stderr, "Usage: %s [options]\n\n", prog); fprintf(stderr, "Options:\n"); fprintf(stderr, " -c PATH Config file (default: %s)\n", DEFAULT_CONFIG_PATH); + fprintf(stderr, " -t SECS Idle timeout in seconds before releasing audio device (0=disable)\n"); fprintf(stderr, " -h Show this help\n"); fprintf(stderr, " -V Show version\n"); } @@ -53,10 +54,12 @@ int main(int argc, char* argv[]) { // Parse command line std::string config_path = DEFAULT_CONFIG_PATH; + int idle_timeout_override = -1; int opt; - while ((opt = getopt(argc, argv, "c:hV")) != -1) { + while ((opt = getopt(argc, argv, "c:t:hV")) != -1) { switch (opt) { case 'c': config_path = optarg; break; + case 't': idle_timeout_override = std::atoi(optarg); break; case 'h': print_usage(argv[0]); return 0; case 'V': fprintf(stdout, "sendspin-armv6 %s\n", VERSION); return 0; default: print_usage(argv[0]); return 1; @@ -68,6 +71,11 @@ int main(int argc, char* argv[]) { if (!load_config(config_path, cfg)) { return 1; } + + if (idle_timeout_override >= 0) { + cfg.idle_timeout_s = idle_timeout_override; + } + if (cfg.server_url.empty()) { fprintf(stderr, "Error: server_url is required in %s\n", config_path.c_str()); return 1; @@ -81,6 +89,32 @@ int main(int argc, char* argv[]) { } SendspinClient::set_log_level(log_level); + // --- STARTUP LOGGING BLOCK --- + fprintf(stderr, "\n"); + fprintf(stderr, "=================================================================\n"); + fprintf(stderr, " Sendspin ARMv6 Client - Version: %s\n", VERSION); + fprintf(stderr, " Build Date: %s %s\n", __DATE__, __TIME__); + fprintf(stderr, "=================================================================\n"); + fprintf(stderr, " Active Configuration:\n"); + fprintf(stderr, " Config File : %s\n", config_path.c_str()); + fprintf(stderr, " Friendly Name : %s\n", cfg.name.c_str()); + fprintf(stderr, " Audio Device : %s\n", cfg.device.empty() ? "System Default" : cfg.device.c_str()); + fprintf(stderr, " Server URL : %s\n", cfg.server_url.c_str()); + fprintf(stderr, " Idle Timeout : %s\n", cfg.idle_timeout_s == 0 ? "inaktiv" : (std::to_string(cfg.idle_timeout_s) + " seconds").c_str()); + + const char* log_level_str = "info"; + switch (log_level) { + case LogLevel::NONE: log_level_str = "none"; break; + case LogLevel::ERROR: log_level_str = "error"; break; + case LogLevel::WARN: log_level_str = "warn"; break; + case LogLevel::INFO: log_level_str = "info"; break; + case LogLevel::DEBUG: log_level_str = "debug"; break; + case LogLevel::VERBOSE: log_level_str = "verbose"; break; + } + fprintf(stderr, " Log Level : %s\n", log_level_str); + fprintf(stderr, "=================================================================\n\n"); + // --- END STARTUP LOGGING BLOCK --- + // Derive a unique, persistent client_id from /etc/machine-id (systemd). // Falls back to hostname. The spec requires this to be unique per device // so that servers can track group membership and de-duplicate connections. @@ -139,9 +173,15 @@ int main(int argc, char* argv[]) { // --- Listener implementations --- struct ArmPlayerListener : PlayerRoleListener { + bool stream_active{false}; + bool device_open{false}; + std::chrono::steady_clock::time_point last_stream_end_time{std::chrono::steady_clock::now()}; + int idle_timeout_s{0}; + AlsaPipeSink& sink; PlayerRole& player; - ArmPlayerListener(AlsaPipeSink& s, PlayerRole& p) : sink(s), player(p) {} + ArmPlayerListener(AlsaPipeSink& s, PlayerRole& p, int timeout_s) + : sink(s), player(p), idle_timeout_s(timeout_s) {} size_t on_audio_write(uint8_t* data, size_t length, uint32_t timeout_ms) override { @@ -150,6 +190,8 @@ int main(int argc, char* argv[]) { void on_stream_start() override { fprintf(stderr, ">>> Stream started\n"); + this->stream_active = true; + this->device_open = true; auto& params = player.get_current_stream_params(); if (params.sample_rate.has_value() && params.channels.has_value() && params.bit_depth.has_value()) { @@ -160,6 +202,12 @@ int main(int argc, char* argv[]) { void on_stream_end() override { fprintf(stderr, ">>> Stream ended\n"); + this->stream_active = false; + this->last_stream_end_time = std::chrono::steady_clock::now(); + + if (this->idle_timeout_s > 0) { + fprintf(stderr, ">>> Audio device will be released in %d seconds if idle\n", this->idle_timeout_s); + } sink.clear(); } @@ -182,7 +230,7 @@ int main(int argc, char* argv[]) { bool is_network_ready() override { return true; } }; - ArmPlayerListener player_listener(audio_sink, player); + ArmPlayerListener player_listener(audio_sink, player, cfg.idle_timeout_s); audio_sink.on_frames_played = [&player](uint32_t frames, int64_t timestamp) { player.notify_audio_played(frames, timestamp); }; @@ -196,8 +244,6 @@ int main(int argc, char* argv[]) { client.set_network_provider(&network_provider); // Start the WebSocket server - fprintf(stderr, "sendspin-armv6 %s — %s\n", VERSION, cfg.name.c_str()); - if (!client.start_server()) { fprintf(stderr, "Failed to start server\n"); return 1; @@ -224,6 +270,17 @@ int main(int argc, char* argv[]) { while (running.load()) { client.loop(); + // Idle timeout check to release audio device + if (cfg.idle_timeout_s > 0 && !player_listener.stream_active && player_listener.device_open) { + auto now = clock::now(); + auto elapsed = std::chrono::duration_cast(now - player_listener.last_stream_end_time).count(); + if (elapsed >= cfg.idle_timeout_s) { + fprintf(stderr, ">>> Idle timeout reached (%d s), releasing audio device\n", cfg.idle_timeout_s); + audio_sink.stop(); + player_listener.device_open = false; + } + } + if (!client.is_connected()) { auto now = clock::now(); if (now - last_connect_attempt >= RECONNECT_INTERVAL) { @@ -239,4 +296,4 @@ int main(int argc, char* argv[]) { fprintf(stderr, "\nShutting down...\n"); client.disconnect(SendspinGoodbyeReason::SHUTDOWN); return 0; -} +} \ No newline at end of file