Minor changes

This commit is contained in:
Ethan O'Brien
2026-07-03 00:40:06 -05:00
parent d4a6a56a27
commit 6b465eca8e
19 changed files with 2929 additions and 15 deletions

View File

@@ -0,0 +1,183 @@
use std::num::{NonZeroU8, NonZeroU32};
use symphonia::core::codecs::CodecParameters;
use symphonia::core::codecs::audio::AudioDecoderOptions;
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::TrackType;
use symphonia::core::formats::probe::Hint;
use symphonia::core::io::MediaSourceStream;
use vorbis_rs::{VorbisBitrateManagementStrategy, VorbisEncoderBuilder};
use super::{DEFAULT_PREVIEW_LENGTH_SEC, PREVIEW_FADE_SEC};
// The whole audio pipeline runs in-process: symphonia (pure Rust) decodes and
// validates uploads, vorbis_rs (libvorbis compiled into the binary - a library
// call, like rusqlite's bundled sqlite) encodes. No external processes are
// ever spawned and nothing needs to be on PATH.
//
// - ogg-vorbis uploads are stored AS-IS once they prove decodable, so the
// served play cue's md5 is stable across servers (export/import keeps it)
// - mp3/wav uploads are transcoded to ogg-vorbis once, at upload time
// - the select cue (menu preview) is cut + faded in samples and re-encoded
// Encodes keep the source sample rate and use a fixed Ogg stream serial, so
// they're deterministic: the same input always produces the same bytes
pub struct Cue {
pub bytes: Vec<u8>,
pub md5: String,
pub duration_sec: f64
}
// ~ffmpeg's -q:a 6
const ENCODE_QUALITY: f32 = 0.6;
// "SIF2" - fixed so encoding is deterministic
const STREAM_SERIAL: i32 = 0x53494632;
const ENCODE_BLOCK_FRAMES: usize = 65536;
struct DecodedAudio {
// Planar f32, one Vec per channel. Channels past the first two are dropped
channels: Vec<Vec<f32>>,
sample_rate: u32
}
impl DecodedAudio {
fn frames(&self) -> usize {
self.channels[0].len()
}
fn duration(&self) -> f64 {
self.frames() as f64 / self.sample_rate as f64
}
}
fn is_ogg_vorbis(bytes: &[u8]) -> bool {
// "OggS" capture pattern + the "\x01vorbis" identification header on the first page
bytes.starts_with(b"OggS") && bytes.len() > 64 && bytes[..64].windows(7).any(|w| w == b"\x01vorbis")
}
fn decode(bytes: &[u8]) -> Result<DecodedAudio, String> {
let stream = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.to_vec())), Default::default());
let mut format = symphonia::default::get_probe()
.probe(&Hint::new(), stream, Default::default(), Default::default())
.map_err(|_| String::from("Could not read audio file (expected ogg vorbis, mp3 or wav)"))?;
let track = format.default_track(TrackType::Audio).ok_or(String::from("Audio file has no audio track"))?;
let track_id = track.id;
let Some(CodecParameters::Audio(params)) = track.codec_params.clone() else {
return Err(String::from("Audio file has no audio track"));
};
let mut decoder = symphonia::default::get_codecs()
.make_audio_decoder(&params, &AudioDecoderOptions::default())
.map_err(|_| String::from("Could not read audio file (expected ogg vorbis, mp3 or wav)"))?;
let mut channels: Vec<Vec<f32>> = Vec::new();
let mut sample_rate = 0;
let mut interleaved: Vec<f32> = Vec::new();
loop {
let packet = match format.next_packet() {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(SymphoniaError::ResetRequired) => break,
Err(_) => return Err(String::from("Audio file is corrupt or truncated"))
};
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(decoded) => decoded,
// Decoders treat a bad packet as recoverable; skip it like they do
Err(SymphoniaError::DecodeError(_)) => continue,
Err(_) => return Err(String::from("Audio file is corrupt or truncated"))
};
let count = decoded.spec().channels().count();
if channels.is_empty() {
sample_rate = decoded.spec().rate();
channels = vec![Vec::new(); std::cmp::min(count, 2)];
}
decoded.copy_to_vec_interleaved(&mut interleaved);
for (i, samples) in channels.iter_mut().enumerate() {
samples.extend(interleaved.iter().skip(i).step_by(count));
}
}
if channels.is_empty() || channels[0].is_empty() || sample_rate == 0 {
return Err(String::from("Audio file is corrupt or truncated"));
}
Ok(DecodedAudio { channels, sample_rate })
}
fn encode(channels: &[&[f32]], sample_rate: u32) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
let mut builder = VorbisEncoderBuilder::new_with_serial(
NonZeroU32::new(sample_rate).ok_or(String::from("Audio file is corrupt or truncated"))?,
NonZeroU8::new(channels.len() as u8).unwrap(),
&mut out,
STREAM_SERIAL
);
builder.bitrate_management_strategy(VorbisBitrateManagementStrategy::QualityVbr {
target_quality: ENCODE_QUALITY
});
let mut encoder = builder.build().map_err(|e| format!("Audio encode failed: {}", e))?;
let frames = channels[0].len();
let mut i = 0;
while i < frames {
let end = std::cmp::min(i + ENCODE_BLOCK_FRAMES, frames);
let block: Vec<&[f32]> = channels.iter().map(|samples| &samples[i..end]).collect();
encoder.encode_audio_block(&block).map_err(|e| format!("Audio encode failed: {}", e))?;
i = end;
}
encoder.finish().map_err(|e| format!("Audio encode failed: {}", e))?;
Ok(out)
}
fn cue(bytes: Vec<u8>, duration_sec: f64) -> Cue {
Cue {
md5: format!("{:x}", md5::compute(&bytes)),
bytes,
duration_sec
}
}
// The play cue is the full track, the select cue is a preview cut with short
// fades. Both are stored content-addressed by the md5 of the final ogg bytes -
// the client validates md5(file) against the value served in the catalog
pub fn process(bytes: &[u8], preview_start_sec: Option<f64>, preview_length_sec: Option<f64>) -> Result<(Cue, Cue), String> {
let audio = decode(bytes)?;
let duration = audio.duration();
if duration <= 1.0 {
return Err(String::from("Audio track is too short"));
}
let planar: Vec<&[f32]> = audio.channels.iter().map(|samples| samples.as_slice()).collect();
let play = if is_ogg_vorbis(bytes) {
// Already ogg-vorbis and proven decodable: keep the exact bytes
cue(bytes.to_vec(), duration)
} else {
cue(encode(&planar, audio.sample_rate)?, duration)
};
// Preview defaults: start 30% into the track, 30 seconds long
let mut start = preview_start_sec.unwrap_or(duration * 0.3);
if start < 0.0 || start >= duration {
start = duration * 0.3;
}
let length = preview_length_sec.unwrap_or(DEFAULT_PREVIEW_LENGTH_SEC).clamp(1.0, duration - start);
let start_frame = (start * audio.sample_rate as f64) as usize;
let end_frame = std::cmp::min(start_frame + (length * audio.sample_rate as f64) as usize, audio.frames());
let fade_frames = (PREVIEW_FADE_SEC * audio.sample_rate as f64) as usize;
let mut segment: Vec<Vec<f32>> = audio.channels.iter().map(|samples| samples[start_frame..end_frame].to_vec()).collect();
let frames = end_frame - start_frame;
if frames > fade_frames * 2 {
for samples in segment.iter_mut() {
for i in 0..fade_frames {
let gain = i as f32 / fade_frames as f32;
samples[i] *= gain;
samples[frames - 1 - i] *= gain;
}
}
}
let planar: Vec<&[f32]> = segment.iter().map(|samples| samples.as_slice()).collect();
let select = cue(encode(&planar, audio.sample_rate)?, frames as f64 / audio.sample_rate as f64);
Ok((play, select))
}

