P1a: material import, sixteen faces, sample engine and quarry
This commit is contained in:
@@ -10,21 +10,57 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use zhila_protocol::{
|
||||
Actor, AudioInfo, Command, CommandResult, Event, LOOP_BARS, LOOP_STEPS, MAX_BPM, MAX_GAIN,
|
||||
MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State, TransportState,
|
||||
VEIN_COUNT, VeinId, VeinState,
|
||||
Actor, AudioInfo, Command, CommandResult, CoreSummary, Event, FaceRef, LOOP_BARS, LOOP_STEPS,
|
||||
MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State,
|
||||
TransportState, VEIN_COUNT, VeinId, VeinState,
|
||||
};
|
||||
|
||||
pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66];
|
||||
|
||||
/// A face as the engine needs it: half-open sample range in the core's source
|
||||
/// rate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioFace {
|
||||
pub index: u8,
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub rms: f32,
|
||||
}
|
||||
|
||||
/// A core with its samples. The archive keeps this; the engine receives a
|
||||
/// snapshot of it through the control layer.
|
||||
#[derive(Clone)]
|
||||
pub struct Core {
|
||||
pub summary: CoreSummary,
|
||||
pub samples: Arc<[f32]>,
|
||||
pub faces: Vec<AudioFace>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AudioCore {
|
||||
pub id: String,
|
||||
pub sample_rate: u32,
|
||||
pub samples: Arc<[f32]>,
|
||||
pub faces: Vec<AudioFace>,
|
||||
}
|
||||
|
||||
/// Immutable material view shared with the audio thread.
|
||||
#[derive(Clone)]
|
||||
pub struct MaterialSnapshot {
|
||||
pub pattern: Pattern,
|
||||
pub cores: Vec<AudioCore>,
|
||||
}
|
||||
|
||||
/// The audio engine as seen by the session. Implementations must be real-time
|
||||
/// safe on the audio thread and non-blocking on these methods.
|
||||
pub trait Engine: Send + Sync {
|
||||
fn play(&self);
|
||||
fn stop(&self);
|
||||
fn strike(&self, vein: VeinId, velocity: f32);
|
||||
fn strike_face(&self, core: &str, face: u8, velocity: f32);
|
||||
fn set_tempo(&self, bpm: f32);
|
||||
fn set_gain(&self, vein: VeinId, gain: f32);
|
||||
fn set_material(&self, material: Arc<MaterialSnapshot>);
|
||||
fn info(&self) -> AudioInfo;
|
||||
fn meter(&self) -> MeterFrame;
|
||||
}
|
||||
@@ -37,6 +73,14 @@ pub enum CoreError {
|
||||
InvalidVelocity,
|
||||
#[error("gain must be between 0 and 1.2")]
|
||||
InvalidGain,
|
||||
#[error("pattern row must be 0..4")]
|
||||
InvalidRow,
|
||||
#[error("step must be 0..15")]
|
||||
InvalidStep,
|
||||
#[error("no such core or face")]
|
||||
UnknownFace,
|
||||
#[error("this command only exists on the binary import path")]
|
||||
UnsupportedCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -55,6 +99,7 @@ pub struct Session {
|
||||
playing: bool,
|
||||
gains: [f32; VEIN_COUNT],
|
||||
pattern: Pattern,
|
||||
cores: Vec<Core>,
|
||||
journal: Vec<JournalEntry>,
|
||||
engine: Arc<dyn Engine>,
|
||||
}
|
||||
@@ -65,16 +110,19 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn with_pattern(engine: Arc<dyn Engine>, pattern: Pattern) -> Self {
|
||||
Self {
|
||||
let session = Self {
|
||||
revision: 0,
|
||||
seq: 0,
|
||||
bpm: 128.0,
|
||||
playing: false,
|
||||
gains: INITIAL_GAINS,
|
||||
pattern,
|
||||
cores: Vec::new(),
|
||||
journal: Vec::new(),
|
||||
engine,
|
||||
}
|
||||
};
|
||||
session.sync_material();
|
||||
session
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
@@ -109,6 +157,7 @@ impl Session {
|
||||
})
|
||||
.collect(),
|
||||
pattern: self.pattern.clone(),
|
||||
cores: self.cores.iter().map(|core| core.summary.clone()).collect(),
|
||||
audio: self.engine.info(),
|
||||
journal_len: self.journal.len(),
|
||||
}
|
||||
@@ -223,9 +272,187 @@ impl Session {
|
||||
vec![Event::VeinGain { vein, gain }],
|
||||
))
|
||||
}
|
||||
Command::StrikeFace {
|
||||
core,
|
||||
face,
|
||||
velocity,
|
||||
} => {
|
||||
if !velocity.is_finite() || !(0.0..=1.0).contains(&velocity) {
|
||||
return Err(CoreError::InvalidVelocity);
|
||||
}
|
||||
self.find_face(&core, face)?;
|
||||
self.engine.strike_face(&core, face, velocity);
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::FaceStruck {
|
||||
core,
|
||||
face,
|
||||
velocity,
|
||||
}],
|
||||
))
|
||||
}
|
||||
Command::SetPatternStep {
|
||||
row,
|
||||
step,
|
||||
velocity,
|
||||
} => self.set_pattern_step(row, step, velocity, actor),
|
||||
Command::SetPatternFace { row, core, face } => {
|
||||
self.set_pattern_face(row, &core, face, actor)
|
||||
}
|
||||
Command::ImportCore { .. } => Err(CoreError::UnsupportedCommand),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cores(&self) -> &[Core] {
|
||||
&self.cores
|
||||
}
|
||||
|
||||
pub fn material_snapshot(&self) -> MaterialSnapshot {
|
||||
MaterialSnapshot {
|
||||
pattern: self.pattern.clone(),
|
||||
cores: self
|
||||
.cores
|
||||
.iter()
|
||||
.map(|core| AudioCore {
|
||||
id: core.summary.id.clone(),
|
||||
sample_rate: core.summary.sample_rate,
|
||||
samples: core.samples.clone(),
|
||||
faces: core.faces.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the daemon after the binary import and the analysis.
|
||||
pub fn import_core(&mut self, core: Core, actor: Actor) -> State {
|
||||
let name = core.summary.name.clone();
|
||||
let seal = core.summary.seal;
|
||||
self.cores.push(core);
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::ImportCore { name, seal });
|
||||
self.sync_material();
|
||||
self.snapshot()
|
||||
}
|
||||
|
||||
pub fn set_pattern_step(
|
||||
&mut self,
|
||||
row: u8,
|
||||
step: u8,
|
||||
velocity: u8,
|
||||
actor: Actor,
|
||||
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||
if row as usize >= self.pattern.rows.len() {
|
||||
return Err(CoreError::InvalidRow);
|
||||
}
|
||||
if step as usize >= LOOP_STEPS {
|
||||
return Err(CoreError::InvalidStep);
|
||||
}
|
||||
let velocity = velocity.min(127);
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
if row_ref.velocities[step as usize] == velocity {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
row_ref.velocities[step as usize] = velocity;
|
||||
self.revision += 1;
|
||||
self.record(
|
||||
actor,
|
||||
Command::SetPatternStep {
|
||||
row,
|
||||
step,
|
||||
velocity,
|
||||
},
|
||||
);
|
||||
self.sync_material();
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::PatternStep {
|
||||
row,
|
||||
step,
|
||||
velocity,
|
||||
}],
|
||||
))
|
||||
}
|
||||
|
||||
pub fn set_pattern_face(
|
||||
&mut self,
|
||||
row: u8,
|
||||
core: &str,
|
||||
face: u8,
|
||||
actor: Actor,
|
||||
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||
if row as usize >= self.pattern.rows.len() {
|
||||
return Err(CoreError::InvalidRow);
|
||||
}
|
||||
self.find_face(core, face)?;
|
||||
let reference = FaceRef {
|
||||
core: core.to_string(),
|
||||
face,
|
||||
};
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
if row_ref.face.as_ref() == Some(&reference) {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
row_ref.face = Some(reference);
|
||||
self.revision += 1;
|
||||
self.record(
|
||||
actor,
|
||||
Command::SetPatternFace {
|
||||
row,
|
||||
core: core.to_string(),
|
||||
face,
|
||||
},
|
||||
);
|
||||
self.sync_material();
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::PatternFace {
|
||||
row,
|
||||
core: core.to_string(),
|
||||
face,
|
||||
}],
|
||||
))
|
||||
}
|
||||
|
||||
fn find_face(&self, core_id: &str, face: u8) -> Result<(), CoreError> {
|
||||
let Some(core) = self
|
||||
.cores
|
||||
.iter()
|
||||
.find(|candidate| candidate.summary.id.as_str() == core_id)
|
||||
else {
|
||||
return Err(CoreError::UnknownFace);
|
||||
};
|
||||
if core.faces.iter().any(|candidate| candidate.index == face) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CoreError::UnknownFace)
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_material(&self) {
|
||||
let snapshot = self.material_snapshot();
|
||||
self.engine.set_material(Arc::new(snapshot));
|
||||
}
|
||||
|
||||
fn record(&mut self, actor: Actor, command: Command) {
|
||||
self.seq += 1;
|
||||
self.journal.push(JournalEntry {
|
||||
@@ -278,7 +505,11 @@ pub fn default_pattern() -> Pattern {
|
||||
velocities[13] = 60;
|
||||
}
|
||||
}
|
||||
rows.push(PatternRow { vein, velocities });
|
||||
rows.push(PatternRow {
|
||||
vein,
|
||||
face: None,
|
||||
velocities,
|
||||
});
|
||||
}
|
||||
Pattern {
|
||||
steps: LOOP_STEPS as u16,
|
||||
@@ -291,6 +522,7 @@ pub fn default_pattern() -> Pattern {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use zhila_protocol::{CoreSummary, FaceSummary, Seal};
|
||||
|
||||
struct TestEngine {
|
||||
calls: Mutex<Vec<String>>,
|
||||
@@ -320,6 +552,10 @@ mod tests {
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
self.calls.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine for TestEngine {
|
||||
@@ -335,6 +571,12 @@ mod tests {
|
||||
.unwrap()
|
||||
.push(format!("strike:{}:{velocity}", vein.name()));
|
||||
}
|
||||
fn strike_face(&self, core: &str, face: u8, velocity: f32) {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("face:{core}:{face}:{velocity}"));
|
||||
}
|
||||
fn set_tempo(&self, bpm: f32) {
|
||||
self.calls.lock().unwrap().push(format!("tempo:{bpm}"));
|
||||
}
|
||||
@@ -344,6 +586,12 @@ mod tests {
|
||||
.unwrap()
|
||||
.push(format!("gain:{}:{gain}", vein.name()));
|
||||
}
|
||||
fn set_material(&self, material: Arc<MaterialSnapshot>) {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("material:{}", material.cores.len()));
|
||||
}
|
||||
fn info(&self) -> AudioInfo {
|
||||
self.info.clone()
|
||||
}
|
||||
@@ -354,7 +602,43 @@ mod tests {
|
||||
|
||||
fn session() -> (Session, Arc<TestEngine>) {
|
||||
let engine = Arc::new(TestEngine::new());
|
||||
(Session::new(engine.clone()), engine)
|
||||
let session = Session::new(engine.clone());
|
||||
engine.clear();
|
||||
(session, engine)
|
||||
}
|
||||
|
||||
fn test_core() -> Core {
|
||||
let faces: Vec<AudioFace> = (0..16)
|
||||
.map(|index| AudioFace {
|
||||
index,
|
||||
start: index as u32 * 3_000,
|
||||
end: (index as u32 + 1) * 3_000,
|
||||
rms: 0.5,
|
||||
})
|
||||
.collect();
|
||||
let summary = CoreSummary {
|
||||
id: "core-test".into(),
|
||||
name: "test material".into(),
|
||||
seal: Seal::Found,
|
||||
sample_rate: 48_000,
|
||||
duration_sec: 1.0,
|
||||
faces: faces
|
||||
.iter()
|
||||
.map(|face| FaceSummary {
|
||||
index: face.index,
|
||||
rank: face.index,
|
||||
start_sec: face.start as f32 / 48_000.0,
|
||||
end_sec: face.end as f32 / 48_000.0,
|
||||
rms: face.rms,
|
||||
})
|
||||
.collect(),
|
||||
peaks: vec![0.0, 0.5, 1.0],
|
||||
};
|
||||
Core {
|
||||
summary,
|
||||
samples: Arc::from(vec![0.0f32; 48_000]),
|
||||
faces,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -467,5 +751,96 @@ mod tests {
|
||||
assert_eq!(state.pattern.rows.len(), VEIN_COUNT);
|
||||
assert_eq!(state.audio.device, "test");
|
||||
assert_eq!(state.journal_len, 0);
|
||||
assert!(state.cores.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_a_core_and_syncs_material() {
|
||||
let (mut session, engine) = session();
|
||||
let state = session.import_core(test_core(), Actor::Hand);
|
||||
assert_eq!(state.cores.len(), 1);
|
||||
assert_eq!(state.cores[0].id, "core-test");
|
||||
assert_eq!(session.journal().len(), 1);
|
||||
assert!(matches!(
|
||||
session.journal()[0].command,
|
||||
Command::ImportCore { .. }
|
||||
));
|
||||
assert!(engine.calls().iter().any(|call| call == "material:1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_edits_are_journaled_and_synced() {
|
||||
let (mut session, engine) = session();
|
||||
session.import_core(test_core(), Actor::Hand);
|
||||
engine.clear();
|
||||
let (result, events) = session
|
||||
.set_pattern_face(0, "core-test", 3, Actor::Hand)
|
||||
.unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.pattern.rows[0].face.as_ref().unwrap().face, 3);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::PatternFace {
|
||||
row: 0,
|
||||
core: "core-test".into(),
|
||||
face: 3
|
||||
}]
|
||||
);
|
||||
let (result, events) = session.set_pattern_step(0, 1, 100, Actor::Hand).unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.pattern.rows[0].velocities[1], 100);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::PatternStep {
|
||||
row: 0,
|
||||
step: 1,
|
||||
velocity: 100
|
||||
}]
|
||||
);
|
||||
assert_eq!(session.journal().len(), 3);
|
||||
assert!(
|
||||
engine
|
||||
.calls()
|
||||
.iter()
|
||||
.any(|call| call.starts_with("material:"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_are_validated_and_can_be_struck() {
|
||||
let (mut session, engine) = session();
|
||||
session.import_core(test_core(), Actor::Hand);
|
||||
engine.clear();
|
||||
let (result, events) = session
|
||||
.apply(
|
||||
Command::StrikeFace {
|
||||
core: "core-test".into(),
|
||||
face: 5,
|
||||
velocity: 0.75,
|
||||
},
|
||||
Actor::Hand,
|
||||
)
|
||||
.unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.cores.len(), 1);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(engine.calls(), vec!["face:core-test:5:0.75"]);
|
||||
assert!(matches!(
|
||||
session.apply(
|
||||
Command::StrikeFace {
|
||||
core: "missing".into(),
|
||||
face: 0,
|
||||
velocity: 0.5
|
||||
},
|
||||
Actor::Hand
|
||||
),
|
||||
Err(CoreError::UnknownFace)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
zhila-core = { path = "../zhila-core" }
|
||||
zhila-engine = { path = "../zhila-engine" }
|
||||
zhila-material = { path = "../zhila-material" }
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
tokio.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
|
||||
@@ -20,7 +20,7 @@ use tracing::{info, warn};
|
||||
use zhila_core::{CoreError, Engine, Session};
|
||||
use zhila_engine::{AudioEngine, NullEngine};
|
||||
use zhila_protocol::{
|
||||
Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, ServerMessage,
|
||||
Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage,
|
||||
};
|
||||
|
||||
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -216,7 +216,7 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
protocol,
|
||||
token,
|
||||
} => (client, protocol, token),
|
||||
ClientMessage::Command { .. } => {
|
||||
ClientMessage::Command { .. } | ClientMessage::ImportCore { .. } => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Protocol, "expected hello before commands"),
|
||||
@@ -303,9 +303,60 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
}
|
||||
});
|
||||
|
||||
let mut pending_import: Option<(u64, String, Seal)> = None;
|
||||
while let Some(frame) = source.next().await {
|
||||
let message = match frame {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Binary(bytes)) => {
|
||||
let Some((id, name, seal)) = pending_import.take() else {
|
||||
let _ = direct_tx.send(error_message(
|
||||
None,
|
||||
ErrorCode::BadRequest,
|
||||
"a binary frame arrived without an import header",
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let import = tokio::task::spawn_blocking(move || {
|
||||
zhila_material::import_bytes(&name, seal, bytes.to_vec())
|
||||
})
|
||||
.await;
|
||||
match import {
|
||||
Ok(Ok(core)) => {
|
||||
let summary = core.summary.clone();
|
||||
let state = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.import_core(core, Actor::Hand)
|
||||
};
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Imported { id, core: summary })
|
||||
.expect("import serializes"),
|
||||
));
|
||||
let snapshot = ServerMessage::Snapshot {
|
||||
revision: state.revision,
|
||||
state,
|
||||
};
|
||||
let _ = shared
|
||||
.out
|
||||
.send(Outbound::Text(serde_json::to_string(&snapshot).unwrap()));
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::BadRequest,
|
||||
error.to_string(),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("import task failed: {error}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the import task failed",
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(_) => continue,
|
||||
Err(error) => {
|
||||
@@ -330,6 +381,17 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
ClientMessage::Hello { .. } => {
|
||||
let _ = direct_tx.send(error_message(None, ErrorCode::Protocol, "already greeted"));
|
||||
}
|
||||
ClientMessage::ImportCore { id, name, seal } => {
|
||||
if pending_import.is_some() {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::BadRequest,
|
||||
"the previous import has not received its bytes yet",
|
||||
));
|
||||
} else {
|
||||
pending_import = Some((id, name, seal));
|
||||
}
|
||||
}
|
||||
ClientMessage::Command { id, command } => {
|
||||
let outcome = {
|
||||
let mut session = shared.session.lock().await;
|
||||
@@ -403,9 +465,13 @@ fn origin_allowed(origin: &str) -> bool {
|
||||
|
||||
fn error_code(error: &CoreError) -> ErrorCode {
|
||||
match error {
|
||||
CoreError::InvalidTempo | CoreError::InvalidVelocity | CoreError::InvalidGain => {
|
||||
ErrorCode::BadRequest
|
||||
}
|
||||
CoreError::InvalidTempo
|
||||
| CoreError::InvalidVelocity
|
||||
| CoreError::InvalidGain
|
||||
| CoreError::InvalidRow
|
||||
| CoreError::InvalidStep
|
||||
| CoreError::UnknownFace
|
||||
| CoreError::UnsupportedCommand => ErrorCode::BadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+237
-36
@@ -12,7 +12,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{BufferSize, FromSample, SampleFormat, SizedSample, Stream, StreamConfig};
|
||||
use rtrb::{Consumer, Producer, RingBuffer};
|
||||
use thiserror::Error;
|
||||
use zhila_core::Engine as EngineControl;
|
||||
use zhila_core::{Engine as EngineControl, MaterialSnapshot};
|
||||
use zhila_protocol::{
|
||||
AudioInfo, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern,
|
||||
STEPS_PER_BAR, VEIN_COUNT, VeinId,
|
||||
@@ -20,13 +20,27 @@ use zhila_protocol::{
|
||||
|
||||
const VOICES: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// (vein index, velocity, optional (core index, face index)) for one step.
|
||||
type StepTrigger = (usize, u8, Option<(usize, u8)>);
|
||||
|
||||
enum AudioCommand {
|
||||
Play,
|
||||
Stop,
|
||||
Strike { vein: u8, velocity: f32 },
|
||||
Strike {
|
||||
vein: u8,
|
||||
velocity: f32,
|
||||
},
|
||||
StrikeFace {
|
||||
core: String,
|
||||
face: u8,
|
||||
velocity: f32,
|
||||
},
|
||||
SetTempo(f32),
|
||||
SetGain { vein: u8, gain: f32 },
|
||||
SetGain {
|
||||
vein: u8,
|
||||
gain: f32,
|
||||
},
|
||||
SetMaterial(Arc<MaterialSnapshot>),
|
||||
}
|
||||
|
||||
struct Levels {
|
||||
@@ -63,6 +77,14 @@ impl Levels {
|
||||
}
|
||||
}
|
||||
|
||||
struct SampleVoice {
|
||||
core_index: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
position: f64,
|
||||
rate: f64,
|
||||
}
|
||||
|
||||
struct Voice {
|
||||
active: bool,
|
||||
vein: usize,
|
||||
@@ -78,6 +100,7 @@ struct Voice {
|
||||
hp: f32,
|
||||
prev: f32,
|
||||
rng: u32,
|
||||
sample: Option<SampleVoice>,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
@@ -97,6 +120,7 @@ impl Voice {
|
||||
hp: 0.0,
|
||||
prev: 0.0,
|
||||
rng: 0x9e37_79b9,
|
||||
sample: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,19 +134,52 @@ impl Voice {
|
||||
self.lp = 0.0;
|
||||
self.hp = 0.0;
|
||||
self.prev = 0.0;
|
||||
let (freq, attack, decay, pan) = match vein {
|
||||
VeinId::Bass => (55.0, 0.004, 3.2, 0.0),
|
||||
VeinId::Crack => (0.0, 0.001, 38.0, 0.35),
|
||||
VeinId::Voice => (220.0, 0.09, 1.1, -0.25),
|
||||
VeinId::Noise => (0.0, 0.004, 5.0, 0.55),
|
||||
VeinId::Silt => (1318.5, 0.002, 14.0, -0.45),
|
||||
self.sample = None;
|
||||
let (freq, attack, decay) = match vein {
|
||||
VeinId::Bass => (55.0, 0.004, 3.2),
|
||||
VeinId::Crack => (0.0, 0.001, 38.0),
|
||||
VeinId::Voice => (220.0, 0.09, 1.1),
|
||||
VeinId::Noise => (0.0, 0.004, 5.0),
|
||||
VeinId::Silt => (1318.5, 0.002, 14.0),
|
||||
};
|
||||
self.freq = freq;
|
||||
self.attack = attack;
|
||||
self.decay = decay;
|
||||
let angle = (pan + 1.0) * FRAC_PI_4;
|
||||
self.gain_l = angle.cos();
|
||||
self.gain_r = angle.sin();
|
||||
let (gain_l, gain_r) = pan_for(vein);
|
||||
self.gain_l = gain_l;
|
||||
self.gain_r = gain_r;
|
||||
}
|
||||
|
||||
fn trigger_sample(
|
||||
&mut self,
|
||||
vein: VeinId,
|
||||
velocity: f32,
|
||||
core_index: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
rate: f64,
|
||||
) {
|
||||
self.active = true;
|
||||
self.vein = vein.index();
|
||||
self.phase = 0.0;
|
||||
self.freq = 0.0;
|
||||
self.t = 0.0;
|
||||
self.velocity = velocity.clamp(0.0, 1.0);
|
||||
self.attack = 0.004;
|
||||
self.decay = 4.0;
|
||||
self.lp = 0.0;
|
||||
self.hp = 0.0;
|
||||
self.prev = 0.0;
|
||||
let (gain_l, gain_r) = pan_for(vein);
|
||||
self.gain_l = gain_l;
|
||||
self.gain_r = gain_r;
|
||||
self.sample = Some(SampleVoice {
|
||||
core_index,
|
||||
start,
|
||||
end,
|
||||
position: start as f64,
|
||||
rate,
|
||||
});
|
||||
}
|
||||
|
||||
fn noise(&mut self) -> f32 {
|
||||
@@ -130,8 +187,35 @@ impl Voice {
|
||||
(self.rng as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||||
}
|
||||
|
||||
fn next(&mut self, sample_rate: f32) -> f32 {
|
||||
fn next(&mut self, sample_rate: f32, material: Option<&MaterialSnapshot>) -> f32 {
|
||||
self.t += 1.0 / sample_rate;
|
||||
if let Some(voice) = self.sample.as_mut() {
|
||||
let Some(core) = material.and_then(|material| material.cores.get(voice.core_index))
|
||||
else {
|
||||
self.active = false;
|
||||
return 0.0;
|
||||
};
|
||||
if voice.position >= voice.end as f64 {
|
||||
self.active = false;
|
||||
return 0.0;
|
||||
}
|
||||
let index = voice.position.floor() as usize;
|
||||
let fraction = (voice.position - index as f64) as f32;
|
||||
let first = core.samples.get(index).copied().unwrap_or(0.0);
|
||||
let second = core.samples.get(index + 1).copied().unwrap_or(first);
|
||||
let raw = first + (second - first) * fraction;
|
||||
let remaining =
|
||||
((voice.end as f64 - voice.position) / voice.rate / sample_rate as f64) as f32;
|
||||
let fade_in = (self.t / 0.004).min(1.0);
|
||||
let fade_out = (remaining / 0.004).min(1.0);
|
||||
voice.position += voice.rate;
|
||||
if voice.position >= voice.end as f64 {
|
||||
self.active = false;
|
||||
}
|
||||
let _ = voice.start;
|
||||
return raw * fade_in.min(fade_out) * self.velocity;
|
||||
}
|
||||
|
||||
let attack = (self.t / self.attack).min(1.0);
|
||||
let env = attack * (-self.t * self.decay).exp();
|
||||
if env < 0.0002 || self.t > 8.0 {
|
||||
@@ -173,6 +257,18 @@ impl Voice {
|
||||
}
|
||||
}
|
||||
|
||||
fn pan_for(vein: VeinId) -> (f32, f32) {
|
||||
let pan = match vein {
|
||||
VeinId::Bass => 0.0,
|
||||
VeinId::Crack => 0.35,
|
||||
VeinId::Voice => -0.25,
|
||||
VeinId::Noise => 0.55,
|
||||
VeinId::Silt => -0.45,
|
||||
};
|
||||
let angle = (pan + 1.0) * FRAC_PI_4;
|
||||
(angle.cos(), angle.sin())
|
||||
}
|
||||
|
||||
struct AudioState {
|
||||
rx: Consumer<AudioCommand>,
|
||||
sample_rate: f32,
|
||||
@@ -181,7 +277,8 @@ struct AudioState {
|
||||
playing: bool,
|
||||
step: usize,
|
||||
pending: f64,
|
||||
pattern: [[u8; LOOP_STEPS]; VEIN_COUNT],
|
||||
fallback: Pattern,
|
||||
material: Option<Arc<MaterialSnapshot>>,
|
||||
voices: [Voice; VOICES],
|
||||
gains: [f32; VEIN_COUNT],
|
||||
master_env: f32,
|
||||
@@ -192,13 +289,6 @@ struct AudioState {
|
||||
|
||||
impl AudioState {
|
||||
fn new(sample_rate: u32, channels: u16, pattern: &Pattern, rx: Consumer<AudioCommand>) -> Self {
|
||||
let mut grid = [[0u8; LOOP_STEPS]; VEIN_COUNT];
|
||||
for row in &pattern.rows {
|
||||
let index = row.vein.index();
|
||||
for (step, velocity) in row.velocities.iter().take(LOOP_STEPS).enumerate() {
|
||||
grid[index][step] = *velocity;
|
||||
}
|
||||
}
|
||||
Self {
|
||||
rx,
|
||||
sample_rate: sample_rate as f32,
|
||||
@@ -207,7 +297,8 @@ impl AudioState {
|
||||
playing: false,
|
||||
step: 0,
|
||||
pending: 0.0,
|
||||
pattern: grid,
|
||||
fallback: pattern.clone(),
|
||||
material: None,
|
||||
voices: std::array::from_fn(|_| Voice::inactive()),
|
||||
gains: zhila_core::INITIAL_GAINS,
|
||||
master_env: 0.0,
|
||||
@@ -246,6 +337,34 @@ impl AudioState {
|
||||
};
|
||||
self.trigger(vein, velocity);
|
||||
}
|
||||
AudioCommand::StrikeFace {
|
||||
core,
|
||||
face,
|
||||
velocity,
|
||||
} => {
|
||||
let Some(material) = self.material.as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(core_index) = material
|
||||
.cores
|
||||
.iter()
|
||||
.position(|candidate| candidate.id == core)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let vein_index = material
|
||||
.pattern
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| {
|
||||
row.face.as_ref().is_some_and(|reference| {
|
||||
reference.core == core && reference.face == face
|
||||
})
|
||||
})
|
||||
.map(|row| row.vein.index())
|
||||
.unwrap_or(0);
|
||||
self.trigger_sample(core_index, face, vein_index, velocity);
|
||||
}
|
||||
AudioCommand::SetTempo(bpm) => {
|
||||
self.bpm = bpm.clamp(MIN_BPM, MAX_BPM);
|
||||
}
|
||||
@@ -254,15 +373,14 @@ impl AudioState {
|
||||
self.gains[index.index()] = gain.clamp(MIN_GAIN, MAX_GAIN);
|
||||
}
|
||||
}
|
||||
AudioCommand::SetMaterial(material) => {
|
||||
self.material = Some(material);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger(&mut self, vein: VeinId, velocity: f32) {
|
||||
if velocity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let index = self
|
||||
.voices
|
||||
fn free_voice(&self) -> usize {
|
||||
self.voices
|
||||
.iter()
|
||||
.position(|voice| !voice.active)
|
||||
.unwrap_or_else(|| {
|
||||
@@ -272,18 +390,86 @@ impl AudioState {
|
||||
.max_by(|a, b| a.1.t.total_cmp(&b.1.t))
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn trigger(&mut self, vein: VeinId, velocity: f32) {
|
||||
if velocity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let index = self.free_voice();
|
||||
self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
self.voices[index].trigger(vein, velocity, self.rng);
|
||||
}
|
||||
|
||||
fn trigger_sample(
|
||||
&mut self,
|
||||
core_index: usize,
|
||||
face_index: u8,
|
||||
vein_index: usize,
|
||||
velocity: f32,
|
||||
) {
|
||||
if velocity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(material) = self.material.as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(core) = material.cores.get(core_index) else {
|
||||
return;
|
||||
};
|
||||
let Some(face) = core.faces.iter().find(|face| face.index == face_index) else {
|
||||
return;
|
||||
};
|
||||
let start = face.start as usize;
|
||||
let end = face.end as usize;
|
||||
if start >= end || end > core.samples.len() {
|
||||
return;
|
||||
}
|
||||
let Some(vein) = VeinId::from_index(vein_index) else {
|
||||
return;
|
||||
};
|
||||
let rate = core.sample_rate as f64 / self.sample_rate as f64;
|
||||
let index = self.free_voice();
|
||||
self.voices[index].trigger_sample(vein, velocity, core_index, start, end, rate);
|
||||
}
|
||||
|
||||
fn trigger_step(&mut self) {
|
||||
let step = self.step;
|
||||
for vein in 0..VEIN_COUNT {
|
||||
let velocity = self.pattern[vein][step];
|
||||
if velocity > 0
|
||||
&& let Some(vein) = VeinId::from_index(vein)
|
||||
{
|
||||
let (plan, count) = {
|
||||
let material = self.material.as_ref().cloned();
|
||||
let pattern = material
|
||||
.as_ref()
|
||||
.map(|material| &material.pattern)
|
||||
.unwrap_or(&self.fallback);
|
||||
let mut plan: [StepTrigger; 8] = [(0, 0, None); 8];
|
||||
let mut count = 0;
|
||||
for row in &pattern.rows {
|
||||
if count >= plan.len() {
|
||||
break;
|
||||
}
|
||||
let velocity = row.velocities.get(step).copied().unwrap_or(0);
|
||||
if velocity == 0 {
|
||||
continue;
|
||||
}
|
||||
let face = row.face.as_ref().and_then(|reference| {
|
||||
material.as_ref().and_then(|material| {
|
||||
material
|
||||
.cores
|
||||
.iter()
|
||||
.position(|core| core.id == reference.core)
|
||||
.map(|core_index| (core_index, reference.face))
|
||||
})
|
||||
});
|
||||
plan[count] = (row.vein.index(), velocity, face);
|
||||
count += 1;
|
||||
}
|
||||
(plan, count)
|
||||
};
|
||||
for (vein_index, velocity, face) in plan.into_iter().take(count) {
|
||||
if let Some((core_index, face_index)) = face {
|
||||
self.trigger_sample(core_index, face_index, vein_index, velocity as f32 / 127.0);
|
||||
} else if let Some(vein) = VeinId::from_index(vein_index) {
|
||||
self.trigger(vein, velocity as f32 / 127.0);
|
||||
}
|
||||
}
|
||||
@@ -299,6 +485,7 @@ impl AudioState {
|
||||
self.handle(command);
|
||||
}
|
||||
|
||||
let material = self.material.as_ref().cloned();
|
||||
for frame in data.chunks_mut(self.channels) {
|
||||
if self.playing {
|
||||
if self.pending <= 0.0 {
|
||||
@@ -315,7 +502,7 @@ impl AudioState {
|
||||
if !voice.active {
|
||||
continue;
|
||||
}
|
||||
let sample = voice.next(self.sample_rate);
|
||||
let sample = voice.next(self.sample_rate, material.as_deref());
|
||||
let index = voice.vein;
|
||||
let amplitude = sample * self.gains[index];
|
||||
left += amplitude * voice.gain_l;
|
||||
@@ -374,6 +561,14 @@ impl EngineControl for Control {
|
||||
});
|
||||
}
|
||||
|
||||
fn strike_face(&self, core: &str, face: u8, velocity: f32) {
|
||||
self.send(AudioCommand::StrikeFace {
|
||||
core: core.to_string(),
|
||||
face,
|
||||
velocity,
|
||||
});
|
||||
}
|
||||
|
||||
fn set_tempo(&self, bpm: f32) {
|
||||
self.send(AudioCommand::SetTempo(bpm));
|
||||
}
|
||||
@@ -385,6 +580,10 @@ impl EngineControl for Control {
|
||||
});
|
||||
}
|
||||
|
||||
fn set_material(&self, material: Arc<MaterialSnapshot>) {
|
||||
self.send(AudioCommand::SetMaterial(material));
|
||||
}
|
||||
|
||||
fn info(&self) -> AudioInfo {
|
||||
self.info.clone()
|
||||
}
|
||||
@@ -527,8 +726,10 @@ impl EngineControl for NullEngine {
|
||||
fn play(&self) {}
|
||||
fn stop(&self) {}
|
||||
fn strike(&self, _vein: VeinId, _velocity: f32) {}
|
||||
fn strike_face(&self, _core: &str, _face: u8, _velocity: f32) {}
|
||||
fn set_tempo(&self, _bpm: f32) {}
|
||||
fn set_gain(&self, _vein: VeinId, _gain: f32) {}
|
||||
fn set_material(&self, _material: Arc<MaterialSnapshot>) {}
|
||||
fn info(&self) -> AudioInfo {
|
||||
self.info.clone()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "zhila-material"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
zhila-core = { path = "../zhila-core" }
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
symphonia = "0.5"
|
||||
blake3 = "1"
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
hound = "3"
|
||||
@@ -0,0 +1,316 @@
|
||||
//! ZHILA material: decode an uploaded file, find its seams, cut sixteen faces.
|
||||
//!
|
||||
//! The import path is intentionally CPU-bound and off the audio thread. The
|
||||
//! archive receives mono f32 samples at the source rate; the engine resamples
|
||||
//! playback through its rate while the output stays at 48 kHz.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use blake3::Hasher;
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::DecoderOptions;
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use thiserror::Error;
|
||||
use zhila_core::{AudioFace, Core};
|
||||
use zhila_protocol::{CoreSummary, FaceSummary, Seal};
|
||||
|
||||
const FACE_COUNT: usize = 16;
|
||||
const PEAK_BINS: usize = 1400;
|
||||
const ONSET_HOPS_PER_SECOND: f32 = 100.0; // 10 ms energy hops
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MaterialError {
|
||||
#[error("could not decode the audio: {0}")]
|
||||
Decode(String),
|
||||
#[error("the file has no audio track")]
|
||||
NoTrack,
|
||||
#[error("the audio is too short to cut")]
|
||||
TooShort,
|
||||
}
|
||||
|
||||
/// Decode `bytes`, analyse the seams and build a core. `name` is used both as
|
||||
/// the display name and as the format hint (its extension).
|
||||
pub fn import_bytes(name: &str, seal: Seal, bytes: Vec<u8>) -> Result<Core, MaterialError> {
|
||||
let mut hasher = Hasher::new();
|
||||
hasher.update(&bytes);
|
||||
let digest = hasher.finalize();
|
||||
let id = format!("core-{}", &digest.to_hex()[..10]);
|
||||
|
||||
let (samples, sample_rate) = decode(name, bytes)?;
|
||||
if samples.len() < 1_000 {
|
||||
return Err(MaterialError::TooShort);
|
||||
}
|
||||
let (faces, face_summaries) = analyse(&samples, sample_rate);
|
||||
let peaks = waveform_peaks(&samples);
|
||||
let duration_sec = samples.len() as f32 / sample_rate as f32;
|
||||
let summary = CoreSummary {
|
||||
id,
|
||||
name: display_name(name),
|
||||
seal,
|
||||
sample_rate,
|
||||
duration_sec,
|
||||
faces: face_summaries,
|
||||
peaks,
|
||||
};
|
||||
Ok(Core {
|
||||
summary,
|
||||
samples: Arc::from(samples),
|
||||
faces,
|
||||
})
|
||||
}
|
||||
|
||||
fn display_name(name: &str) -> String {
|
||||
let base = std::path::Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or("core")
|
||||
.trim();
|
||||
if base.is_empty() {
|
||||
"core".into()
|
||||
} else {
|
||||
base.chars().take(48).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(name: &str, bytes: Vec<u8>) -> Result<(Vec<f32>, u32), MaterialError> {
|
||||
let source = MediaSourceStream::new(Box::new(Cursor::new(bytes)), Default::default());
|
||||
let mut hint = Hint::new();
|
||||
if let Some(extension) = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
{
|
||||
hint.with_extension(extension);
|
||||
}
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
source,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|error| MaterialError::Decode(error.to_string()))?;
|
||||
let mut format = probed.format;
|
||||
let track = format.default_track().ok_or(MaterialError::NoTrack)?;
|
||||
let track_id = track.id;
|
||||
let codec_params = track.codec_params.clone();
|
||||
let sample_rate = codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| MaterialError::Decode("missing sample rate".into()))?;
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&codec_params, &DecoderOptions::default())
|
||||
.map_err(|error| MaterialError::Decode(error.to_string()))?;
|
||||
|
||||
let mut samples: Vec<f32> = Vec::with_capacity(48_000 * 8);
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(SymphoniaError::IoError(_)) => break,
|
||||
Err(SymphoniaError::ResetRequired) => break,
|
||||
Err(error) => return Err(MaterialError::Decode(error.to_string())),
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
let spec = *decoded.spec();
|
||||
let channels = spec.channels.count().max(1);
|
||||
let mut buffer = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
buffer.copy_interleaved_ref(decoded);
|
||||
for frame in buffer.samples().chunks(channels) {
|
||||
let mono = frame.iter().sum::<f32>() / channels as f32;
|
||||
samples.push(mono.clamp(-1.0, 1.0));
|
||||
}
|
||||
}
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(error) => return Err(MaterialError::Decode(error.to_string())),
|
||||
}
|
||||
}
|
||||
if samples.is_empty() {
|
||||
return Err(MaterialError::Decode("no samples decoded".into()));
|
||||
}
|
||||
Ok((samples, sample_rate))
|
||||
}
|
||||
|
||||
fn analyse(samples: &[f32], sample_rate: u32) -> (Vec<AudioFace>, Vec<FaceSummary>) {
|
||||
let hop = (sample_rate as f32 / ONSET_HOPS_PER_SECOND).max(64.0) as usize;
|
||||
let energy: Vec<f32> = samples
|
||||
.chunks(hop)
|
||||
.map(|chunk| {
|
||||
chunk.iter().map(|sample| sample * sample).sum::<f32>() / chunk.len().max(1) as f32
|
||||
})
|
||||
.collect();
|
||||
let mut flux = vec![0.0f32; energy.len()];
|
||||
for index in 1..energy.len() {
|
||||
flux[index] = (energy[index] - energy[index - 1]).max(0.0);
|
||||
}
|
||||
let mean = flux.iter().sum::<f32>() / flux.len().max(1) as f32;
|
||||
let variance = flux
|
||||
.iter()
|
||||
.map(|value| (value - mean) * (value - mean))
|
||||
.sum::<f32>()
|
||||
/ flux.len().max(1) as f32;
|
||||
let threshold = mean + variance.sqrt() * 0.55;
|
||||
let min_gap = (sample_rate as f32 * 0.09 / hop as f32).max(1.0) as usize;
|
||||
|
||||
let mut candidates: Vec<(usize, f32)> = Vec::new();
|
||||
for index in 1..flux.len().saturating_sub(1) {
|
||||
let value = flux[index];
|
||||
if value > threshold && value >= flux[index - 1] && value > flux[index + 1] {
|
||||
candidates.push((index, value));
|
||||
}
|
||||
}
|
||||
candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
|
||||
let mut onsets: Vec<usize> = Vec::new();
|
||||
for (index, _) in candidates {
|
||||
if onsets.len() >= FACE_COUNT - 1 {
|
||||
break;
|
||||
}
|
||||
if onsets
|
||||
.iter()
|
||||
.all(|existing| existing.abs_diff(index) >= min_gap)
|
||||
{
|
||||
onsets.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
let mut boundaries: Vec<usize> = onsets.into_iter().map(|index| index * hop).collect();
|
||||
boundaries.push(0);
|
||||
boundaries.push(samples.len());
|
||||
boundaries.sort_unstable();
|
||||
boundaries.dedup();
|
||||
while boundaries.len() < FACE_COUNT + 1 {
|
||||
let Some((split_at, _)) = boundaries
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.max_by_key(|(_, pair)| pair[1] - pair[0])
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let pair = &boundaries[split_at..split_at + 2];
|
||||
let midpoint = pair[0] + (pair[1] - pair[0]) / 2;
|
||||
if midpoint == pair[0] || midpoint == pair[1] {
|
||||
break;
|
||||
}
|
||||
boundaries.insert(split_at + 1, midpoint);
|
||||
}
|
||||
if boundaries.len() > FACE_COUNT + 1 {
|
||||
boundaries.truncate(FACE_COUNT + 1);
|
||||
let last = boundaries.len() - 1;
|
||||
boundaries[last] = samples.len();
|
||||
}
|
||||
|
||||
let mut faces = Vec::with_capacity(FACE_COUNT);
|
||||
let mut summaries = Vec::with_capacity(FACE_COUNT);
|
||||
for index in 0..FACE_COUNT {
|
||||
let start = boundaries[index];
|
||||
let end = boundaries[index + 1].max(start + 1).min(samples.len());
|
||||
let segment = &samples[start..end];
|
||||
let rms = (segment.iter().map(|sample| sample * sample).sum::<f32>()
|
||||
/ segment.len().max(1) as f32)
|
||||
.sqrt();
|
||||
faces.push(AudioFace {
|
||||
index: index as u8,
|
||||
start: start as u32,
|
||||
end: end as u32,
|
||||
rms,
|
||||
});
|
||||
summaries.push(FaceSummary {
|
||||
index: index as u8,
|
||||
rank: 0,
|
||||
start_sec: start as f32 / sample_rate as f32,
|
||||
end_sec: end as f32 / sample_rate as f32,
|
||||
rms,
|
||||
});
|
||||
}
|
||||
|
||||
let mut ranked: Vec<usize> = (0..summaries.len()).collect();
|
||||
ranked.sort_by(|a, b| summaries[*b].rms.total_cmp(&summaries[*a].rms));
|
||||
for (rank, index) in ranked.into_iter().enumerate() {
|
||||
summaries[index].rank = rank as u8;
|
||||
}
|
||||
|
||||
(faces, summaries)
|
||||
}
|
||||
|
||||
fn waveform_peaks(samples: &[f32]) -> Vec<f32> {
|
||||
let mut peaks = vec![0.0f32; PEAK_BINS];
|
||||
let chunk = (samples.len() as f32 / PEAK_BINS as f32).ceil().max(1.0) as usize;
|
||||
for (index, frame) in samples.chunks(chunk).take(PEAK_BINS).enumerate() {
|
||||
peaks[index] = frame
|
||||
.iter()
|
||||
.fold(0.0f32, |peak, sample| peak.max(sample.abs()));
|
||||
}
|
||||
let maximum = peaks.iter().fold(0.0f32, |peak, value| peak.max(*value));
|
||||
if maximum > 1e-6 {
|
||||
for peak in &mut peaks {
|
||||
*peak = (*peak / maximum).min(1.0);
|
||||
}
|
||||
}
|
||||
peaks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn click_track(seconds: f32, sample_rate: u32) -> Vec<u8> {
|
||||
let frames = (seconds * sample_rate as f32) as usize;
|
||||
let mut samples = vec![0.0f32; frames];
|
||||
let mut position = 0;
|
||||
while position < frames {
|
||||
for offset in 0..960 {
|
||||
if position + offset < frames {
|
||||
let envelope = 1.0 - offset as f32 / 960.0;
|
||||
samples[position + offset] = (offset as f32 * 0.12).sin() * envelope * 0.7;
|
||||
}
|
||||
}
|
||||
position += (sample_rate / 6) as usize;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
let spec = hound::WavSpec {
|
||||
channels: 1,
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
let mut writer = hound::WavWriter::new(Cursor::new(&mut bytes), spec).unwrap();
|
||||
for sample in samples {
|
||||
writer
|
||||
.write_sample((sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16)
|
||||
.unwrap();
|
||||
}
|
||||
writer.finalize().unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_a_click_track_into_sixteen_faces() {
|
||||
let bytes = click_track(2.0, 48_000);
|
||||
let core = import_bytes("clicks.wav", Seal::Found, bytes).unwrap();
|
||||
assert_eq!(core.summary.sample_rate, 48_000);
|
||||
assert_eq!(core.faces.len(), FACE_COUNT);
|
||||
assert_eq!(core.summary.faces.len(), FACE_COUNT);
|
||||
assert_eq!(core.summary.peaks.len(), PEAK_BINS);
|
||||
assert!(core.summary.duration_sec > 1.9);
|
||||
assert_eq!(core.summary.name, "clicks");
|
||||
assert_eq!(core.summary.seal, Seal::Found);
|
||||
assert!(core.faces.iter().all(|face| face.start < face.end));
|
||||
assert!(core.summary.faces.iter().all(|face| face.rank < 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_audio_is_rejected() {
|
||||
let bytes = click_track(0.01, 48_000);
|
||||
assert!(matches!(
|
||||
import_bytes("short.wav", Seal::Found, bytes),
|
||||
Err(MaterialError::TooShort)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,46 @@ pub enum Actor {
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Seal {
|
||||
Found,
|
||||
Taken,
|
||||
Gift,
|
||||
Loan,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// A face of a core, as the wire sees it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FaceSummary {
|
||||
pub index: u8,
|
||||
/// 0 is the loudest face.
|
||||
pub rank: u8,
|
||||
pub start_sec: f32,
|
||||
pub end_sec: f32,
|
||||
pub rms: f32,
|
||||
}
|
||||
|
||||
/// A core in the archive, without the samples themselves.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CoreSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub seal: Seal,
|
||||
pub sample_rate: u32,
|
||||
pub duration_sec: f32,
|
||||
pub faces: Vec<FaceSummary>,
|
||||
/// Normalised waveform envelope for the quarry, roughly 1400 bins.
|
||||
pub peaks: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FaceRef {
|
||||
pub core: String,
|
||||
pub face: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
@@ -91,6 +131,12 @@ pub enum ClientMessage {
|
||||
id: u64,
|
||||
command: Command,
|
||||
},
|
||||
/// The next binary frame is the encoded audio for this import.
|
||||
ImportCore {
|
||||
id: u64,
|
||||
name: String,
|
||||
seal: Seal,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -113,6 +159,10 @@ pub enum ServerMessage {
|
||||
id: u64,
|
||||
result: CommandResult,
|
||||
},
|
||||
Imported {
|
||||
id: u64,
|
||||
core: CoreSummary,
|
||||
},
|
||||
Error {
|
||||
id: Option<u64>,
|
||||
code: ErrorCode,
|
||||
@@ -126,9 +176,37 @@ pub enum Command {
|
||||
GetSnapshot,
|
||||
Play,
|
||||
Stop,
|
||||
Strike { vein: VeinId, velocity: f32 },
|
||||
SetTempo { bpm: f32 },
|
||||
SetVeinGain { vein: VeinId, gain: f32 },
|
||||
Strike {
|
||||
vein: VeinId,
|
||||
velocity: f32,
|
||||
},
|
||||
StrikeFace {
|
||||
core: String,
|
||||
face: u8,
|
||||
velocity: f32,
|
||||
},
|
||||
SetTempo {
|
||||
bpm: f32,
|
||||
},
|
||||
SetVeinGain {
|
||||
vein: VeinId,
|
||||
gain: f32,
|
||||
},
|
||||
SetPatternStep {
|
||||
row: u8,
|
||||
step: u8,
|
||||
velocity: u8,
|
||||
},
|
||||
SetPatternFace {
|
||||
row: u8,
|
||||
core: String,
|
||||
face: u8,
|
||||
},
|
||||
/// Journal-only: a core was imported through the binary path.
|
||||
ImportCore {
|
||||
name: String,
|
||||
seal: Seal,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -141,10 +219,35 @@ pub enum CommandResult {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
Transport { playing: bool },
|
||||
Tempo { bpm: f32 },
|
||||
VeinGain { vein: VeinId, gain: f32 },
|
||||
Struck { vein: VeinId, velocity: f32 },
|
||||
Transport {
|
||||
playing: bool,
|
||||
},
|
||||
Tempo {
|
||||
bpm: f32,
|
||||
},
|
||||
VeinGain {
|
||||
vein: VeinId,
|
||||
gain: f32,
|
||||
},
|
||||
Struck {
|
||||
vein: VeinId,
|
||||
velocity: f32,
|
||||
},
|
||||
PatternStep {
|
||||
row: u8,
|
||||
step: u8,
|
||||
velocity: u8,
|
||||
},
|
||||
PatternFace {
|
||||
row: u8,
|
||||
core: String,
|
||||
face: u8,
|
||||
},
|
||||
FaceStruck {
|
||||
core: String,
|
||||
face: u8,
|
||||
velocity: f32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -184,6 +287,7 @@ pub struct State {
|
||||
pub transport: TransportState,
|
||||
pub veins: Vec<VeinState>,
|
||||
pub pattern: Pattern,
|
||||
pub cores: Vec<CoreSummary>,
|
||||
pub audio: AudioInfo,
|
||||
pub journal_len: usize,
|
||||
}
|
||||
@@ -219,6 +323,8 @@ impl Pattern {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PatternRow {
|
||||
pub vein: VeinId,
|
||||
/// None keeps the procedural voice for this vein; Some plays a face.
|
||||
pub face: Option<FaceRef>,
|
||||
/// One velocity per step, 0..=127. Zero means the step is empty.
|
||||
pub velocities: Vec<u8>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user