Compare commits

...

8 Commits

24 changed files with 494 additions and 20 deletions

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
* text=auto eol=lf
/.gitignore export-ignore
/.gitattributes export-ignore
*.mp3 filter=lfs diff=lfs merge=lfs -text
assets/bin/** filter=lfs diff=lfs merge=lfs -text
assets/bin/**/*.env -filter -diff -merge
assets/bin/**/*.txt -filter -diff -merge

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
debug/ debug/
target/ target/
assets/.cache
Cargo.lock Cargo.lock

View File

@@ -4,3 +4,11 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
dotenvy = "^0"
ron = "^0"
toml = "^1"
serde_json = "^1"
serde = { version = "^1", features = ["derive"] }
thiserror = "^2"
tracing-subscriber = { version = "^0", features = ["env-filter"] }

131
README.md
View File

@@ -6,17 +6,46 @@
```terminaloutput ```terminaloutput
assets/ assets/
config.(json|toml) config.(json|toml|ron)
songs/ bin/
my_song/ x86_64/
my_song.mp3 aarch64/
descriptor.(json|toml) songs/
intro/ my_song/
MrNewVegas_N.mp3 (optional) my_song.mp3
outro/ descriptor.(json|toml|ron)
MrNewVegas_N.mp3 (optional) intro/
generic/ Intro_MrNewVegas_my_song.mp3 (optional)
MrNewVegas_N.mp3 generic/
intro/
Intro_MrNewVegas_N.mp3
shared/
Story_MrNewVegas_N.mp3
news/
story_N/
story.toml (optional, ignored)
Story_MrNewVegas_N.mp3
Story_Guest_N.mp3
Story_MrNewVegas_N.mp3 -> symlink shared/Story_MrNewVegas_N.mp3
.cache/ (automatically generated)
songs/
my_song/
song.pcm (f32 pcm)
intro.pcm (f32 pcm)
cache.json
generic/
intro/
intro_N.pcm (f32 pcm)
intro_N.cache.json
shared/
story_N.pcm (f32 pcm)
story_N.cache.json
news/
story_N/
part_N.pcm (f32 pcm)
part_N.pcm (f32 pcm)
part_N.pcm (f32 pcm) -> symlink shared/story_N.pcm
cache.json
``` ```
--- ---
@@ -33,11 +62,23 @@ assets/
"settings": { "settings": {
"weight": 1.0, "weight": 1.0,
"volume": 1.0, "volume": 1.0,
"crossfade": 2.0 // in seconds "crossfade": 2.0, // in seconds
"weights": {
"news": {
"chance": 0.2
},
"genre": {
"floor": 0.55,
"recover_after": 3.0
},
"composite": {
"floor": 0.05,
"recover_after": 5.0
}
}
}, },
"rules": { "rules": {
"shuffle_mode": "weighted", // options: "weighted", "strict_random" "shuffle_mode": "weighted", // options: "weighted", "strict_random"
"max_same_genre_in_row": 2
} }
} }
@@ -55,10 +96,51 @@ temp_directory = "temp"
weight = 1.0 weight = 1.0
volume = 1.0 volume = 1.0
crossfade = 2.0 # in seconds crossfade = 2.0 # in seconds
[settings.weights.news]
chance = 0.2
[settings.weights.genre]
floor = 0.55
recover_after = 3.0
[settings.weights.composite]
floor = 0.05
recover_after = 5.0
[rules] [rules]
shuffle_mode = "weighted" # "weighted", "strict_random" shuffle_mode = "weighted" # "weighted", "strict_random"
max_same_genre_in_row = 2 ```
OR
#### `config.ron`
```ron
(
defaults: (
songs_directory: "songs",
generic_directory: "generic",
log_directory: "logs",
temp_directory: "temp",
),
settings: (
debug: true,
weight: 1.0,
volume: 1.0,
crossfade: 2.0, // in seconds
weights: (
news: (
chance: 0.2,
),
genre: (
floor: 0.55,
recover_after: 3.0,
),
composite: (
floor: 0.05,
recover_after: 5.0,
),
),
),
rules: (
shuffle_mode: weighted, // weighted, strict_random
),
)
``` ```
--- ---
@@ -70,8 +152,7 @@ max_same_genre_in_row = 2
"artist": "Kay Kyser", "artist": "Kay Kyser",
"tags": ["orchestra", "swing", "jazz"], "tags": ["orchestra", "swing", "jazz"],
"genre": ["swing", "jazz"], "genre": ["swing", "jazz"],
"intro": false, "intro": true,
"outro": false,
"weight": 1.0, // optional "weight": 1.0, // optional
"volume": 1.0, // optional "volume": 1.0, // optional
} }
@@ -83,8 +164,20 @@ title = "[I Got Spurs That] Jingle, Jangle, Jingle"
artist = "Kay Kyser" artist = "Kay Kyser"
tags = ["orchestra", "swing", "jazz"] tags = ["orchestra", "swing", "jazz"]
genre = ["swing", "jazz"] genre = ["swing", "jazz"]
intro = false intro = true
outro = false
weight = 1.0 # optional weight = 1.0 # optional
volume = 1.0 # optional volume = 1.0 # optional
``` ```
OR
#### `descriptor.ron`
```ron
(
title: "[I Got Spurs That] Jingle, Jangle, Jingle",
artist: "Kay Kyser",
tags: ["orchestra", "swing", "jazz"],
genre: ["swing", "jazz"],
intro: true,
weight: 1.0, // optional
volume: 1.0, // optional
)
```