View File

@@ -0,0 +1,266 @@
use jzon::{object, JsonValue};
// Transcodes a SIF1/NPPS4 beatmap (array of {timing_sec, effect, effect_value, position})
// into the SIF2 chart JSON the client deserializes into NoteData.
//
// Mapping rules:
// - line = position - 1 (both are right-to-left)
// - effect 1 (and 2, the "parallel" marker) -> type 1 (tap)
// - effect 3 (hold) -> head note (type 1) at timing_sec plus a SYNTHESIZED tail note
// (type 1, same line) at timing_sec + effect_value, linked through parent/child ids
// - effect 4 (star/token) -> type 3
// - effect 11/12/13 (swing) and anything unknown -> plain type 1 tap for v1.
// Slider chains are a later feature.
// - notes_attribute / notes_level are dropped (SIF2 has no per-note attribute)
// - ids are sequential from 1 in time order. num is the spawn group: the dummy
// header occupies 100, real groups count up from 101, and notes that hit
// simultaneously (equal timing_sec, which covers SIF1 effect 2 pairs) share one num.
// - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client
// deserializes it verbatim.
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
// line (the game counts a same-lane hold as one combo for the chain)
struct WorkNote {
time: f64,
line: i64,
kind: i64,
// Index into the work list of the hold head this tail belongs to
head: Option<usize>
}
fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64), String> {
let timing = data["timing_sec"].as_f64().ok_or(format!("Note {}: missing timing_sec", index))?;
let effect = data["effect"].as_i64().ok_or(format!("Note {}: missing effect", index))?;
let effect_value = data["effect_value"].as_f64().unwrap_or(0.0);
let position = data["position"].as_i64().ok_or(format!("Note {}: missing position", index))?;
if !(1..=9).contains(&position) {
return Err(format!("Note {}: position {} is outside 1-9", index, position));
}
if timing < 0.0 {
return Err(format!("Note {}: negative timing_sec {}", index, timing));
}
if effect == 3 && effect_value <= 0.0 {
return Err(format!("Note {}: hold with effect_value {} (must be > 0)", index, effect_value));
}
Ok((timing, effect, effect_value, position))
}
// Returns the chart JSON and its max_combo_count (== the difficulty's full_combo)
pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
if !beatmap.is_array() || beatmap.is_empty() {
return Err(String::from("Chart is not a JSON array of notes"));
}
let mut work: Vec<WorkNote> = Vec::new();
for (i, data) in beatmap.members().enumerate() {
let (timing, effect, effect_value, position) = parse_sif_note(data, i)?;
for other in beatmap.members().take(i) {
if other["timing_sec"].as_f64() == Some(timing) && other["position"].as_i64() == Some(position) && other["effect"].as_i64() != Some(effect) {
return Err(format!("Note {}: duplicate timing {} on position {} with a different effect", i, timing, position));
}
}
let head = work.len();
work.push(WorkNote {
time: timing,
line: position - 1,
kind: if effect == 4 { 3 } else { 1 },
head: None
});
if effect == 3 {
work.push(WorkNote {
time: timing + effect_value,
line: position - 1,
kind: 1,
head: Some(head)
});
}
}
// Sequential ids in time order. Stable sort keeps input order on ties
let mut order: Vec<usize> = (0..work.len()).collect();
order.sort_by(|a, b| work[*a].time.partial_cmp(&work[*b].time).unwrap());
let mut ids = vec![0i64; work.len()];
let mut nums = vec![0i64; work.len()];
let mut num = 100;
let mut last_time = f64::NEG_INFINITY;
for (i, index) in order.iter().enumerate() {
ids[*index] = (i + 1) as i64;
// Simultaneous notes share a spawn group
if work[*index].time != last_time {
num += 1;
last_time = work[*index].time;
}
nums[*index] = num;
}
let mut tail_of = vec![0usize; work.len()];
for (i, note) in work.iter().enumerate() {
if let Some(head) = note.head {
tail_of[head] = i;
}
}
let mut notes = jzon::array![{
"id": 0, "num": 100, "line": 0, "time": 0.0, "type": 0,
"parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0,
"force_sync_group_id": 0
}];
let mut max_combo_count = 0;
for index in order.iter() {
let note = &work[*index];
let tail = tail_of[*index];
let is_head = tail != 0;
// Same-lane hold heads don't count toward the combo, their tail does
if !(is_head && work[tail].line == note.line) {
max_combo_count += 1;
}
notes.push(object!{
"id": ids[*index],
"num": nums[*index],
"line": note.line,
"time": note.time,
"type": note.kind,
"parent_id": if let Some(head) = note.head { ids[head] } else { 0 },
"child_id": if is_head { ids[tail] } else { 0 },
"child_num": if is_head { nums[tail] } else { 0 },
"child_line": if is_head { work[tail].line } else { 0 },
"force_sync_group_id": 0
}).unwrap();
}
Ok((object!{
"max_lane": 9,
"sound_name": "",
"max_combo_count": max_combo_count,
"notes": notes
}, max_combo_count))
}
#[cfg(test)]
mod tests {
use super::*;
fn sif_note(timing_sec: f64, position: i64, effect: i64, effect_value: f64) -> JsonValue {
object!{
"timing_sec": timing_sec,
"notes_attribute": 1,
"notes_level": 1,
"effect": effect,
"effect_value": effect_value,
"position": position
}
}
#[test]
fn plain_taps() {
let beatmap = jzon::array![
sif_note(1.0, 1, 1, 2.0),
sif_note(2.0, 5, 1, 2.0),
sif_note(3.0, 9, 1, 2.0)
];
let (chart, combo) = transcode(&beatmap).unwrap();
assert_eq!(combo, 3);
assert_eq!(chart["max_combo_count"], 3);
assert_eq!(chart["max_lane"], 9);
assert_eq!(chart["notes"].len(), 4);
// Dummy header is verbatim
assert_eq!(chart["notes"][0]["id"], 0);
assert_eq!(chart["notes"][0]["num"], 100);
assert_eq!(chart["notes"][0]["type"], 0);
// Real notes: sequential ids, monotonic nums, right-to-left lines
assert_eq!(chart["notes"][1]["id"], 1);
assert_eq!(chart["notes"][1]["num"], 101);
assert_eq!(chart["notes"][1]["line"], 0);
assert_eq!(chart["notes"][1]["type"], 1);
assert_eq!(chart["notes"][2]["num"], 102);
assert_eq!(chart["notes"][2]["line"], 4);
assert_eq!(chart["notes"][3]["id"], 3);
assert_eq!(chart["notes"][3]["num"], 103);
assert_eq!(chart["notes"][3]["line"], 8);
}
#[test]
fn hold_head_and_tail() {
let beatmap = jzon::array![
sif_note(1.0, 3, 3, 2.5)
];
let (chart, combo) = transcode(&beatmap).unwrap();
// The synthesized same-lane tail counts, the head does not
assert_eq!(combo, 1);
assert_eq!(chart["notes"].len(), 3);
let head = &chart["notes"][1];
let tail = &chart["notes"][2];
assert_eq!(head["id"], 1);
assert_eq!(head["child_id"], 2);
assert_eq!(head["child_num"], tail["num"].clone());
assert_eq!(head["child_line"], 2);
assert_eq!(head["parent_id"], 0);
assert_eq!(tail["id"], 2);
assert_eq!(tail["parent_id"], 1);
assert_eq!(tail["child_id"], 0);
assert_eq!(tail["line"], 2);
assert_eq!(tail["time"].as_f64().unwrap(), 3.5);
}
#[test]
fn parallel_pair() {
let beatmap = jzon::array![
sif_note(1.0, 2, 2, 2.0),
sif_note(1.0, 8, 2, 2.0)
];
let (chart, combo) = transcode(&beatmap).unwrap();
// Simultaneous notes share a spawn group and both count
assert_eq!(combo, 2);
assert_eq!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone());
assert_eq!(chart["notes"][1]["type"], 1);
assert_eq!(chart["notes"][2]["type"], 1);
}
#[test]
fn mixed() {
let beatmap = jzon::array![
sif_note(1.0, 5, 1, 2.0), // tap
sif_note(2.0, 3, 3, 1.5), // hold: head at 2.0, tail at 3.5
sif_note(2.5, 7, 4, 0.0), // star
sif_note(3.5, 1, 2, 2.0), // parallel with the hold tail
sif_note(4.0, 9, 11, 0.0) // swing -> plain tap for v1
];
let (chart, combo) = transcode(&beatmap).unwrap();
// 6 real notes, minus the same-lane hold head
assert_eq!(combo, 5);
assert_eq!(chart["notes"].len(), 7);
// Time order: tap(1.0), head(2.0), star(2.5), tail(3.5), parallel(3.5), swing(4.0)
assert_eq!(chart["notes"][2]["child_id"], 4);
assert_eq!(chart["notes"][3]["type"], 3);
assert_eq!(chart["notes"][4]["parent_id"], 2);
// The tail and the parallel tap at 3.5 share a spawn group
assert_eq!(chart["notes"][4]["num"], chart["notes"][5]["num"].clone());
assert_eq!(chart["notes"][6]["type"], 1);
// Ids stay sequential in time order
for (i, data) in chart["notes"].members().enumerate() {
assert_eq!(data["id"], i);
}
}
#[test]
fn rejects_bad_charts() {
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());
assert!(transcode(&jzon::array![sif_note(1.0, 10, 1, 2.0)]).is_err());
assert!(transcode(&jzon::array![sif_note(-1.0, 5, 1, 2.0)]).is_err());
assert!(transcode(&jzon::array![sif_note(1.0, 5, 3, 0.0)]).is_err());
assert!(transcode(&jzon::array![sif_note(1.0, 5, 1, 2.0), sif_note(1.0, 5, 3, 2.0)]).is_err());
assert!(transcode(&jzon::object!{}).is_err());
}
}

