Spaces:
Building
Building
| //! Music shard module β CFT decomposition of audio metadata into DA51 CBOR shards. | |
| //! | |
| //! Scale tower for music (mirrors erdfa-publish text CFT): | |
| //! Track β Stem β Segment β Frame β Sample β Byte | |
| //! | |
| //! Shards are semantic representations of track structure encoded as DA51-tagged | |
| //! CBOR bytes using the erdfa-publish library. | |
| //! | |
| //! ## NFT gating model (updated) | |
| //! | |
| //! Shard DATA is **fully public** β `GET /api/shard/:cid` returns the complete shard | |
| //! to any caller without authentication. This follows the "public DA" (decentralised | |
| //! availability) model: the bits are always accessible on BTFS. | |
| //! | |
| //! NFT ownership gates only the **ShardManifest** (assembly instructions) served via | |
| //! `GET /api/manifest/:token_id`. A wallet that holds the NFT can request the | |
| //! ordered CID list + optional AES-256-GCM decryption key for the assembled track. | |
| //! | |
| //! Pre-generated source shards (Emacs Lisp / Fractran VM reflections of each | |
| //! Rust module) live in `shards/` at the repo root and can be served directly | |
| //! via GET /api/shard/:cid once indexed at startup or via POST /api/shard/index. | |
| use axum::{ | |
| extract::{Path, State}, | |
| http::StatusCode, | |
| Json, | |
| }; | |
| use erdfa_publish::{cft::Scale as TextScale, Component, Shard, ShardSet}; | |
| use shared::types::Isrc; | |
| use std::{collections::HashMap, sync::RwLock}; | |
| use tracing::info; | |
| // ββ Audio CFT scale tower ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// Audio-native CFT scales β mirrors the text CFT in erdfa-publish. | |
| /// All six variants are part of the public tower API even if only Track/Stem/Segment | |
| /// are emitted by the current decompose_track() implementation. | |
| pub enum AudioScale { | |
| Track, // whole release | |
| Stem, // vocal / drums / bass / keys | |
| Segment, // verse / chorus / bridge | |
| Frame, // ~23 ms audio frame | |
| Sample, // individual PCM sample | |
| Byte, // raw bytes | |
| } | |
| impl AudioScale { | |
| pub fn tag(&self) -> &'static str { | |
| match self { | |
| Self::Track => "cft.track", | |
| Self::Stem => "cft.stem", | |
| Self::Segment => "cft.segment", | |
| Self::Frame => "cft.frame", | |
| Self::Sample => "cft.sample", | |
| Self::Byte => "cft.byte", | |
| } | |
| } | |
| pub fn depth(&self) -> u8 { | |
| match self { | |
| Self::Track => 0, | |
| Self::Stem => 1, | |
| Self::Segment => 2, | |
| Self::Frame => 3, | |
| Self::Sample => 4, | |
| Self::Byte => 5, | |
| } | |
| } | |
| /// Corresponding text-domain scale for cross-tower morphisms. | |
| pub fn text_analogue(&self) -> TextScale { | |
| match self { | |
| Self::Track => TextScale::Post, | |
| Self::Stem => TextScale::Paragraph, | |
| Self::Segment => TextScale::Line, | |
| Self::Frame => TextScale::Token, | |
| Self::Sample => TextScale::Emoji, | |
| Self::Byte => TextScale::Byte, | |
| } | |
| } | |
| } | |
| // ββ Shard quality tiers ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// Quality is now informational only. All shard data served via the API is | |
| /// `Full` β the old `Preview` tier is removed. NFT gates the *manifest*, not | |
| /// the data. `Degraded` and `Steganographic` tiers are retained for future | |
| /// p2p stream quality signalling. | |
| pub enum ShardQuality { | |
| Full, // full public shard β always returned | |
| Degraded { kbps: u16 }, // low-bitrate p2p stream (reserved) | |
| Steganographic, // hidden in cover content (reserved) | |
| } | |
| // ββ In-memory shard store ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// Lightweight in-process shard index (cid β JSON metadata). | |
| /// Populated at startup by indexing pre-built shards from disk or via upload. | |
| pub struct ShardStore(pub RwLock<HashMap<String, serde_json::Value>>); | |
| impl ShardStore { | |
| pub fn new() -> Self { | |
| Self(RwLock::new(HashMap::new())) | |
| } | |
| pub fn insert(&self, cid: &str, data: serde_json::Value) { | |
| self.0.write().unwrap().insert(cid.to_string(), data); | |
| } | |
| pub fn get(&self, cid: &str) -> Option<serde_json::Value> { | |
| self.0.read().unwrap().get(cid).cloned() | |
| } | |
| } | |
| impl Default for ShardStore { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| // ββ CFT decomposition ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// Decompose track metadata into erdfa-publish `Shard`s at each audio scale. | |
| /// | |
| /// Returns shards for: | |
| /// - one Track-level shard | |
| /// - one Stem shard per stem label | |
| /// - one Segment shard per segment label | |
| pub fn decompose_track(isrc: &Isrc, stems: &[&str], segments: &[&str]) -> Vec<Shard> { | |
| let prefix = &isrc.0; | |
| let mut shards = Vec::new(); | |
| // Track level | |
| shards.push(Shard::new( | |
| format!("{prefix}_track"), | |
| Component::KeyValue { | |
| pairs: vec![ | |
| ("isrc".into(), isrc.0.clone()), | |
| ("scale".into(), AudioScale::Track.tag().into()), | |
| ("stems".into(), stems.len().to_string()), | |
| ("segments".into(), segments.len().to_string()), | |
| ], | |
| }, | |
| )); | |
| // Stem level | |
| for (i, stem) in stems.iter().enumerate() { | |
| shards.push(Shard::new( | |
| format!("{prefix}_{stem}"), | |
| Component::KeyValue { | |
| pairs: vec![ | |
| ("isrc".into(), isrc.0.clone()), | |
| ("scale".into(), AudioScale::Stem.tag().into()), | |
| ("stem".into(), stem.to_string()), | |
| ("index".into(), i.to_string()), | |
| ("parent".into(), format!("{prefix}_track")), | |
| ], | |
| }, | |
| )); | |
| } | |
| // Segment level | |
| for (i, seg) in segments.iter().enumerate() { | |
| shards.push(Shard::new( | |
| format!("{prefix}_seg{i}"), | |
| Component::KeyValue { | |
| pairs: vec![ | |
| ("isrc".into(), isrc.0.clone()), | |
| ("scale".into(), AudioScale::Segment.tag().into()), | |
| ("label".into(), seg.to_string()), | |
| ("index".into(), i.to_string()), | |
| ("parent".into(), format!("{prefix}_track")), | |
| ], | |
| }, | |
| )); | |
| } | |
| shards | |
| } | |
| /// Build a `ShardSet` manifest and serialise all shards to a DA51-tagged CBOR tar archive. | |
| /// | |
| /// Each shard is encoded individually with `Shard::to_cbor()` (DA51 tag) and | |
| /// collected using `ShardSet::to_tar()`. Intended for batch export of track shards. | |
| pub fn shards_to_tar(name: &str, shards: &[Shard]) -> anyhow::Result<Vec<u8>> { | |
| let mut set = ShardSet::new(name); | |
| for s in shards { | |
| set.add(s); | |
| } | |
| let mut buf = Vec::new(); | |
| set.to_tar(shards, &mut buf)?; | |
| Ok(buf) | |
| } | |
| // ββ HTTP handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| use crate::AppState; | |
| /// `GET /api/shard/:cid` | |
| /// | |
| /// Returns the full shard JSON to any caller β shards are public DA on BTFS. | |
| /// NFT ownership is NOT checked here; it gates only `/api/manifest/:token_id` | |
| /// (the assembly instructions + optional decryption key). | |
| /// | |
| /// The optional `x-wallet-address` header is accepted but only logged for | |
| /// analytics; it does not alter the response. | |
| pub async fn get_shard( | |
| State(state): State<AppState>, | |
| Path(cid): Path<String>, | |
| headers: axum::http::HeaderMap, | |
| ) -> Result<Json<serde_json::Value>, StatusCode> { | |
| // LangSec: CID must be non-empty and β€ 128 chars | |
| if cid.is_empty() || cid.len() > 128 { | |
| return Err(StatusCode::UNPROCESSABLE_ENTITY); | |
| } | |
| let shard_data = state.shard_store.get(&cid).ok_or(StatusCode::NOT_FOUND)?; | |
| let wallet = headers | |
| .get("x-wallet-address") | |
| .and_then(|v| v.to_str().ok()) | |
| .map(String::from); | |
| info!(cid = %cid, wallet = ?wallet, "Public shard served"); | |
| Ok(Json(serde_json::json!({ | |
| "cid": cid, | |
| "quality": "full", | |
| "data": shard_data, | |
| // Hint: for assembly instructions use GET /api/manifest/<token_id> (NFT-gated) | |
| "manifest_hint": "/api/manifest/{token_id}", | |
| }))) | |
| } | |
| /// `POST /api/shard/decompose` | |
| /// | |
| /// Accepts `{ "isrc": "...", "stems": [...], "segments": [...] }`, runs | |
| /// `decompose_track`, stores shards in the in-process index, and returns | |
| /// the shard CID list. | |
| pub async fn decompose_and_index( | |
| State(state): State<AppState>, | |
| Json(body): Json<serde_json::Value>, | |
| ) -> Result<Json<serde_json::Value>, StatusCode> { | |
| let isrc_str = body | |
| .get("isrc") | |
| .and_then(|v| v.as_str()) | |
| .ok_or(StatusCode::BAD_REQUEST)?; | |
| let isrc = | |
| shared::parsers::recognize_isrc(isrc_str).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)?; | |
| let stems: Vec<&str> = body | |
| .get("stems") | |
| .and_then(|v| v.as_array()) | |
| .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) | |
| .unwrap_or_default(); | |
| let segments: Vec<&str> = body | |
| .get("segments") | |
| .and_then(|v| v.as_array()) | |
| .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) | |
| .unwrap_or_default(); | |
| let shards = decompose_track(&isrc, &stems, &segments); | |
| for shard in &shards { | |
| let json = serde_json::to_value(shard).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; | |
| state.shard_store.insert(&shard.cid, json); | |
| info!(id = %shard.id, cid = %shard.cid, "Shard indexed"); | |
| } | |
| Ok(Json(serde_json::json!({ | |
| "isrc": isrc_str, | |
| "shards": shards.len(), | |
| "cids": shard_cid_list(&shards), | |
| }))) | |
| } | |
| // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fn shard_cid_list(shards: &[Shard]) -> Vec<String> { | |
| shards.iter().map(|s| s.cid.clone()).collect() | |
| } | |