2
assets/bin/aarch64/.env Normal file
View File

@@ -0,0 +1,2 @@
LOCAL_ASSETS_DIR=../../../assets
LOCAL_BIN_DIR=.

BIN
assets/bin/aarch64/ffmpeg (Stored with Git LFS) Executable file

Binary file not shown.

BIN
assets/bin/aarch64/ffplay (Stored with Git LFS) Executable file

Binary file not shown.

BIN
assets/bin/aarch64/ffprobe (Stored with Git LFS) Executable file

Binary file not shown.

BIN
assets/bin/aarch64/rnv_utils (Stored with Git LFS) Executable file

Binary file not shown.

View File

@@ -0,0 +1,11 @@
LGPL-3.0
ffmpeg, ffplay, ffprobe
https://github.com/BtbN/FFmpeg-Builds/releases/tag/autobuild-2026-02-18-13-03
https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
sha256:5ec371abbf6da81fa0a7dfe013035754fadf77f8206b93cb784ec582b0870607
LGPL-3.0
rnv_utils
locally compiled (arm64), golang
sha256:44d2aa338f3a2f5dca65d50d9a120a1ad02b18210e8ba1d114db4496e1056d14

2
assets/bin/x86_64/.env Normal file
View File

@@ -0,0 +1,2 @@
LOCAL_ASSETS_DIR=../../../assets
LOCAL_BIN_DIR=.

BIN
assets/bin/x86_64/ffmpeg (Stored with Git LFS) Executable file

Binary file not shown.

BIN
assets/bin/x86_64/ffplay (Stored with Git LFS) Executable file

Binary file not shown.

BIN
assets/bin/x86_64/ffprobe (Stored with Git LFS) Executable file

Binary file not shown.

BIN
assets/bin/x86_64/rnv_utils (Stored with Git LFS) Executable file

Binary file not shown.

View File

@@ -0,0 +1,11 @@
LGPL-3.0
ffmpeg, ffplay, ffprobe
https://github.com/BtbN/FFmpeg-Builds/releases/tag/autobuild-2026-02-18-13-03
https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
sha256:5ec371abbf6da81fa0a7dfe013035754fadf77f8206b93cb784ec582b0870607
LGPL-3.0
rnv_utils
locally compiled (amd64), golang
sha256:6c68de4d77d058b9ae9506a29617fe3181277ed6aebdd1507d06284d2519f3ea

129
src/app/config.rs Normal file
View File

