mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
Add custom character voicelines
This commit is contained in:
@@ -14,6 +14,8 @@ services:
|
|||||||
DISABLE_IMPORTS: false # Will disable account imports
|
DISABLE_IMPORTS: false # Will disable account imports
|
||||||
DISABLE_EXPORTS: false # Will disable account exports
|
DISABLE_EXPORTS: false # Will disable account exports
|
||||||
#ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints)
|
#ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints)
|
||||||
|
#ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints)
|
||||||
|
#OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards
|
||||||
#PURGE: false # Purge dead user accounts on startup
|
#PURGE: false # Purge dead user accounts on startup
|
||||||
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
|
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
|
||||||
#MASTERDATA: /masterdata/ # Override bundled CSVs / new_user.json at runtime (missing files fall back to the internal copies)
|
#MASTERDATA: /masterdata/ # Override bundled CSVs / new_user.json at runtime (missing files fall back to the internal copies)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ args=(
|
|||||||
[ "${DISABLE_IMPORTS:-}" = "true" ] && args+=(--disable-imports)
|
[ "${DISABLE_IMPORTS:-}" = "true" ] && args+=(--disable-imports)
|
||||||
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
|
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
|
||||||
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
|
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
|
||||||
|
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
|
||||||
|
|
||||||
add_opt() {
|
add_opt() {
|
||||||
local value="$1" flag="$2"
|
local value="$1" flag="$2"
|
||||||
@@ -30,6 +31,9 @@ add_opt "${EN_ANDROID_ASSET_HASH:-}" --en-android-asset-hash
|
|||||||
add_opt "${EN_IOS_ASSET_HASH:-}" --en-ios-asset-hash
|
add_opt "${EN_IOS_ASSET_HASH:-}" --en-ios-asset-hash
|
||||||
add_opt "${WINDOWS_ASSET_HASH:-}" --windows-asset-hash
|
add_opt "${WINDOWS_ASSET_HASH:-}" --windows-asset-hash
|
||||||
|
|
||||||
|
# Server owner uid(s) for the permission system (comma-separated)
|
||||||
|
add_opt "${OWNER:-}" --owner
|
||||||
|
|
||||||
# Asset / image paths.
|
# Asset / image paths.
|
||||||
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
|
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
|
||||||
add_opt "${MASTERDATA:-}" --masterdata
|
add_opt "${MASTERDATA:-}" --masterdata
|
||||||
|
|||||||
@@ -339,6 +339,21 @@ pub fn obtainable_card_ids(rarity: i64) -> Vec<i64> {
|
|||||||
// on-disk bytes - so this is the index the /custom_card/data/{md5} route
|
// on-disk bytes - so this is the index the /custom_card/data/{md5} route
|
||||||
// serves from, and it self-heals: a replaced file gets a new md5 and the old
|
// serves from, and it self-heals: a replaced file gets a new md5 and the old
|
||||||
// one simply stops resolving
|
// one simply stops resolving
|
||||||
|
// Resolve a voiceline md5 to its ogg under custom_cards/. Voicelines live in
|
||||||
|
// the character blob's `voice` array and on disk under the character's own
|
||||||
|
// voice/ subdirectory, so they share the art routes' self-healing property
|
||||||
|
pub fn find_voice_by_md5(md5: &str) -> Option<String> {
|
||||||
|
let blob = DATABASE.lock_and_select("SELECT character FROM characters WHERE character LIKE ?1", params!(format!("%{}%", md5))).ok()?;
|
||||||
|
let character = jzon::parse(&blob).ok()?;
|
||||||
|
let id = character["master_character_id"].as_i64()?;
|
||||||
|
for line in character["voice"].members() {
|
||||||
|
if line["md5"].as_str() == Some(md5) {
|
||||||
|
return Some(format!("characters/{}/voice/{}.ogg", id, md5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub fn find_asset_by_md5(md5: &str) -> Option<String> {
|
pub fn find_asset_by_md5(md5: &str) -> Option<String> {
|
||||||
let like = format!("%{}%", md5);
|
let like = format!("%{}%", md5);
|
||||||
if let Ok(blob) = DATABASE.lock_and_select("SELECT card FROM cards WHERE card LIKE ?1", params!(like.clone())) {
|
if let Ok(blob) = DATABASE.lock_and_select("SELECT card FROM cards WHERE card LIKE ?1", params!(like.clone())) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
use crate::router::{databases, global, userdata, webui, Login, Api};
|
use crate::router::{databases, global, userdata, webui, Login, Api};
|
||||||
use crate::router::databases::csv::{table, Region};
|
use crate::router::databases::csv::{table, Region};
|
||||||
|
use crate::router::custom_song::audio;
|
||||||
use crate::database::custom_card as database;
|
use crate::database::custom_card as database;
|
||||||
use crate::database::permissions;
|
use crate::database::permissions;
|
||||||
use crate::runtime::get_data_path;
|
use crate::runtime::get_data_path;
|
||||||
@@ -123,6 +124,24 @@ const CHARACTER_ART: &[ArtKind] = &[
|
|||||||
ArtKind { kind: "character", width: 600, height: 920 }
|
ArtKind { kind: "character", width: 600, height: 920 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Optional voicelines per character. Multipart fields per line:
|
||||||
|
// voice_{kind}_{index} audio file (any format symphonia reads;
|
||||||
|
// transcoded to ogg-vorbis, stored
|
||||||
|
// content-addressed like the art)
|
||||||
|
// voice_{kind}_{index}_text caption, may be empty
|
||||||
|
// voice_{kind}_{index}_text_en English caption, may be empty
|
||||||
|
// voice_{kind}_{index}_delete flag: remove this line
|
||||||
|
// Absent slots keep their stored line, captions without a file update the
|
||||||
|
// stored line's captions, and surviving lines are renumbered contiguously
|
||||||
|
// per kind (1..n) after every edit
|
||||||
|
const VOICE_KINDS: &[&str] = &[
|
||||||
|
"live_start", "live_success", "live_failed", "result_bond",
|
||||||
|
"skill_smile", "skill_pure", "skill_cool"
|
||||||
|
];
|
||||||
|
const MAX_VOICE_VARIANTS: usize = 9;
|
||||||
|
const MAX_VOICE_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
const MAX_VOICE_SECONDS: f64 = 30.0;
|
||||||
|
|
||||||
type Fields = HashMap<String, Vec<u8>>;
|
type Fields = HashMap<String, Vec<u8>>;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
@@ -199,6 +218,7 @@ pub fn web_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
cfg.service(
|
cfg.service(
|
||||||
web::scope("/custom_card")
|
web::scope("/custom_card")
|
||||||
.route("/data/{hash}/{file}", web::get().to(data))
|
.route("/data/{hash}/{file}", web::get().to(data))
|
||||||
|
.route("/voice/{hash}/{file}", web::get().to(voice))
|
||||||
.route("/create", web::post().to(create))
|
.route("/create", web::post().to(create))
|
||||||
.route("/update", web::post().to(update))
|
.route("/update", web::post().to(update))
|
||||||
.route("/publish", web::post().to(publish))
|
.route("/publish", web::post().to(publish))
|
||||||
@@ -430,6 +450,31 @@ async fn data(req: HttpRequest) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voiceline oggs, same sessionless content-addressed semantics as the art
|
||||||
|
// data route: '{server}/custom_card/voice/{md5}/{md5}.ogg'
|
||||||
|
async fn voice(req: HttpRequest) -> HttpResponse {
|
||||||
|
if disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let hash = req.match_info().get("hash").unwrap_or("").to_string();
|
||||||
|
let file = req.match_info().get("file").unwrap_or("").to_string();
|
||||||
|
if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) || file != format!("{}.ogg", hash) {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let Some(relative) = database::find_voice_by_md5(&hash) else {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
};
|
||||||
|
match fs::read(asset_path(&relative)) {
|
||||||
|
Ok(body) => {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(("content-type", "audio/ogg"))
|
||||||
|
.insert_header(("content-length", body.len()))
|
||||||
|
.body(body)
|
||||||
|
},
|
||||||
|
Err(_) => HttpResponse::NotFound().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_session_uid(req: &HttpRequest) -> Option<i64> {
|
fn get_session_uid(req: &HttpRequest) -> Option<i64> {
|
||||||
let token = webui::get_login_token(req)?;
|
let token = webui::get_login_token(req)?;
|
||||||
let login_token = userdata::webui_login_token(&token)?;
|
let login_token = userdata::webui_login_token(&token)?;
|
||||||
@@ -669,6 +714,93 @@ fn write_art(dir: &str, pending: &[PendingArt]) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The character's voiceline set after applying this form: stored lines kept
|
||||||
|
// unless deleted, new files decoded + transcoded to ogg-vorbis, captions
|
||||||
|
// updated in place, and each kind's survivors renumbered 1..n. Returns the
|
||||||
|
// wire-shape array and the new (md5, ogg bytes) files to write
|
||||||
|
fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue, Vec<(String, Vec<u8>)>), String> {
|
||||||
|
let mut rv = array![];
|
||||||
|
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
|
||||||
|
for kind in VOICE_KINDS {
|
||||||
|
let mut lines: Vec<JsonValue> = Vec::new();
|
||||||
|
for index in 1..=MAX_VOICE_VARIANTS {
|
||||||
|
let base = format!("voice_{}_{}", kind, index);
|
||||||
|
let stored_line = stored_voice.members().find(|line| line["kind"] == *kind && line["index"] == index);
|
||||||
|
if field_flag(fields, &format!("{}_delete", base)) {
|
||||||
|
if file_of(fields, &base).is_some() {
|
||||||
|
return Err(format!("'{}': cannot both replace and delete the same line", base));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = |suffix: &str| -> String {
|
||||||
|
let key = format!("{}_{}", base, suffix);
|
||||||
|
if fields.contains_key(&key) {
|
||||||
|
field_str(fields, &key)
|
||||||
|
} else {
|
||||||
|
stored_line.map(|line| line[suffix].as_str().unwrap_or("").to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(bytes) = file_of(fields, &base) {
|
||||||
|
if bytes.len() > MAX_VOICE_BYTES {
|
||||||
|
return Err(format!("'{}' exceeds the {} MB per-file limit for voicelines", base, MAX_VOICE_BYTES / (1024 * 1024)));
|
||||||
|
}
|
||||||
|
let clip = audio::process_one_shot(bytes, MAX_VOICE_SECONDS).map_err(|e| format!("'{}': {}", base, e))?;
|
||||||
|
lines.push(object!{
|
||||||
|
"kind": *kind,
|
||||||
|
"index": 0,
|
||||||
|
"md5": clip.md5.clone(),
|
||||||
|
"size": clip.bytes.len(),
|
||||||
|
"text": text("text"),
|
||||||
|
"text_en": text("text_en")
|
||||||
|
});
|
||||||
|
files.push((clip.md5, clip.bytes));
|
||||||
|
} else if let Some(stored_line) = stored_line {
|
||||||
|
lines.push(object!{
|
||||||
|
"kind": *kind,
|
||||||
|
"index": 0,
|
||||||
|
"md5": stored_line["md5"].clone(),
|
||||||
|
"size": stored_line["size"].clone(),
|
||||||
|
"text": text("text"),
|
||||||
|
"text_en": text("text_en")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Caption fields for a slot with neither a file nor a stored line
|
||||||
|
// are ignored, like unknown multipart fields everywhere else
|
||||||
|
}
|
||||||
|
for (i, mut line) in lines.into_iter().enumerate() {
|
||||||
|
line["index"] = (i as i64 + 1).into();
|
||||||
|
rv.push(line).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((rv, files))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn voice_path(master_character_id: i64, md5: &str) -> String {
|
||||||
|
format!("{}/voice/{}.ogg", character_dir(master_character_id), md5)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_voice(master_character_id: i64, files: &[(String, Vec<u8>)]) -> Result<(), String> {
|
||||||
|
if files.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
fs::create_dir_all(format!("{}/voice", character_dir(master_character_id))).map_err(|e| e.to_string())?;
|
||||||
|
for (md5, bytes) in files {
|
||||||
|
fs::write(voice_path(master_character_id, md5), bytes).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files whose md5 no longer appears in the voice array are gone for good
|
||||||
|
// (their old catalog md5 404s, exactly like replaced art)
|
||||||
|
fn gc_voice(master_character_id: i64, old_voice: &JsonValue, new_voice: &JsonValue) {
|
||||||
|
for old in old_voice.members() {
|
||||||
|
let md5 = old["md5"].to_string();
|
||||||
|
if !md5.is_empty() && !new_voice.members().any(|line| line["md5"] == old["md5"]) {
|
||||||
|
let _ = fs::remove_file(voice_path(master_character_id, &md5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// card.upload manages your own uploads, card.edit is moderation over anybody's
|
// card.upload manages your own uploads, card.edit is moderation over anybody's
|
||||||
fn can_manage(uid: i64, owner: i64) -> bool {
|
fn can_manage(uid: i64, owner: i64) -> bool {
|
||||||
permissions::has(uid, permissions::CARD_EDIT)
|
permissions::has(uid, permissions::CARD_EDIT)
|
||||||
@@ -1006,9 +1138,10 @@ pub fn create_character(uid: i64, fields: &Fields) -> Result<i64, String> {
|
|||||||
if !permissions::has(uid, permissions::CARD_UPLOAD) {
|
if !permissions::has(uid, permissions::CARD_UPLOAD) {
|
||||||
return Err(String::from("You do not have permission to upload characters"));
|
return Err(String::from("You do not have permission to upload characters"));
|
||||||
}
|
}
|
||||||
// Fail fast: cheap text validation before any image work
|
// Fail fast: cheap text validation before any image/audio work
|
||||||
build_character(0, fields, &object!{})?;
|
build_character(0, fields, &object!{})?;
|
||||||
let character_art = collect_character_art(fields, true)?;
|
let character_art = collect_character_art(fields, true)?;
|
||||||
|
let (voice, voice_files) = collect_voice(fields, &array![])?;
|
||||||
|
|
||||||
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||||
let master_character_id = database::next_character_id();
|
let master_character_id = database::next_character_id();
|
||||||
@@ -1021,8 +1154,12 @@ pub fn create_character(uid: i64, fields: &Fields) -> Result<i64, String> {
|
|||||||
|
|
||||||
let mut character = build_character(master_character_id, fields, &object!{})?;
|
let mut character = build_character(master_character_id, fields, &object!{})?;
|
||||||
character["art"] = merge_art(&array![], &character_art);
|
character["art"] = merge_art(&array![], &character_art);
|
||||||
|
if !voice.is_empty() {
|
||||||
|
character["voice"] = voice;
|
||||||
|
}
|
||||||
|
|
||||||
write_art(&character_dir(master_character_id), &character_art)?;
|
write_art(&character_dir(master_character_id), &character_art)?;
|
||||||
|
write_voice(master_character_id, &voice_files)?;
|
||||||
database::insert_character(master_character_id, uid, &character);
|
database::insert_character(master_character_id, uid, &character);
|
||||||
database::bump_revision();
|
database::bump_revision();
|
||||||
drop(lock);
|
drop(lock);
|
||||||
@@ -1039,17 +1176,26 @@ pub fn update_character(uid: i64, master_character_id: i64, fields: &Fields) ->
|
|||||||
}
|
}
|
||||||
let stored = database::get_character(master_character_id).ok_or(String::from("Character not found"))?;
|
let stored = database::get_character(master_character_id).ok_or(String::from("Character not found"))?;
|
||||||
|
|
||||||
// Field validation first (cheap), art processing second - fail fast
|
// Field validation first (cheap), image/audio processing second - fail fast
|
||||||
let mut character = build_character(master_character_id, fields, &stored)?;
|
let mut character = build_character(master_character_id, fields, &stored)?;
|
||||||
let character_art = collect_character_art(fields, false)?;
|
let character_art = collect_character_art(fields, false)?;
|
||||||
|
let (voice, voice_files) = collect_voice(fields, &stored["voice"])?;
|
||||||
character["art"] = merge_art(&stored["art"], &character_art);
|
character["art"] = merge_art(&stored["art"], &character_art);
|
||||||
|
if !voice.is_empty() {
|
||||||
|
character["voice"] = voice.clone();
|
||||||
|
}
|
||||||
|
|
||||||
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||||
write_art(&character_dir(master_character_id), &character_art)?;
|
write_art(&character_dir(master_character_id), &character_art)?;
|
||||||
|
write_voice(master_character_id, &voice_files)?;
|
||||||
database::update_character(master_character_id, &character);
|
database::update_character(master_character_id, &character);
|
||||||
database::bump_revision();
|
database::bump_revision();
|
||||||
drop(lock);
|
drop(lock);
|
||||||
|
|
||||||
|
// Replaced/deleted lines: their oggs are per-character, so a md5 gone
|
||||||
|
// from the blob is safe to remove
|
||||||
|
gc_voice(master_character_id, &stored["voice"], &voice);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1356,6 +1502,128 @@ pub mod tests {
|
|||||||
crate::runtime::update_owners(&[]);
|
crate::runtime::update_owners(&[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seeded 44.1kHz 16-bit mono wav, so different seeds give different md5s
|
||||||
|
pub fn test_wav(seconds: f64, seed: u8) -> Vec<u8> {
|
||||||
|
let sample_rate: u32 = 44100;
|
||||||
|
let frames = (seconds * sample_rate as f64) as u32;
|
||||||
|
let data_len = frames * 2;
|
||||||
|
let mut rv = Vec::new();
|
||||||
|
rv.extend(b"RIFF");
|
||||||
|
rv.extend((36 + data_len).to_le_bytes());
|
||||||
|
rv.extend(b"WAVEfmt ");
|
||||||
|
rv.extend(16u32.to_le_bytes());
|
||||||
|
rv.extend(1u16.to_le_bytes());
|
||||||
|
rv.extend(1u16.to_le_bytes());
|
||||||
|
rv.extend(sample_rate.to_le_bytes());
|
||||||
|
rv.extend((sample_rate * 2).to_le_bytes());
|
||||||
|
rv.extend(2u16.to_le_bytes());
|
||||||
|
rv.extend(16u16.to_le_bytes());
|
||||||
|
rv.extend(b"data");
|
||||||
|
rv.extend(data_len.to_le_bytes());
|
||||||
|
for i in 0..frames {
|
||||||
|
let sample = (((i as f64 * (220.0 + seed as f64) * 2.0 * std::f64::consts::PI / sample_rate as f64).sin()) * 8000.0) as i16;
|
||||||
|
rv.extend(sample.to_le_bytes());
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voicelines: transcode to ogg, wire shape + captions, renumbering,
|
||||||
|
// caption-only edits, replacement GC, deletion, the caps, and the
|
||||||
|
// content-addressed voice route index
|
||||||
|
#[test]
|
||||||
|
fn character_voicelines() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(4010);
|
||||||
|
|
||||||
|
let mut fields = character_fields();
|
||||||
|
// Sparse indexes on purpose: 1 and 5 must renumber to 1 and 2
|
||||||
|
fields.insert(String::from("voice_live_start_1"), test_wav(1.0, 1));
|
||||||
|
field(&mut fields, "voice_live_start_1_text", "いくよー!");
|
||||||
|
field(&mut fields, "voice_live_start_1_text_en", "Here we go!");
|
||||||
|
fields.insert(String::from("voice_live_start_5"), test_wav(1.0, 2));
|
||||||
|
fields.insert(String::from("voice_skill_smile_1"), test_wav(0.5, 3));
|
||||||
|
// Index 10 is out of range and ignored entirely
|
||||||
|
fields.insert(String::from("voice_live_start_10"), test_wav(1.0, 4));
|
||||||
|
let id = with_permissions(4010, &[permissions::CARD_UPLOAD], || create_character(4010, &fields).unwrap());
|
||||||
|
|
||||||
|
let character = database::get_character(id).unwrap();
|
||||||
|
let voice = &character["voice"];
|
||||||
|
assert_eq!(voice.len(), 3);
|
||||||
|
let live: Vec<&JsonValue> = voice.members().filter(|line| line["kind"] == "live_start").collect();
|
||||||
|
assert_eq!(live.len(), 2);
|
||||||
|
assert_eq!(live[0]["index"].as_i64(), Some(1));
|
||||||
|
assert_eq!(live[1]["index"].as_i64(), Some(2));
|
||||||
|
assert_eq!(live[0]["text"].as_str(), Some("いくよー!"));
|
||||||
|
assert_eq!(live[0]["text_en"].as_str(), Some("Here we go!"));
|
||||||
|
assert_eq!(live[1]["text"].as_str(), Some(""));
|
||||||
|
for line in voice.members() {
|
||||||
|
let md5 = line["md5"].to_string();
|
||||||
|
assert_eq!(md5.len(), 32);
|
||||||
|
let path = format!("{}/voice/{}.ogg", character_dir(id), md5);
|
||||||
|
let bytes = fs::read(&path).unwrap();
|
||||||
|
assert!(bytes.starts_with(b"OggS"), "transcoded to ogg");
|
||||||
|
assert_eq!(format!("{:x}", md5::compute(&bytes)), md5);
|
||||||
|
assert_eq!(bytes.len(), line["size"].as_usize().unwrap());
|
||||||
|
// The voice route's index resolves the md5 to this exact file
|
||||||
|
assert_eq!(database::find_voice_by_md5(&md5), Some(format!("characters/{}/voice/{}.ogg", id, md5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caption-only edit: md5 untouched, captions replaced
|
||||||
|
let old_md5 = live[0]["md5"].to_string();
|
||||||
|
let mut edit = Fields::new();
|
||||||
|
field(&mut edit, "voice_live_start_1_text_en", "Let's gooo!");
|
||||||
|
with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit).unwrap());
|
||||||
|
let line = database::get_character(id).unwrap()["voice"].members()
|
||||||
|
.find(|line| line["kind"] == "live_start" && line["index"] == 1).unwrap().clone();
|
||||||
|
assert_eq!(line["md5"].to_string(), old_md5);
|
||||||
|
assert_eq!(line["text_en"].as_str(), Some("Let's gooo!"));
|
||||||
|
assert_eq!(line["text"].as_str(), Some("いくよー!"));
|
||||||
|
|
||||||
|
// Replacing a line swaps the md5 and garbage-collects the old ogg
|
||||||
|
let mut edit = Fields::new();
|
||||||
|
edit.insert(String::from("voice_live_start_1"), test_wav(1.0, 9));
|
||||||
|
with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit).unwrap());
|
||||||
|
let updated = database::get_character(id).unwrap();
|
||||||
|
let new_md5 = updated["voice"].members()
|
||||||
|
.find(|line| line["kind"] == "live_start" && line["index"] == 1).unwrap()["md5"].to_string();
|
||||||
|
assert_ne!(new_md5, old_md5);
|
||||||
|
assert!(fs::read(format!("{}/voice/{}.ogg", character_dir(id), old_md5)).is_err());
|
||||||
|
assert!(fs::read(format!("{}/voice/{}.ogg", character_dir(id), new_md5)).is_ok());
|
||||||
|
assert_eq!(database::find_voice_by_md5(&old_md5), None);
|
||||||
|
|
||||||
|
// Deleting line 2 removes its file and leaves a contiguous single line
|
||||||
|
let gone_md5 = updated["voice"].members()
|
||||||
|
.find(|line| line["kind"] == "live_start" && line["index"] == 2).unwrap()["md5"].to_string();
|
||||||
|
let mut edit = Fields::new();
|
||||||
|
field(&mut edit, "voice_live_start_2_delete", "1");
|
||||||
|
with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit).unwrap());
|
||||||
|
let after = database::get_character(id).unwrap();
|
||||||
|
let live: Vec<JsonValue> = after["voice"].members().filter(|line| line["kind"] == "live_start").cloned().collect();
|
||||||
|
assert_eq!(live.len(), 1);
|
||||||
|
assert_eq!(live[0]["index"].as_i64(), Some(1));
|
||||||
|
assert!(fs::read(format!("{}/voice/{}.ogg", character_dir(id), gone_md5)).is_err());
|
||||||
|
|
||||||
|
// Replace + delete on the same slot is contradictory
|
||||||
|
let mut edit = Fields::new();
|
||||||
|
field(&mut edit, "voice_live_start_1_delete", "1");
|
||||||
|
edit.insert(String::from("voice_live_start_1"), test_wav(1.0, 5));
|
||||||
|
assert!(with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit))
|
||||||
|
.unwrap_err().contains("cannot both replace and delete"));
|
||||||
|
|
||||||
|
// The caps: over-long and undecodable clips are refused by name
|
||||||
|
let mut edit = Fields::new();
|
||||||
|
edit.insert(String::from("voice_result_bond_1"), test_wav(31.0, 6));
|
||||||
|
let err = with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit)).unwrap_err();
|
||||||
|
assert!(err.contains("voice_result_bond_1") && err.contains("maximum is 30"), "{}", err);
|
||||||
|
let mut edit = Fields::new();
|
||||||
|
edit.insert(String::from("voice_result_bond_1"), b"definitely not audio".to_vec());
|
||||||
|
assert!(with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit))
|
||||||
|
.unwrap_err().contains("Could not read audio file"));
|
||||||
|
|
||||||
|
assert_eq!(database::find_voice_by_md5(&"0".repeat(32)), None);
|
||||||
|
wipe(4010);
|
||||||
|
}
|
||||||
|
|
||||||
// A full create: derived ids, pinned columns, art md5s and the catalog
|
// A full create: derived ids, pinned columns, art md5s and the catalog
|
||||||
// shape the client parses
|
// shape the client parses
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
mod audio;
|
// audio is shared: custom_card voicelines transcode through the same
|
||||||
|
// in-process symphonia + vorbis machinery
|
||||||
|
pub mod audio;
|
||||||
mod chart;
|
mod chart;
|
||||||
mod package;
|
mod package;
|
||||||
|
|
||||||
@@ -1398,7 +1400,15 @@ mod tests {
|
|||||||
field(&mut fields, "attribute", "1");
|
field(&mut fields, "attribute", "1");
|
||||||
fields.insert(String::from("jacket"), test_png());
|
fields.insert(String::from("jacket"), test_png());
|
||||||
fields.insert(String::from("audio"), test_ogg());
|
fields.insert(String::from("audio"), test_ogg());
|
||||||
fields.insert(String::from("chart_1"), test_chart());
|
// A chart UNIQUE to this test: the self-heal assert below relies on
|
||||||
|
// the old md5 resolving nowhere, and the shared test_chart() bytes
|
||||||
|
// also live in other tests' songs (the md5 index is content-addressed
|
||||||
|
// across all songs, so identical charts alias)
|
||||||
|
fields.insert(String::from("chart_1"), jzon::stringify(jzon::array![
|
||||||
|
{"timing_sec": 0.25, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 2},
|
||||||
|
{"timing_sec": 0.75, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 6},
|
||||||
|
{"timing_sec": 1.25, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 9}
|
||||||
|
]).into_bytes());
|
||||||
let music_id = create_song(9191, &fields).unwrap();
|
let music_id = create_song(9191, &fields).unwrap();
|
||||||
|
|
||||||
let song = database::get_song(music_id).unwrap();
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
|||||||
@@ -137,6 +137,25 @@ fn cue(bytes: Vec<u8>, duration_sec: f64) -> Cue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-shot line (custom-character voicelines): decode anything symphonia
|
||||||
|
// reads, keep it as-is when it's already ogg-vorbis, otherwise transcode. No
|
||||||
|
// cuts, fades, loop points or preview split - mono or stereo as-sourced
|
||||||
|
pub fn process_one_shot(bytes: &[u8], max_duration_sec: f64) -> Result<Cue, String> {
|
||||||
|
let audio = decode(bytes)?;
|
||||||
|
let duration = audio.duration();
|
||||||
|
if duration < 0.2 {
|
||||||
|
return Err(String::from("Audio clip is shorter than 0.2 seconds"));
|
||||||
|
}
|
||||||
|
if duration > max_duration_sec {
|
||||||
|
return Err(format!("Audio clip is {:.1} seconds long - the maximum is {:.0} seconds", duration, max_duration_sec));
|
||||||
|
}
|
||||||
|
if is_ogg_vorbis(bytes) {
|
||||||
|
return Ok(cue(bytes.to_vec(), duration));
|
||||||
|
}
|
||||||
|
let planar: Vec<&[f32]> = audio.channels.iter().map(|samples| samples.as_slice()).collect();
|
||||||
|
Ok(cue(encode(&planar, audio.sample_rate)?, duration))
|
||||||
|
}
|
||||||
|
|
||||||
// The play cue is the full track, the select cue is a preview cut with short
|
// 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 -
|
// 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
|
// the client validates md5(file) against the value served in the catalog
|
||||||
|
|||||||
2
webui
2
webui
Submodule webui updated: 1561074cc5...28b1c65fc6
Reference in New Issue
Block a user