View File

@@ -0,0 +1,90 @@
use std::collections::HashMap;
use std::fs;
use std::io::{Cursor, Read, Seek, Write};
use zip::write::SimpleFileOptions;
use super::{song_path, LEVEL_COUNT};
// Export packages carry the ORIGINAL upload artifacts. SIF1 is the canonical
// interchange format - the transcoded NoteData charts are derived data and are
// never exported. Importing a package on another ew server replays the exact
// same upload pipeline the multipart form uses. Layout of the zip:
// manifest.json upload metadata, same schema as the multipart fields
// jacket original jacket image bytes (png/jpg)
// audio original audio bytes (ogg/mp3/wav)
// chart_{level}.json SIF1-schema charts, level 1..4 (only uploaded levels)
// visibility/shared_with/downloads_disabled are per-server settings and are
// deliberately NOT part of the package.
pub fn build(music_id: i64) -> Result<Vec<u8>, String> {
// Songs uploaded before export support have no original artifacts on disk
let manifest = fs::read(song_path(music_id, "original/manifest.json"))
.map_err(|_| String::from("This song was uploaded before export support and can't be downloaded"))?;
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default();
let mut add = |name: &str, bytes: &[u8]| -> Result<(), String> {
zip.start_file(name, options).map_err(|e| e.to_string())?;
zip.write_all(bytes).map_err(|e| e.to_string())
};
add("manifest.json", &manifest)?;
for name in ["jacket", "audio"] {
let bytes = fs::read(song_path(music_id, &format!("original/{}", name))).map_err(|e| e.to_string())?;
add(name, &bytes)?;
}
for level in 1..=LEVEL_COUNT {
let Ok(bytes) = fs::read(song_path(music_id, &format!("original/chart_{}.json", level))) else { continue; };
add(&format!("chart_{}.json", level), &bytes)?;
}
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
}
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
let mut file = archive.by_name(name).ok()?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).ok()?;
Some(bytes)
}
// Expands a package into the same field map the upload form produces - the zip
// contents map 1:1 onto the multipart fields, so an import is just an upload
// with its fields sourced from the package. Fields already present in the map
// that the package also carries are overwritten (the package's metadata wins);
// visibility/shared_with/downloads_disabled aren't packaged and stay untouched
pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(), String> {
let mut archive = zip::ZipArchive::new(Cursor::new(package)).map_err(|_| String::from("Package is not a valid zip file"))?;
let manifest = read_entry(&mut archive, "manifest.json").ok_or(String::from("Package has no manifest.json"))?;
let manifest = jzon::parse(&String::from_utf8_lossy(&manifest)).map_err(|_| String::from("Package manifest is not valid JSON"))?;
if manifest["format"].as_i64() != Some(1) {
return Err(String::from("Unsupported package format"));
}
for key in ["name", "name_en", "short_name", "kana", "artist", "artist_en", "band_category", "attribute", "bpm", "preview_start_sec", "preview_length_sec"] {
if !manifest[key].is_null() {
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
}
}
for data in manifest["levels"].members() {
let Some(level) = data["level"].as_i64() else { continue; };
if !data["level_number"].is_null() {
fields.insert(format!("level_number_{}", level), data["level_number"].to_string().into_bytes());
}
}
fields.insert(String::from("jacket"), read_entry(&mut archive, "jacket").ok_or(String::from("Package has no jacket"))?);
fields.insert(String::from("audio"), read_entry(&mut archive, "audio").ok_or(String::from("Package has no audio"))?);
let mut has_chart = false;
for level in 1..=LEVEL_COUNT {
if let Some(chart) = read_entry(&mut archive, &format!("chart_{}.json", level)) {
fields.insert(format!("chart_{}", level), chart);
has_chart = true;
}
}
if !has_chart {
return Err(String::from("Package has no charts"));
}
Ok(())
}