@@ -0,0 +1,129 @@
use std::{
env,
sync::{ Arc, LazyLock },
path::{ Path, PathBuf, Component },
};
use serde::{ Serialize, Deserialize };
use crate::app::parser::FromConfig;
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct Config {
pub defaults: Defaults,
pub settings: Settings,
pub rules: Rules,
}
//
static CFG: LazyLock<Arc<Config>> = LazyLock::new(|| {
Arc::new(<Config as FromConfig>::load())
});
impl Config
{
/// Arc to a Singleton Instance of Config.
pub fn load() -> Arc<Self> { CFG.clone() }
fn resolve(&mut self, root: impl AsRef<Path>)
{
let d = &mut self.defaults;
let r = root.as_ref();
d.songs_directory = Self::normalize(r.join(&d.songs_directory));
d.generic_directory = Self::normalize(r.join(&d.generic_directory));
d.cache_directory = Self::normalize(r.join(&d.cache_directory));
d.log_directory = Self::normalize(r.join(&d.log_directory));
d.temp_directory = Self::normalize(r.join(&d.temp_directory));
}
fn normalize(path: impl AsRef<Path>) -> PathBuf
{
path.as_ref().components().fold(PathBuf::new(), |mut acc, c| {
match c { Component::ParentDir => { acc.pop(); acc }, _ => { acc.push(c); acc } }
})
}
}
impl FromConfig for Config
{
fn load() -> Self
{
let root = env::current_exe()
.expect("Unable to locate executable.").parent()
.expect("Unable to locate root directory.").to_owned();
// Removed expectation for dockerized-environments.
dotenvy::dotenv()
.map(|_| ())
.or_else(|_| dotenvy::from_path(root.join(".env")))
.ok(); //.expect("Unable to load .env file.");
let assets_directory: PathBuf = env::var("RNV_ASSETS_DIRECTORY")
.ok()
.and_then(|v| v.parse().ok())
.map(|p: PathBuf| root.join(p))
.unwrap();
Self::deserialize_config_with_resolver(
assets_directory.join("config"),
&[
("ron", Self::from_ron),
("toml", Self::from_toml),
("json", Self::from_json),
],
|cfg: &mut Self| cfg.resolve(&assets_directory)
).expect("Failed to deserialize config.")
}
}
//
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct Defaults {
pub songs_directory: PathBuf,
pub generic_directory: PathBuf,
pub cache_directory: PathBuf,
pub log_directory: PathBuf,
pub temp_directory: PathBuf,
}
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct Settings {
pub debug: bool,
pub weight: f64,
pub volume: f64,
pub crossfade: f64,
pub weights: Weights,
}
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct Weights {
pub news: NewsWeight,
pub genre: RecoveringWeight,
pub composite: RecoveringWeight,
}
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct NewsWeight {
pub chance: f64,
}
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct RecoveringWeight {
pub floor: f64,
pub recover_after: f64,
}
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct Rules {
pub shuffle_mode: ShuffleMode,
}
#[derive(Clone, Deserialize, Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum ShuffleMode {
Weighted,
StrictRandom,
}

7
src/app/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod state;
pub mod models;
pub mod config;
mod parser;
pub use parser::FromConfig;

2
src/app/models.rs Normal file
View File

@@ -0,0 +1,2 @@
pub struct Scheduler {}

3
src/app/parser/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod parser;
pub use parser::FromConfig;

130
src/app/parser/parser.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::path::Path;
use serde::Deserialize;
use thiserror::Error;
/// A trait for types that can be loaded from a configuration file.
///
/// Implementors must define [`load`](FromConfig::load), which is responsible for
/// resolving the config path and calling [`deserialize_config`](FromConfig::deserialize_config).
/// All parsing methods have default implementations supporting [`RON`](FromConfig::from_ron),
/// [`TOML`](FromConfig::from_toml), and [`JSON`](FromConfig::from_json),
/// and can be overridden if custom parsing behaviour is needed.
pub trait FromConfig
{
/// Loads and deserializes the config into `Self`.
///
/// Implementations should resolve the config file path and call
/// [`deserialize_config`](FromConfig::deserialize_config) with the desired formats.
fn load() -> Self;
/// Deserializes a config file into a rust struct.
///
/// Returns [`ConfigError::NotFound`] if no file matching any of the provided extensions
/// exists, [`ConfigError::Io`] if the file cannot be read, or the appropriate parse
/// error variant if deserialisation fails.
///
/// Example:
/// ```
/// let cfg: Config = Config::deserialize_config(
/// "path/to/config/file",
/// &[
/// ("ron", Config::from_ron),
/// ("toml", Config::from_toml),
/// ("json", Config::from_json),
/// ],
/// )?;
/// ```
fn deserialize_config<T>(file: impl AsRef<Path>, formats: &[(&str, ParseFn<T>)]) -> Result<T, ConfigError>
where
T: for<'de> Deserialize<'de>,
{
let base = file.as_ref();
for (ext, parse) in formats
{
let path = base.with_extension(ext);
if path.exists()
{
let raw = std::fs::read_to_string(&path)
.map_err(ConfigError::Io)?;
return parse(&raw);
}
}
Err(ConfigError::NotFound { path: file.as_ref().to_string_lossy().into() })
}
/// Deserializes a config file into a Rust struct, then applies a post-processing
/// resolver before returning.
///
/// This is identical to [`deserialize_config`](FromConfig::deserialize_config), except
/// that after a successful parse, `resolver` is called with a mutable reference to the
/// deserialized value. Use it to fill in derived fields, apply default overrides, or
/// validate and normalise values that cannot be expressed in the raw config format.
///
/// Example:
/// ```
/// let cfg: Config = Config::deserialize_config(
/// "path/to/config/file",
/// &[
/// ("ron", Config::from_ron),
/// ("toml", Config::from_toml),
/// ("json", Config::from_json),
/// ],
/// |cfg: &mut Self| cfg.resolve(&assets_directory)
/// )?;
/// ```
fn deserialize_config_with_resolver<T>(file: impl AsRef<Path>, formats: &[(&str, ParseFn<T>)], resolver: impl Fn(&mut T)) -> Result<T, ConfigError>
where
T: for<'de> Deserialize<'de>,
{
let base = file.as_ref();
for (ext, parse) in formats
{
let path = base.with_extension(ext);
if path.exists()
{
let raw = std::fs::read_to_string(&path)
.map_err(ConfigError::Io)?;
let mut cfg = parse(&raw)?;
resolver(&mut cfg);
return Ok(cfg);
}
}
Err(ConfigError::NotFound { path: file.as_ref().to_string_lossy().into() })
}
fn from_ron<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, ConfigError> {
ron::from_str(s).map_err(ConfigError::Ron)
}
fn from_toml<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, ConfigError> {
toml::from_str(s).map_err(ConfigError::Toml)
}
fn from_json<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, ConfigError> {
serde_json::from_str(s).map_err(ConfigError::Json)
}
}
//
pub type ParseFn<T> = fn(&str) -> Result<T, ConfigError>;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("No config file found at '{path}.(ron|toml|json)'.")]
NotFound { path: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Ron parse error: {0}")]
Ron(#[from] ron::error::SpannedError),
#[error("TOML parse error: {0}")]
Toml(#[from] toml::de::Error),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
}

30
src/app/state.rs Normal file
View File

@@ -0,0 +1,30 @@
use std::sync::Arc;
use tracing_subscriber::{
EnvFilter,
fmt::{
SubscriberBuilder,
format::{ Format, DefaultFields }
}
};
use crate::app::{
models::*,
config::Config
};
pub struct AppState {
pub config: Arc<Config>,
pub scheduler: Scheduler
}
impl AppState
{
pub fn new(config: Arc<Config>, scheduler: Scheduler) -> Self
{
Self { config, scheduler }
}
pub fn get_tracer(filter: impl Into<EnvFilter>) -> SubscriberBuilder<DefaultFields, Format, EnvFilter> {
tracing_subscriber::fmt().with_env_filter(filter)
}
}

3
src/lib.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod app;
pub use app::{ config, state };

View File

@@ -1,4 +1,13 @@
use new_vegas_radio::app::{ config::Config, state::AppState, models::Scheduler };
fn main() { fn main()
{
let cfg = Config::load();
let _app = AppState::new(cfg.clone(), Scheduler {});
if cfg.settings.debug {
AppState::get_tracer("new_vegas_radio=debug").init();
}
println!("{:?}", cfg);
} }