Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b22474c0a5
|
||
|
|
0898d13718
|
||
|
|
9740812517
|
||
|
|
1065ffa12e
|
||
|
|
f9613c0a11
|
||
|
|
fe5a1a826f
|
||
|
|
de2378b8b3
|
||
|
|
a4fbe929ea
|
||
|
|
1bd8991053
|
||
|
|
3b73bd2728
|
||
|
|
c545e50d41
|
||
|
|
d88e8fbaa0
|
||
|
|
095f2f3a30
|
||
|
|
e568cf9d37
|
||
|
|
a25c20e8c8
|
||
|
|
8bd371824d
|
||
|
|
f40406e49b
|
||
|
|
6535efc51f
|
||
|
|
231c4b3991
|
||
|
|
03b3e3e701
|
||
|
|
1f96bf1d16
|
||
|
|
4431b19461
|
||
|
|
01a3ddaefa
|
||
|
|
1a1129f3d1
|
||
|
|
4904160431
|
||
|
|
c7fc3308b0
|
||
|
|
a1592cd38e
|
||
|
|
6fd81539e5
|
||
|
|
6c45663f3c
|
||
|
|
6498c27a4b
|
@@ -52,6 +52,11 @@ concurrency:
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_TERM_COLOR: "always"
|
||||
# Every RPM here is built for Fedora 43, but the runners build on their
|
||||
# host, which may run a newer release. Each Stage step fails a binary that
|
||||
# needs a newer glibc than Fedora 43 ships, rather than letting dnf skip
|
||||
# the upgrade at deploy.
|
||||
GLIBC_FLOOR: "2.42"
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
@@ -315,6 +320,7 @@ jobs:
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/cortex artifacts/cortex
|
||||
script/check-glibc-floor.sh "$GLIBC_FLOOR" artifacts/cortex
|
||||
./artifacts/cortex --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
@@ -354,6 +360,7 @@ jobs:
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/helexa-bench artifacts/helexa-bench
|
||||
script/check-glibc-floor.sh "$GLIBC_FLOOR" artifacts/helexa-bench
|
||||
./artifacts/helexa-bench --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
@@ -393,6 +400,7 @@ jobs:
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/helexa-upstream artifacts/helexa-upstream
|
||||
script/check-glibc-floor.sh "$GLIBC_FLOOR" artifacts/helexa-upstream
|
||||
./artifacts/helexa-upstream --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
@@ -432,6 +440,7 @@ jobs:
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/helexa-angels artifacts/helexa-angels
|
||||
script/check-glibc-floor.sh "$GLIBC_FLOOR" artifacts/helexa-angels
|
||||
./artifacts/helexa-angels --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
@@ -467,6 +476,7 @@ jobs:
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/helexa-router artifacts/helexa-router
|
||||
script/check-glibc-floor.sh "$GLIBC_FLOOR" artifacts/helexa-router
|
||||
./artifacts/helexa-router --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
@@ -502,6 +512,7 @@ jobs:
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/helexa-tools artifacts/helexa-tools
|
||||
script/check-glibc-floor.sh "$GLIBC_FLOOR" artifacts/helexa-tools
|
||||
./artifacts/helexa-tools --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
@@ -619,6 +630,7 @@ jobs:
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/neuron artifacts/neuron-${{ matrix.flavour }}
|
||||
script/check-glibc-floor.sh "$GLIBC_FLOOR" "artifacts/neuron-${{ matrix.flavour }}"
|
||||
file "artifacts/neuron-${{ matrix.flavour }}"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -183,6 +183,13 @@ jobs:
|
||||
# enable --now: start the service AND enable it for boot so the
|
||||
# fleet self-heals after a host reboot.
|
||||
sudo /usr/bin/systemctl enable --now cortex.service
|
||||
# dnf can exit 0 without installing anything (e.g. an unmet glibc
|
||||
# requirement), so compare what is now installed with what was published.
|
||||
now=$(rpm -q --qf '%{VERSION}-%{RELEASE}' "${pkg}" 2>/dev/null || true)
|
||||
if [ -n "${latest}" ] && [ "${now}" != "${latest}" ]; then
|
||||
echo "::error::${pkg}: ${now:-nothing} is installed, not the published ${latest}"
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY
|
||||
|
||||
# Wait for the service to either come up or wedge, then capture
|
||||
@@ -462,6 +469,13 @@ jobs:
|
||||
# enable --now: start the service AND enable it for boot so the
|
||||
# fleet self-heals after a host reboot.
|
||||
sudo /usr/bin/systemctl enable --now neuron.service
|
||||
# dnf can exit 0 without installing anything (e.g. an unmet glibc
|
||||
# requirement), so compare what is now installed with what was published.
|
||||
now=$(rpm -q --qf '%{VERSION}-%{RELEASE}' "${pkg}" 2>/dev/null || true)
|
||||
if [ -n "${latest}" ] && [ "${now}" != "${latest}" ]; then
|
||||
echo "::error::${pkg}: ${now:-nothing} is installed, not the published ${latest}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Post-deploy validation ────────────────────────────────
|
||||
# A deploy only goes green if the neuron (a) finishes loading
|
||||
@@ -858,7 +872,15 @@ jobs:
|
||||
fi
|
||||
sudo /usr/bin/systemctl daemon-reload
|
||||
sudo /usr/bin/systemctl enable --now "${pkg}.service"
|
||||
# dnf can exit 0 without installing anything (e.g. an unmet glibc
|
||||
# requirement), so compare what is now installed with what was published.
|
||||
now=$(rpm -q --qf '%{VERSION}-%{RELEASE}' "${pkg}" 2>/dev/null || true)
|
||||
if [ -n "${latest}" ] && [ "${now}" != "${latest}" ]; then
|
||||
echo "::error::${pkg}: ${now:-nothing} is installed, not the published ${latest}"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
[ "${failed:-0}" -eq 0 ]
|
||||
DEPLOY
|
||||
|
||||
# Router config (#287). Tracked because it holds no secrets, and
|
||||
@@ -1087,6 +1109,13 @@ jobs:
|
||||
# enable --now: start the service AND enable it for boot so the
|
||||
# bench resumes collecting after a host reboot.
|
||||
sudo /usr/bin/systemctl enable --now helexa-bench.service
|
||||
# dnf can exit 0 without installing anything (e.g. an unmet glibc
|
||||
# requirement), so compare what is now installed with what was published.
|
||||
now=$(rpm -q --qf '%{VERSION}-%{RELEASE}' "${pkg}" 2>/dev/null || true)
|
||||
if [ -n "${latest}" ] && [ "${now}" != "${latest}" ]; then
|
||||
echo "::error::${pkg}: ${now:-nothing} is installed, not the published ${latest}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Post-deploy validation ────────────────────────────────
|
||||
# The bench serves a read-only API on :13132 alongside the
|
||||
|
||||
@@ -1274,7 +1274,9 @@ stored as that lossless master. MP3 (320 kbps CBR, LAME) is a download
|
||||
format only: `/content?format=mp3` transcodes the FLAC once and caches
|
||||
`{id}.mp3` beside it until the job expires; `response_format` sets a
|
||||
job's default download. Results stored as WAV by older neurons are
|
||||
served as WAV.
|
||||
served as WAV. cortex refuses new jobs (503 `audio_storage_full`) while the job
|
||||
directory's filesystem has under `[audio_jobs] min_free_mb` free (10 GiB
|
||||
default); shared object storage for the Pascal-scale fleet is #411.
|
||||
|
||||
**Metering: per audio-second**, at a per-model rate stored in
|
||||
helexa-upstream's `model_rates` table (`GET /authz/v1/rates`, cached by
|
||||
@@ -1299,14 +1301,37 @@ render.
|
||||
upstream CPU f32 reference is ~1e-5 with it.
|
||||
- **Seeding is host-side** (SplitMix64 + Box-Muller): candle's CPU
|
||||
`set_seed` is unsupported, and a host RNG makes CPU and GPU agree.
|
||||
- **The ACE planner checkpoint has no `model.` prefix**; it is renamed at
|
||||
load. A planner that fails to load is non-fatal. It stays off unless
|
||||
`planner: "on"` until #384 closes the parity gaps (code-phase CFG,
|
||||
constrained decoding).
|
||||
- **The 1.7B planner checkpoint has no `model.` prefix; the 0.6B one
|
||||
does.** The loader strips it only where it is absent. A planner that
|
||||
fails to load is non-fatal, so a wrong prefix shows up only as a
|
||||
warning and `plan_ms: 0`.
|
||||
- **The planner matches upstream exactly (#384)** — in f32 with greedy
|
||||
decoding, the CoT byte for byte and every audio code of the trace — and
|
||||
`auto` (the default) now runs it. That parity rests on three things a
|
||||
port gets wrong easily: the CoT grammar (`fsm.rs`, a port of
|
||||
`MetadataConstrainedLogitsProcessor`); the codes prompt, which carries
|
||||
the plan *re-dumped by PyYAML* (`cot.rs`: folded at column 80, quoting
|
||||
values that would read back as bools or numbers); and CFG 2.0 in the
|
||||
codes phase only, against `NO USER INPUT` with empty reasoning.
|
||||
Upstream's own bf16 already picks a different first code, so judge
|
||||
parity in f32.
|
||||
- **ACE-Step's FSQ is the symmetric variant.** `vector_quantize_pytorch`'s
|
||||
`ResidualFSQ` builds its layers with `preserve_symmetry=True` (level k
|
||||
of L → `2k/(L-1) − 1`). The vendored port had the asymmetric formula
|
||||
and tests re-derived from that same formula, so they passed while every
|
||||
planner hint was 18% off. Pin vendored maths to values recorded from
|
||||
the reference library, never to the port's own arithmetic. With codes,
|
||||
upstream also switches the DiT instruction to the cover one.
|
||||
- **Levels follow each upstream.** ACE-Step peak-normalises to −1 dBFS;
|
||||
HeartMuLa does not.
|
||||
- **ACE timing is one number.** `text2music` is a single call, so
|
||||
`generate_ms` covers encode + denoise + VAE.
|
||||
- **ACE timing is split** into `encode_ms`, `generate_ms` (denoise) and
|
||||
`decode_ms` (VAE). Each phase ends with a device synchronize, so queued
|
||||
GPU work is charged to the phase that queued it.
|
||||
- **candle only uses its fast transposed-conv path when unpadded.** The
|
||||
Oobleck decoder runs its upsampling unpadded and trims the padding
|
||||
from the output (identical result). Before that, the VAE decode was
|
||||
~95% of a render: 22.8 s for a 30 s song on a 3060, 27 s for 480 s on
|
||||
benjy. Watch for the same trap in any padded `ConvTranspose1d`.
|
||||
- **HeartMuLa's first decode after load is slow** (~33 s, one-time
|
||||
warm-up); every decode after that is ~2 s per 10 s of audio.
|
||||
- **benjy's root drive is slow under pressure** (DRAM-less, 90% full;
|
||||
@@ -1320,6 +1345,27 @@ render.
|
||||
**Retired:** the PyTorch evaluation beta (`beta.music.helexa.ai`, #388).
|
||||
Its ratings data is archived privately.
|
||||
|
||||
**Next:** #384 (planner parity), #385 (q8 + an 8 GB profile), and the
|
||||
Pascal epic #380 (rig work waits until rigs are named; the fallback if
|
||||
**Layouts (#385).** `[harness.candle.audio]` in neuron.toml sets, per
|
||||
host: `quant = "q8_0"` (DiT, text encoder and planner linears, f32
|
||||
compute), `compute = "f32"`, `placement.<component> = "gpu" | "cpu"`,
|
||||
`planner = "<subfolder>" | "none"`, `max_duration_secs`, and
|
||||
`vram_budget_mb` (smaller VAE chunks). `enforce_vram_budget` holds the
|
||||
rest of the card's memory so a 24 GB card rehearses an 8 GB one.
|
||||
Measured peaks (process, CUDA context included):
|
||||
|
||||
| Layout | Resident | 180 s song | 240 s | 480 s |
|
||||
|---|---|---|---|---|
|
||||
| bf16, no planner | 6.4 GB | 7.8 GB | 7.8 GB | 9.1 GB |
|
||||
| q8 + 8 GiB budget, no planner | 4.6 GB | 6.0 GB | 6.2 GB | 10.0 GB |
|
||||
| q8 + 8 GiB budget + 0.6B planner | 5.8 GB | 7.6 GB | 7.9 GB | 13.1 GB |
|
||||
| dense f32 | 13.2 GB | — | — | — |
|
||||
|
||||
So an 8 GB card serves q8 with the 0.6B planner up to 240 s; beyond
|
||||
that a term grows with the square of song length. q8's mean latent drift
|
||||
from upstream's f32 reference (0.26 rel L2 over four cases) is smaller
|
||||
than upstream's own bf16 (0.32). A 180 s vocal song with the 0.6B
|
||||
planner takes 44 s on a 3060 (q8, 7.1 GB peak). The 0.6B planner is a
|
||||
separate repo, so name it by id: `planner = "ACE-Step/acestep-5Hz-lm-0.6B"`.
|
||||
|
||||
**Next:** the Pascal epic #380 (rig work waits until rigs are named; the fallback if
|
||||
ACE-Step cannot run on Pascal is Stable Audio and/or EzAudio).
|
||||
|
||||
Generated
+1
@@ -875,6 +875,7 @@ dependencies = [
|
||||
"metrics-exporter-prometheus",
|
||||
"mp3lame-encoder",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -129,3 +129,6 @@ enabled = false
|
||||
# max_attempts = 2
|
||||
# How long a job may wait on a busy model before failing, in seconds.
|
||||
# queue_timeout_secs = 3600
|
||||
# New jobs are refused (503) while the job directory's filesystem has less
|
||||
# than this much free space, in MiB. 0 disables the check.
|
||||
# min_free_mb = 10240
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Keep the cortex binary installable on the oldest glibc we package for.
|
||||
//!
|
||||
//! glibc 2.43 gave `log10f` a new symbol version, so a binary linked on a
|
||||
//! newer host references `log10f@GLIBC_2.43` and will not install on a
|
||||
//! system with an older glibc. The only caller is LAME (the MP3 encoder
|
||||
//! behind `/content?format=mp3`). Routing its calls through
|
||||
//! `__wrap_log10f` (see `glibc_compat.rs`) removes the reference.
|
||||
fn main() {
|
||||
println!("cargo:rustc-link-arg-bins=-Wl,--wrap=log10f");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Replacements for libm symbols whose current version is newer than the
|
||||
//! glibc cortex is packaged for. `build.rs` points the linker at these.
|
||||
|
||||
/// `log10f` computed through `log10`, which keeps its original symbol
|
||||
/// version. Rounding the f64 result to f32 is at least as accurate as
|
||||
/// libm's single-precision version.
|
||||
#[unsafe(no_mangle)]
|
||||
extern "C" fn __wrap_log10f(x: f32) -> f32 {
|
||||
(x as f64).log10() as f32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::__wrap_log10f;
|
||||
|
||||
#[test]
|
||||
fn matches_log10_including_edge_cases() {
|
||||
for x in [1e-30_f32, 0.5, 1.0, 2.0, 10.0, 1234.5, f32::MAX] {
|
||||
assert!(
|
||||
(__wrap_log10f(x) - x.log10()).abs() <= 1e-6 * x.log10().abs().max(1.0),
|
||||
"{x}"
|
||||
);
|
||||
}
|
||||
assert_eq!(__wrap_log10f(0.0), f32::NEG_INFINITY);
|
||||
assert!(__wrap_log10f(-1.0).is_nan());
|
||||
assert!(__wrap_log10f(f32::NAN).is_nan());
|
||||
assert_eq!(__wrap_log10f(f32::INFINITY), f32::INFINITY);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod glibc_compat;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use cortex_core::config::GatewayConfig;
|
||||
|
||||
@@ -57,6 +57,12 @@ pub struct AudioJobsConfig {
|
||||
/// How long a job may wait for a busy model before it fails, in seconds.
|
||||
#[serde(default = "default_audio_queue_timeout")]
|
||||
pub queue_timeout_secs: u64,
|
||||
/// New jobs are refused (503 + `Retry-After`) while the filesystem
|
||||
/// holding `dir` has less than this much free space, in MiB. Rendered
|
||||
/// audio is written there, so this keeps a busy store from filling the
|
||||
/// disk it shares. 0 disables the check.
|
||||
#[serde(default = "default_audio_min_free_mb")]
|
||||
pub min_free_mb: u64,
|
||||
}
|
||||
|
||||
impl Default for AudioJobsConfig {
|
||||
@@ -67,6 +73,7 @@ impl Default for AudioJobsConfig {
|
||||
max_concurrent: default_audio_max_concurrent(),
|
||||
max_attempts: default_audio_max_attempts(),
|
||||
queue_timeout_secs: default_audio_queue_timeout(),
|
||||
min_free_mb: default_audio_min_free_mb(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +93,11 @@ fn default_audio_max_attempts() -> u32 {
|
||||
fn default_audio_queue_timeout() -> u64 {
|
||||
3600
|
||||
}
|
||||
/// 10 GiB: room for well over a hundred of the longest songs while jobs
|
||||
/// already accepted finish.
|
||||
fn default_audio_min_free_mb() -> u64 {
|
||||
10 * 1024
|
||||
}
|
||||
|
||||
/// `[upstream]` — the helexa-upstream authority client (#57). Locally
|
||||
/// unrecognised bearer keys are resolved against `url`'s `/authz/v1` surface
|
||||
|
||||
@@ -139,6 +139,11 @@ pub enum ModelStatus {
|
||||
/// retry error instead of 404, and must not race a second
|
||||
/// placement elsewhere.
|
||||
Recovering,
|
||||
/// Gateway-side synthetic status for a model cached on a neuron that
|
||||
/// is currently unhealthy (#376). The location stays visible for
|
||||
/// diagnostics, but nothing will be routed there, so it never makes
|
||||
/// the model count as `loaded`.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// Unified model entry as exposed by the gateway's `/v1/models` endpoint.
|
||||
|
||||
@@ -28,6 +28,7 @@ bytes = "1"
|
||||
urlencoding = "2"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rustix = { version = "1", default-features = false, features = ["std", "fs"] }
|
||||
claxon = "0.4.3"
|
||||
mp3lame-encoder = { version = "0.2.5", features = ["std"] }
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ const DEFAULT_BUSY_WAIT: Duration = Duration::from_secs(5);
|
||||
/// How often expired jobs are swept.
|
||||
const SWEEP_INTERVAL: Duration = Duration::from_secs(600);
|
||||
|
||||
/// `Retry-After` on a submission refused for lack of disk space: about as
|
||||
/// long as it takes the sweep to come round and expire old jobs.
|
||||
const STORAGE_RETRY_AFTER_SECS: u64 = 600;
|
||||
|
||||
/// A job as cortex stores it: the public object plus what is needed to run
|
||||
/// (or re-run) it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -126,6 +130,30 @@ impl AudioJobs {
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Free space on the filesystem holding the job directory, in MiB.
|
||||
/// `None` when there is no directory (memory only) or it cannot be
|
||||
/// read, in which case submissions are not refused on its account.
|
||||
fn free_mb(&self) -> Option<u64> {
|
||||
let dir = self.dir()?;
|
||||
match rustix::fs::statvfs(dir) {
|
||||
Ok(st) => Some(st.f_bavail.saturating_mul(st.f_frsize) / (1024 * 1024)),
|
||||
Err(e) => {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "cannot read free space for audio jobs");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a new job may be accepted: the store's filesystem must keep
|
||||
/// at least `min_free_mb` free. Returns the free space when it may not.
|
||||
fn storage_full(&self) -> Option<u64> {
|
||||
let floor = self.config.min_free_mb;
|
||||
if floor == 0 {
|
||||
return None;
|
||||
}
|
||||
self.free_mb().filter(|free| *free < floor)
|
||||
}
|
||||
|
||||
fn record_path(&self, id: &str) -> Option<PathBuf> {
|
||||
self.dir().map(|d| d.join(format!("{id}.json")))
|
||||
}
|
||||
@@ -342,6 +370,25 @@ async fn submit(
|
||||
return invalid_params(msg);
|
||||
}
|
||||
|
||||
// Refused before any budget is reserved: nothing to release.
|
||||
if let Some(free) = fleet.audio_jobs.storage_full() {
|
||||
metrics::counter!("cortex_audio_jobs_rejected_total", "reason" => "storage").increment(1);
|
||||
tracing::warn!(
|
||||
free_mb = free,
|
||||
min_free_mb = fleet.audio_jobs.config.min_free_mb,
|
||||
"audio job refused: job store is low on disk space"
|
||||
);
|
||||
return crate::error::envelope_response(
|
||||
OpenAiError::new(
|
||||
503,
|
||||
"server_error",
|
||||
"audio_storage_full",
|
||||
"audio generation is temporarily unavailable: the job store is full",
|
||||
)
|
||||
.with_retry_after(STORAGE_RETRY_AFTER_SECS),
|
||||
);
|
||||
}
|
||||
|
||||
let owner = crate::metering::principal_from_headers(&headers);
|
||||
let mut request = request;
|
||||
request.model = model.clone();
|
||||
|
||||
@@ -46,6 +46,7 @@ pub async fn evict_lru_on_node(
|
||||
.models
|
||||
.values()
|
||||
.filter(|m| m.status == ModelStatus::Loaded)
|
||||
.filter(|m| !fleet.eviction_blocked(node, &m.id))
|
||||
.filter(|m| match incoming {
|
||||
Some(inc) => fleet.catalogue.may_displace(inc, &m.id),
|
||||
None => {
|
||||
|
||||
@@ -1002,14 +1002,24 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
|
||||
// in the catalogue, create a new CortexModelEntry from scratch —
|
||||
// cortex doesn't refuse to surface a manually-loaded model just
|
||||
// because the operator didn't enumerate it in models.toml.
|
||||
//
|
||||
// An unhealthy neuron's models are its last snapshot, not live
|
||||
// state, and the router will not send it anything (#376). They are
|
||||
// listed as `unreachable` and never make a model `loaded`, so
|
||||
// `loaded` means the same thing to a client as it does to routing.
|
||||
for node in nodes.values() {
|
||||
for (model_id, entry) in &node.models {
|
||||
let status = if node.healthy {
|
||||
entry.status
|
||||
} else {
|
||||
cortex_core::node::ModelStatus::Unreachable
|
||||
};
|
||||
let location = ModelLocation {
|
||||
node: node.name.clone(),
|
||||
status: entry.status,
|
||||
status,
|
||||
vram_estimate_mb: entry.vram_estimate_mb,
|
||||
};
|
||||
let was_loaded = matches!(entry.status, cortex_core::node::ModelStatus::Loaded);
|
||||
let was_loaded = matches!(status, cortex_core::node::ModelStatus::Loaded);
|
||||
entries
|
||||
.entry(model_id.clone())
|
||||
.and_modify(|e| {
|
||||
@@ -1093,7 +1103,9 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
|
||||
// Synthesising a Loading location instead tells clients the model
|
||||
// is on its way. Idempotent against Pass 2: if a Loading location
|
||||
// for this node already exists (shouldn't, but be safe) we skip.
|
||||
for node in nodes.values() {
|
||||
// An unhealthy neuron's activation snapshot is as stale as its
|
||||
// models, so it reports nothing as on its way.
|
||||
for node in nodes.values().filter(|n| n.healthy) {
|
||||
let Some(activation) = node.activation.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -310,7 +310,7 @@ async fn discover_capabilities(fleet: &CortexState, name: &str, endpoint: &str)
|
||||
/// Fetch `/health` and stash the activation snapshot on NodeState.
|
||||
/// Decoupled from the /models poll so a /health glitch doesn't mark
|
||||
/// the neuron unhealthy or evict the model list.
|
||||
async fn poll_health(fleet: &CortexState, name: &str, endpoint: &str) {
|
||||
pub async fn poll_health(fleet: &CortexState, name: &str, endpoint: &str) {
|
||||
let url = format!("{endpoint}/health");
|
||||
let resp = match fleet
|
||||
.http_client
|
||||
|
||||
@@ -64,6 +64,10 @@ pub enum RouteError {
|
||||
"model '{model_id}' is recovering on node '{node}' (device context rebuild in progress) — retry shortly"
|
||||
)]
|
||||
ModelRecovering { model_id: String, node: String },
|
||||
#[error(
|
||||
"model '{model_id}' does not fit on '{node}' until a model there finishes its work — retry shortly"
|
||||
)]
|
||||
NodeBusy { model_id: String, node: String },
|
||||
}
|
||||
|
||||
impl RouteError {
|
||||
@@ -74,7 +78,8 @@ impl RouteError {
|
||||
match self {
|
||||
RouteError::NoHealthyNodes
|
||||
| RouteError::ModelRecovering { .. }
|
||||
| RouteError::FeasibleNodeUnhealthy { .. } => 503,
|
||||
| RouteError::FeasibleNodeUnhealthy { .. }
|
||||
| RouteError::NodeBusy { .. } => 503,
|
||||
_ => 404,
|
||||
}
|
||||
}
|
||||
@@ -88,7 +93,8 @@ impl RouteError {
|
||||
| RouteError::NoFeasibleNeuron { .. }
|
||||
| RouteError::ColdLoadFailed { .. }
|
||||
| RouteError::ModelRecovering { .. }
|
||||
| RouteError::FeasibleNodeUnhealthy { .. } => "api_error",
|
||||
| RouteError::FeasibleNodeUnhealthy { .. }
|
||||
| RouteError::NodeBusy { .. } => "api_error",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +108,7 @@ impl RouteError {
|
||||
RouteError::ColdLoadFailed { .. } => "service_unavailable",
|
||||
RouteError::ModelRecovering { .. } => "service_unavailable",
|
||||
RouteError::FeasibleNodeUnhealthy { .. } => "service_unavailable",
|
||||
RouteError::NodeBusy { .. } => "service_unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +122,8 @@ impl RouteError {
|
||||
RouteError::ModelRecovering { .. } => Some(2),
|
||||
RouteError::FeasibleNodeUnhealthy { .. } => Some(3),
|
||||
RouteError::NoHealthyNodes => Some(5),
|
||||
// Clears when the resident model's requests finish.
|
||||
RouteError::NodeBusy { .. } => Some(10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -205,12 +214,14 @@ pub async fn resolve(
|
||||
// Loading is gateway-synthesised from neuron's
|
||||
// activation snapshot; it never appears on the
|
||||
// wire from neuron's `/models`. Skip — the model
|
||||
// isn't actually servable yet. The pre-existing
|
||||
// race (catalogue cold_load fires a parallel
|
||||
// /models/load against the in-flight load) is no
|
||||
// worse than before; fixing it needs neuron-side
|
||||
// in-flight tracking on /models/load itself.
|
||||
// isn't actually servable yet. Cortex's own
|
||||
// cold-loads are serialised per node (priority 4);
|
||||
// a load neuron started by itself at activation is
|
||||
// still invisible to that lock.
|
||||
ModelStatus::Loading => {}
|
||||
// Synthesised only by `/v1/models` for unhealthy
|
||||
// neurons, which this loop has already skipped.
|
||||
ModelStatus::Unreachable => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,41 +258,109 @@ pub async fn resolve(
|
||||
|
||||
// Priority 4: catalogue × topology cold-load.
|
||||
if let Some(profile) = fleet.catalogue.get(model_id) {
|
||||
let (node_name, neuron_endpoint, fits_free) = pick_feasible_neuron(fleet, profile).await?;
|
||||
// Cold-swap (#203): when the chosen node's devices don't have the
|
||||
// free VRAM *right now*, evict unpinned LRU models there until
|
||||
// the profile fits (bounded — a node whose evictable set can't
|
||||
// free enough was ranked below one that can).
|
||||
if !fits_free {
|
||||
for _ in 0..3 {
|
||||
match crate::evictor::evict_lru_on_node(fleet, &node_name, Some(&profile.id)).await
|
||||
{
|
||||
Ok(Some(evicted)) => {
|
||||
tracing::info!(
|
||||
model = %profile.id,
|
||||
node = %node_name,
|
||||
evicted = %evicted,
|
||||
"cold-swap: evicted LRU model to make room"
|
||||
);
|
||||
if node_fits_free(fleet, &node_name, profile).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(node = %node_name, error = %e, "cold-swap eviction failed");
|
||||
break;
|
||||
}
|
||||
let (node_name, neuron_endpoint, _) = pick_feasible_neuron(fleet, profile).await?;
|
||||
// One cold-load per node at a time. The choice above rests on the
|
||||
// last poll's VRAM, which cannot see a load in progress, so every
|
||||
// decision that follows is re-made inside the lock from a fresh
|
||||
// `/health`.
|
||||
let lock = fleet.cold_load_lock(&node_name);
|
||||
let _turn = lock.lock().await;
|
||||
|
||||
// Whoever held the lock may have been loading this very model.
|
||||
if let Some((node, endpoint)) = loaded_location(fleet, model_id).await {
|
||||
return finish(fleet, &node, &endpoint, model_id, false).await;
|
||||
}
|
||||
|
||||
crate::poller::poll_health(fleet, &node_name, &neuron_endpoint).await;
|
||||
// Cold-swap (#203): when the node's devices don't have the free
|
||||
// VRAM right now, evict unpinned LRU models there until the
|
||||
// profile fits (bounded — a node whose evictable set can't free
|
||||
// enough was ranked below one that can).
|
||||
for _ in 0..3 {
|
||||
if node_fits_free(fleet, &node_name, profile).await {
|
||||
break;
|
||||
}
|
||||
match crate::evictor::evict_lru_on_node(fleet, &node_name, Some(&profile.id)).await {
|
||||
Ok(Some(evicted)) => {
|
||||
tracing::info!(
|
||||
model = %profile.id,
|
||||
node = %node_name,
|
||||
evicted = %evicted,
|
||||
"cold-swap: evicted LRU model to make room"
|
||||
);
|
||||
crate::poller::poll_health(fleet, &node_name, &neuron_endpoint).await;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(node = %node_name, error = %e, "cold-swap eviction failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Still short, and a model that would otherwise be displaced is
|
||||
// busy: loading now would land beside it and fail out of memory.
|
||||
// Answer "retry shortly" instead; the room appears when its work
|
||||
// ends.
|
||||
if !node_fits_free(fleet, &node_name, profile).await
|
||||
&& blocked_displaceable(fleet, &node_name, profile).await
|
||||
{
|
||||
tracing::info!(
|
||||
model = %profile.id,
|
||||
node = %node_name,
|
||||
"cold-load deferred: the model it would displace is busy"
|
||||
);
|
||||
return Err(RouteError::NodeBusy {
|
||||
model_id: profile.id.clone(),
|
||||
node: node_name,
|
||||
});
|
||||
}
|
||||
cold_load(fleet, &node_name, &neuron_endpoint, profile).await?;
|
||||
fleet.note_cold_load(&node_name, &profile.id);
|
||||
// The next cold-load in line decides from these readings.
|
||||
crate::poller::poll_health(fleet, &node_name, &neuron_endpoint).await;
|
||||
drop(_turn);
|
||||
return finish(fleet, &node_name, &neuron_endpoint, model_id, true).await;
|
||||
}
|
||||
|
||||
Err(RouteError::ModelNotFound(model_id.to_string()))
|
||||
}
|
||||
|
||||
/// A healthy node where `model_id` is loaded and servable, if any.
|
||||
async fn loaded_location(fleet: &Arc<CortexState>, model_id: &str) -> Option<(String, String)> {
|
||||
let nodes = fleet.nodes.read().await;
|
||||
nodes
|
||||
.values()
|
||||
.filter(|n| n.healthy)
|
||||
.find(|n| {
|
||||
n.models.get(model_id).is_some_and(|m| {
|
||||
matches!(m.status, ModelStatus::Loaded | ModelStatus::Reloading) && m.is_servable()
|
||||
})
|
||||
})
|
||||
.map(|n| (n.name.clone(), n.endpoint.clone()))
|
||||
}
|
||||
|
||||
/// Whether `node_name` holds a model the profile may displace but that
|
||||
/// cannot be evicted right now because it is busy (see
|
||||
/// [`CortexState::eviction_blocked`]), or is still loading its startup
|
||||
/// models.
|
||||
async fn blocked_displaceable(
|
||||
fleet: &Arc<CortexState>,
|
||||
node_name: &str,
|
||||
profile: &ModelProfile,
|
||||
) -> bool {
|
||||
let nodes = fleet.nodes.read().await;
|
||||
nodes.get(node_name).is_some_and(|node| {
|
||||
// A startup load cannot be evicted until it finishes, and its
|
||||
// memory is still arriving: wait for it.
|
||||
activating(node)
|
||||
|| node.models.values().any(|m| {
|
||||
m.status == ModelStatus::Loaded
|
||||
&& fleet.catalogue.may_displace(&profile.id, &m.id)
|
||||
&& fleet.eviction_blocked(node, &m.id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// True when `node_name`'s live device readings show enough free VRAM
|
||||
/// on some device for the profile. Conservative: unknown health (no
|
||||
/// /health poll yet) counts as not-fitting so the ranking prefers
|
||||
@@ -289,12 +368,40 @@ pub async fn resolve(
|
||||
async fn node_fits_free(fleet: &Arc<CortexState>, node_name: &str, profile: &ModelProfile) -> bool {
|
||||
let nodes = fleet.nodes.read().await;
|
||||
nodes.get(node_name).is_some_and(|node| {
|
||||
let reserved = activation_reserved_mb(fleet, node);
|
||||
node.device_health
|
||||
.iter()
|
||||
.any(|d| d.vram_free_mb >= profile.vram_mb.unwrap_or(0))
|
||||
.any(|d| d.vram_free_mb.saturating_sub(reserved) >= profile.vram_mb.unwrap_or(0))
|
||||
})
|
||||
}
|
||||
|
||||
/// VRAM promised to models the neuron is loading on its own at startup
|
||||
/// (its `default_models`), by their catalogue footprint. Those loads are
|
||||
/// not in the neuron's model list until they finish and their memory
|
||||
/// fills in gradually, so free VRAM alone overstates what is available.
|
||||
fn activation_reserved_mb(fleet: &CortexState, node: &cortex_core::node::NodeState) -> u64 {
|
||||
node.activation.as_ref().map_or(0, |a| {
|
||||
a.in_progress
|
||||
.iter()
|
||||
.chain(a.pending.iter())
|
||||
.filter(|id| {
|
||||
!node
|
||||
.models
|
||||
.get(id.as_str())
|
||||
.is_some_and(|m| m.status == ModelStatus::Loaded)
|
||||
})
|
||||
.filter_map(|id| fleet.catalogue.get(id).and_then(|p| p.vram_mb))
|
||||
.sum()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the neuron is still loading its startup models.
|
||||
fn activating(node: &cortex_core::node::NodeState) -> bool {
|
||||
node.activation
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.in_progress.is_some() || !a.pending.is_empty())
|
||||
}
|
||||
|
||||
/// Pick a healthy neuron whose discovered topology satisfies the
|
||||
/// profile. Preference order (#203):
|
||||
/// 1. A neuron from `profile.pinned_on` that is healthy + feasible.
|
||||
@@ -332,7 +439,8 @@ async fn pick_feasible_neuron(
|
||||
.iter()
|
||||
.map(|d| d.vram_free_mb)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(0)
|
||||
.saturating_sub(activation_reserved_mb(fleet, node));
|
||||
let need = profile.vram_mb.unwrap_or(0);
|
||||
let fits_free = max_free >= need;
|
||||
// Evictable estimate: free + the VRAM of loaded models this
|
||||
@@ -344,6 +452,7 @@ async fn pick_feasible_neuron(
|
||||
.values()
|
||||
.filter(|m| {
|
||||
matches!(m.status, cortex_core::node::ModelStatus::Loaded)
|
||||
&& !fleet.eviction_blocked(node, &m.id)
|
||||
&& fleet.catalogue.may_displace(&profile.id, &m.id)
|
||||
})
|
||||
// neuron reports vram_used_mb: null today; fall back to the
|
||||
|
||||
@@ -57,8 +57,22 @@ pub struct CortexState {
|
||||
/// Per-model rates for non-token units (#386), from upstream when it
|
||||
/// is enabled, else the local default.
|
||||
pub rates: Arc<crate::rates::Rates>,
|
||||
/// One lock per node, held across a cold-load's evict → load → fresh
|
||||
/// `/health` sequence. Placement reads VRAM from the last poll, which
|
||||
/// cannot see a load still in progress; two cold-loads deciding at
|
||||
/// once both saw the same free card and loaded side by side into an
|
||||
/// out-of-memory.
|
||||
cold_load_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
/// When cortex last cold-loaded each `(node, model)`. A model loaded
|
||||
/// for a request is protected from eviction for [`COLD_LOAD_GRACE`],
|
||||
/// long enough for that request to reach it; otherwise the next
|
||||
/// cold-load in line evicts it before it has served anything.
|
||||
recent_cold_loads: std::sync::Mutex<HashMap<(String, String), std::time::Instant>>,
|
||||
}
|
||||
|
||||
/// How long a freshly cold-loaded model is protected from eviction.
|
||||
pub const COLD_LOAD_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
impl CortexState {
|
||||
pub fn from_config(config: &GatewayConfig) -> Self {
|
||||
let mut nodes = HashMap::new();
|
||||
@@ -142,6 +156,53 @@ impl CortexState {
|
||||
} else {
|
||||
crate::rates::Rates::local()
|
||||
}),
|
||||
cold_load_locks: std::sync::Mutex::new(HashMap::new()),
|
||||
recent_cold_loads: std::sync::Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock that serialises cold-loads on `node`.
|
||||
pub fn cold_load_lock(&self, node: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
let mut locks = self
|
||||
.cold_load_locks
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
Arc::clone(locks.entry(node.to_string()).or_default())
|
||||
}
|
||||
|
||||
/// Record that cortex has just cold-loaded `model` on `node`.
|
||||
pub fn note_cold_load(&self, node: &str, model: &str) {
|
||||
let mut recent = self
|
||||
.recent_cold_loads
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
recent.retain(|_, at| at.elapsed() < COLD_LOAD_GRACE);
|
||||
recent.insert(
|
||||
(node.to_string(), model.to_string()),
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether `model` on `node` is inside its post-cold-load grace.
|
||||
pub fn recently_cold_loaded(&self, node: &str, model: &str) -> bool {
|
||||
let recent = self
|
||||
.recent_cold_loads
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
recent
|
||||
.get(&(node.to_string(), model.to_string()))
|
||||
.is_some_and(|at| at.elapsed() < COLD_LOAD_GRACE)
|
||||
}
|
||||
|
||||
/// Whether a resident model must not be evicted right now: it has
|
||||
/// requests running or queued (as of the last `/health`), or cortex
|
||||
/// cold-loaded it moments ago for a request that has yet to arrive.
|
||||
/// Evicting either kills work in progress.
|
||||
pub fn eviction_blocked(&self, node: &cortex_core::node::NodeState, model: &str) -> bool {
|
||||
let busy = node
|
||||
.model_load
|
||||
.get(model)
|
||||
.is_some_and(|l| l.in_flight + l.queue_depth > 0);
|
||||
busy || self.recently_cold_loaded(&node.name, model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,9 @@ fn config(neuron: &str, dir: &std::path::Path) -> GatewayConfig {
|
||||
audio_jobs: AudioJobsConfig {
|
||||
dir: dir.to_string_lossy().into_owned(),
|
||||
queue_timeout_secs: 20,
|
||||
// The free-space floor is tested on its own below; elsewhere it
|
||||
// must not make results depend on the build host's disk.
|
||||
min_free_mb: 0,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
@@ -741,6 +744,46 @@ async fn expired_jobs_are_swept() {
|
||||
assert!(!dir.path().join(format!("{id}.mp3")).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submissions_are_refused_when_the_store_is_low_on_disk() {
|
||||
let mock = spawn_mock(Mode::Ok).await;
|
||||
let rates = [("*", 1000)];
|
||||
|
||||
// A floor no filesystem can meet: refused, retryable, nothing written,
|
||||
// nothing reserved.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cfg = config(&mock.url, dir.path());
|
||||
cfg.audio_jobs.min_free_mb = u64::MAX;
|
||||
for k in &mut cfg.entitlements.keys {
|
||||
k.hard_cap = Some(1_000_000);
|
||||
}
|
||||
let fleet = Arc::new(CortexState::from_config(&cfg));
|
||||
let mut table = cortex_gateway::rates::RateTable::default();
|
||||
for (model, rate) in rates {
|
||||
table.set(cortex_gateway::rates::AUDIO_SECOND, model, rate);
|
||||
}
|
||||
fleet.rates.replace(table).await;
|
||||
seed_model(&fleet).await;
|
||||
let gw = serve(&fleet).await;
|
||||
|
||||
let resp = submit(&gw, Some("key-alice"), song()).await;
|
||||
assert_eq!(resp.status(), 503);
|
||||
assert_eq!(resp.headers()["retry-after"], "600");
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["error"]["code"], "audio_storage_full");
|
||||
assert_eq!(spent(&fleet, "alice").await, 0);
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
|
||||
// A floor any filesystem meets: accepted.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cfg = config(&mock.url, dir.path());
|
||||
cfg.audio_jobs.min_free_mb = 1;
|
||||
let fleet = Arc::new(CortexState::from_config(&cfg));
|
||||
seed_model(&fleet).await;
|
||||
let gw = serve(&fleet).await;
|
||||
assert_eq!(submit(&gw, None, song()).await.status(), 202);
|
||||
}
|
||||
|
||||
// ── Per-model rates (#386) ───────────────────────────────────────────
|
||||
|
||||
/// A gateway whose keys are capped at `cap` tokens and whose audio rate
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Cold-load placement under concurrency.
|
||||
//!
|
||||
//! The mock neuron keeps real VRAM books for one 24 GB card: loads take
|
||||
//! time, `/health` reports what is actually resident, and the card notes
|
||||
//! if it is ever asked to hold more than it has. That last flag is the
|
||||
//! failure being guarded against — two cold-loads deciding from the same
|
||||
//! stale poll, both landing, and every request on the card then failing
|
||||
//! out of memory.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use cortex_core::config::{
|
||||
EvictionSettings, EvictionStrategy, GatewayConfig, GatewaySettings, NeuronEndpoint,
|
||||
};
|
||||
use cortex_core::discovery::{DeviceInfo, DiscoveryResponse};
|
||||
use cortex_core::node::{ModelEntry, ModelStatus};
|
||||
use cortex_gateway::router::RouteError;
|
||||
use cortex_gateway::state::CortexState;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
const CARD_MB: u64 = 24_000;
|
||||
|
||||
const CATALOGUE: &str = r#"
|
||||
[[models]]
|
||||
id = "music-a"
|
||||
harness = "candle"
|
||||
vram_mb = 14000
|
||||
|
||||
[[models]]
|
||||
id = "music-b"
|
||||
harness = "candle"
|
||||
vram_mb = 12000
|
||||
"#;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Card {
|
||||
resident: HashMap<String, u64>,
|
||||
in_flight: HashMap<String, usize>,
|
||||
overcommitted: bool,
|
||||
unloads: Vec<String>,
|
||||
/// A model the neuron is loading by itself at startup: not resident
|
||||
/// yet, its memory not yet showing.
|
||||
activating: Option<String>,
|
||||
loads: Vec<String>,
|
||||
}
|
||||
|
||||
fn footprint(model: &str) -> u64 {
|
||||
match model {
|
||||
"music-a" => 14_000,
|
||||
"music-b" => 12_000,
|
||||
_ => 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_neuron(card: Arc<Mutex<Card>>) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let inference = url.clone();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/models/load",
|
||||
post(
|
||||
|State(card): State<Arc<Mutex<Card>>>, Json(spec): Json<Value>| async move {
|
||||
let id = spec["model_id"].as_str().unwrap().to_string();
|
||||
// A load takes a while: long enough for a second
|
||||
// request to decide while this one is in progress.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let mut card = card.lock().unwrap();
|
||||
card.loads.push(id.clone());
|
||||
card.resident.insert(id.clone(), footprint(&id));
|
||||
if card.resident.values().sum::<u64>() > CARD_MB {
|
||||
card.overcommitted = true;
|
||||
}
|
||||
Json(json!({"status": "loaded"}))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/models/unload",
|
||||
post(
|
||||
|State(card): State<Arc<Mutex<Card>>>, Json(body): Json<Value>| async move {
|
||||
let id = body["model_id"].as_str().unwrap().to_string();
|
||||
let mut card = card.lock().unwrap();
|
||||
card.resident.remove(&id);
|
||||
card.unloads.push(id);
|
||||
Json(json!({"status": "unloaded"}))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/health",
|
||||
get(|State(card): State<Arc<Mutex<Card>>>| async move {
|
||||
let card = card.lock().unwrap();
|
||||
let used: u64 = card.resident.values().sum();
|
||||
let models: Vec<Value> = card
|
||||
.resident
|
||||
.keys()
|
||||
.map(|id| {
|
||||
json!({
|
||||
"id": id,
|
||||
"in_flight": card.in_flight.get(id).copied().unwrap_or(0),
|
||||
"queue_depth": 0
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let activation = match &card.activating {
|
||||
Some(id) => json!({"state": "pre_warming", "in_progress": id}),
|
||||
None => json!({"state": "ready"}),
|
||||
};
|
||||
Json(json!({
|
||||
"uptime_secs": 1,
|
||||
"activation": activation,
|
||||
"devices": [{
|
||||
"index": 0,
|
||||
"vram_used_mb": used,
|
||||
"vram_free_mb": CARD_MB.saturating_sub(used),
|
||||
"utilization_pct": 0,
|
||||
"temp_c": 40
|
||||
}],
|
||||
"models": models
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/models/{id}/endpoint",
|
||||
get(move |Path(_id): Path<String>| {
|
||||
let url = inference.clone();
|
||||
async move { Json(json!({"url": url})) }
|
||||
}),
|
||||
)
|
||||
.with_state(card);
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
url
|
||||
}
|
||||
|
||||
/// A gateway with one healthy 24 GB node, its first `/health` already in.
|
||||
async fn gateway(neuron: &str, tag: &str) -> Arc<CortexState> {
|
||||
let path = std::env::temp_dir().join(format!("cortex-cold-load-{tag}.toml"));
|
||||
std::fs::write(&path, CATALOGUE).unwrap();
|
||||
let config = GatewayConfig {
|
||||
gateway: GatewaySettings {
|
||||
listen: "127.0.0.1:0".into(),
|
||||
metrics_listen: "127.0.0.1:0".into(),
|
||||
},
|
||||
eviction: EvictionSettings {
|
||||
strategy: EvictionStrategy::Lru,
|
||||
defrag_after_cycles: 0,
|
||||
},
|
||||
neurons: vec![NeuronEndpoint {
|
||||
name: "gpu".into(),
|
||||
endpoint: neuron.to_string(),
|
||||
}],
|
||||
models_config: path.to_string_lossy().into_owned(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
audio_jobs: Default::default(),
|
||||
};
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
{
|
||||
let mut nodes = fleet.nodes.write().await;
|
||||
let node = nodes.get_mut("gpu").unwrap();
|
||||
node.healthy = true;
|
||||
node.discovery = Some(DiscoveryResponse {
|
||||
hostname: "gpu".into(),
|
||||
os: "linux".into(),
|
||||
kernel: "6".into(),
|
||||
cuda_version: None,
|
||||
driver_version: None,
|
||||
devices: vec![DeviceInfo {
|
||||
index: 0,
|
||||
name: "RTX 4090".into(),
|
||||
vram_total_mb: CARD_MB,
|
||||
compute_capability: "8.9".into(),
|
||||
}],
|
||||
harnesses: vec!["candle".into()],
|
||||
cuda_unavailable_reason: None,
|
||||
max_prompt_tokens: 0,
|
||||
});
|
||||
}
|
||||
cortex_gateway::poller::poll_health(&fleet, "gpu", neuron).await;
|
||||
fleet
|
||||
}
|
||||
|
||||
/// Mark `id` resident in the mock and in cortex's view, last used long
|
||||
/// ago — as the poller would have seen it, not as a fresh cold-load.
|
||||
async fn seed_resident(fleet: &CortexState, card: &Mutex<Card>, neuron: &str, id: &str) {
|
||||
card.lock()
|
||||
.unwrap()
|
||||
.resident
|
||||
.insert(id.into(), footprint(id));
|
||||
{
|
||||
let mut nodes = fleet.nodes.write().await;
|
||||
nodes.get_mut("gpu").unwrap().models.insert(
|
||||
id.into(),
|
||||
ModelEntry {
|
||||
id: id.into(),
|
||||
status: ModelStatus::Loaded,
|
||||
last_accessed: Some(chrono::Utc::now() - chrono::Duration::minutes(10)),
|
||||
vram_estimate_mb: Some(footprint(id)),
|
||||
capabilities: Vec::new(),
|
||||
tool_call: false,
|
||||
reasoning: false,
|
||||
limit: None,
|
||||
servable: None,
|
||||
reasoning_budget: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
cortex_gateway::poller::poll_health(fleet, "gpu", neuron).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_cold_loads_never_overcommit_the_card() {
|
||||
let card = Arc::new(Mutex::new(Card::default()));
|
||||
let neuron = spawn_neuron(Arc::clone(&card)).await;
|
||||
let fleet = gateway(&neuron, "concurrent").await;
|
||||
|
||||
// Both decide while the card is empty; together they do not fit.
|
||||
let (a, b) = tokio::join!(cortex_gateway::router::resolve(&fleet, "music-a"), async {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
cortex_gateway::router::resolve(&fleet, "music-b").await
|
||||
});
|
||||
|
||||
let card = card.lock().unwrap();
|
||||
assert!(!card.overcommitted, "both models were resident at once");
|
||||
assert!(a.is_ok(), "{a:?}");
|
||||
// music-a was cold-loaded for a request that has yet to reach it, so
|
||||
// music-b may not take its place yet: retry shortly, not a failure.
|
||||
match b {
|
||||
Err(e @ RouteError::NodeBusy { .. }) => {
|
||||
assert_eq!(e.http_status(), 503);
|
||||
assert!(e.retry_after_secs().is_some());
|
||||
}
|
||||
other => panic!("expected NodeBusy, got {other:?}"),
|
||||
}
|
||||
assert_eq!(card.resident.keys().collect::<Vec<_>>(), ["music-a"]);
|
||||
assert!(card.unloads.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_model_with_requests_in_flight_is_not_evicted() {
|
||||
let card = Arc::new(Mutex::new(Card::default()));
|
||||
let neuron = spawn_neuron(Arc::clone(&card)).await;
|
||||
let fleet = gateway(&neuron, "in-flight").await;
|
||||
card.lock().unwrap().in_flight.insert("music-a".into(), 1);
|
||||
seed_resident(&fleet, &card, &neuron, "music-a").await;
|
||||
|
||||
let b = cortex_gateway::router::resolve(&fleet, "music-b").await;
|
||||
|
||||
assert!(matches!(b, Err(RouteError::NodeBusy { .. })), "{b:?}");
|
||||
let card = card.lock().unwrap();
|
||||
assert!(card.unloads.is_empty(), "evicted a busy model");
|
||||
assert!(!card.overcommitted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_idle_model_is_swapped_out() {
|
||||
let card = Arc::new(Mutex::new(Card::default()));
|
||||
let neuron = spawn_neuron(Arc::clone(&card)).await;
|
||||
let fleet = gateway(&neuron, "idle").await;
|
||||
seed_resident(&fleet, &card, &neuron, "music-a").await;
|
||||
|
||||
let b = cortex_gateway::router::resolve(&fleet, "music-b").await;
|
||||
|
||||
assert!(b.is_ok(), "{b:?}");
|
||||
let card = card.lock().unwrap();
|
||||
assert_eq!(card.unloads, ["music-a"]);
|
||||
assert_eq!(card.resident.keys().collect::<Vec<_>>(), ["music-b"]);
|
||||
assert!(!card.overcommitted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_cold_load_waits_for_the_neuron_s_own_startup_load() {
|
||||
// The neuron has just restarted and is loading its default model.
|
||||
// That load is in no model list yet and its memory has barely
|
||||
// started to fill, so the card still looks empty.
|
||||
let card = Arc::new(Mutex::new(Card {
|
||||
activating: Some("music-a".into()),
|
||||
..Default::default()
|
||||
}));
|
||||
let neuron = spawn_neuron(Arc::clone(&card)).await;
|
||||
let fleet = gateway(&neuron, "activating").await;
|
||||
|
||||
let b = cortex_gateway::router::resolve(&fleet, "music-b").await;
|
||||
|
||||
assert!(matches!(b, Err(RouteError::NodeBusy { .. })), "{b:?}");
|
||||
assert!(
|
||||
card.lock().unwrap().loads.is_empty(),
|
||||
"loaded over a startup load"
|
||||
);
|
||||
|
||||
// Once the startup load finishes and its model is idle, the swap
|
||||
// goes ahead as usual.
|
||||
{
|
||||
let mut c = card.lock().unwrap();
|
||||
c.activating = None;
|
||||
}
|
||||
seed_resident(&fleet, &card, &neuron, "music-a").await;
|
||||
let b = cortex_gateway::router::resolve(&fleet, "music-b").await;
|
||||
assert!(b.is_ok(), "{b:?}");
|
||||
let card = card.lock().unwrap();
|
||||
assert_eq!(card.unloads, ["music-a"]);
|
||||
assert!(!card.overcommitted);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! `/v1/models` agrees with routing about unhealthy neurons (#376).
|
||||
//!
|
||||
//! When a neuron stops answering, the poller marks it unhealthy but keeps
|
||||
//! its last `/models` and activation snapshot. The router then refuses to
|
||||
//! send it anything, so a model that only that neuron holds cannot be
|
||||
//! served without a cold-load elsewhere. The listing used to report it as
|
||||
//! `loaded: true` anyway — and through tier aliases too — so a client that
|
||||
//! trusted `loaded` picked a model that failed at once.
|
||||
|
||||
mod common;
|
||||
|
||||
use cortex_core::config::{
|
||||
EvictionSettings, EvictionStrategy, GatewayConfig, GatewaySettings, NeuronEndpoint,
|
||||
};
|
||||
use cortex_gateway::state::CortexState;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn write_catalogue() -> std::path::PathBuf {
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
let toml = r#"
|
||||
[[models]]
|
||||
id = "Qwen/large"
|
||||
harness = "candle"
|
||||
min_devices = 1
|
||||
|
||||
[aliases]
|
||||
"helexa/large" = "Qwen/large"
|
||||
"#;
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"cortex_test_unhealthy_listing.{}.{}.toml",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Two neurons: `big` holds `Qwen/large` and is pre-warming `Qwen/next`;
|
||||
/// `small` holds `Qwen/small`. Both are polled healthy once.
|
||||
async fn polled_fleet() -> Arc<CortexState> {
|
||||
let big = common::spawn_mock_neuron_with_models_and_health(
|
||||
json!([
|
||||
{"id": "Qwen/large", "harness": "candle", "status": "loaded", "devices": [0], "vram_used_mb": 20000}
|
||||
]),
|
||||
json!({
|
||||
"uptime_secs": 30,
|
||||
"devices": [],
|
||||
"activation": {
|
||||
"state": "pre_warming",
|
||||
"pending": [],
|
||||
"in_progress": "Qwen/next",
|
||||
"completed": [],
|
||||
"failed": []
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let small = common::spawn_mock_neuron_with_models(json!([
|
||||
{"id": "Qwen/small", "harness": "candle", "status": "loaded", "devices": [0], "vram_used_mb": 4000}
|
||||
]))
|
||||
.await;
|
||||
let config = GatewayConfig {
|
||||
gateway: GatewaySettings {
|
||||
listen: "127.0.0.1:0".into(),
|
||||
metrics_listen: "127.0.0.1:0".into(),
|
||||
},
|
||||
eviction: EvictionSettings {
|
||||
strategy: EvictionStrategy::Lru,
|
||||
defrag_after_cycles: 0,
|
||||
},
|
||||
neurons: vec![
|
||||
NeuronEndpoint {
|
||||
name: "big".into(),
|
||||
endpoint: big,
|
||||
},
|
||||
NeuronEndpoint {
|
||||
name: "small".into(),
|
||||
endpoint: small,
|
||||
},
|
||||
],
|
||||
models_config: write_catalogue().to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
cortex_gateway::poller::poll_once(&fleet).await;
|
||||
fleet
|
||||
}
|
||||
|
||||
async fn listing(fleet: Arc<CortexState>) -> Vec<Value> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = cortex_gateway::build_app(fleet);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let body: Value = reqwest::get(format!("http://{addr}/v1/models"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
body["data"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
fn entry<'a>(data: &'a [Value], id: &str) -> &'a Value {
|
||||
data.iter()
|
||||
.find(|m| m["id"] == id)
|
||||
.unwrap_or_else(|| panic!("{id} should be listed"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_on_an_unhealthy_neuron_are_not_reported_loaded() {
|
||||
let fleet = polled_fleet().await;
|
||||
|
||||
// Control: while both neurons answer, the cached state is live.
|
||||
let data = listing(fleet.clone()).await;
|
||||
assert_eq!(entry(&data, "Qwen/large")["loaded"], true);
|
||||
assert_eq!(entry(&data, "helexa/large")["loaded"], true);
|
||||
assert_eq!(
|
||||
entry(&data, "Qwen/next")["locations"][0]["status"],
|
||||
"loading"
|
||||
);
|
||||
|
||||
// `big` goes dark. The poller flips the flag after three failed
|
||||
// polls (test_poller_marks_unreachable_node_unhealthy) and keeps the
|
||||
// snapshot, which is the state this sets up.
|
||||
fleet.nodes.write().await.get_mut("big").unwrap().healthy = false;
|
||||
|
||||
let data = listing(fleet).await;
|
||||
let large = entry(&data, "Qwen/large");
|
||||
assert_eq!(
|
||||
large["loaded"], false,
|
||||
"a model held only by an unhealthy neuron cannot be served as loaded"
|
||||
);
|
||||
assert_eq!(
|
||||
large["locations"],
|
||||
json!([{"node": "big", "status": "unreachable", "vram_estimate_mb": large["locations"][0]["vram_estimate_mb"]}]),
|
||||
"the location stays visible for diagnostics, marked unreachable"
|
||||
);
|
||||
assert_eq!(
|
||||
entry(&data, "helexa/large")["loaded"],
|
||||
false,
|
||||
"the tier alias must not report its target as loaded either"
|
||||
);
|
||||
assert!(
|
||||
data.iter().all(|m| m["id"] != "Qwen/next"),
|
||||
"a stale activation snapshot must not advertise a model as loading"
|
||||
);
|
||||
|
||||
// The healthy neuron is unaffected.
|
||||
assert_eq!(entry(&data, "Qwen/small")["loaded"], true);
|
||||
}
|
||||
@@ -138,6 +138,66 @@ pub struct CandleHarnessConfig {
|
||||
/// text-to-image pipeline.
|
||||
#[serde(default)]
|
||||
pub image: ImageConfig,
|
||||
|
||||
/// `[harness.candle.audio]` settings (#385) — how an ACE-Step
|
||||
/// pipeline is laid out on this host.
|
||||
#[serde(default)]
|
||||
pub audio: AudioConfig,
|
||||
}
|
||||
|
||||
/// `[harness.candle.audio]` settings (#385).
|
||||
///
|
||||
/// These describe the host, not the model, which is why they live here
|
||||
/// and not in the catalogue: the same ACE-Step id serves bf16 on a 4090
|
||||
/// and q8 with f32 compute on an 8 GB Pascal card.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AudioConfig {
|
||||
/// In-situ weight quantization of the DiT, the text encoder and the
|
||||
/// planner: `"q8_0"`, or unset for none. Quantized components compute
|
||||
/// in f32. The VAE is never quantized.
|
||||
#[serde(default)]
|
||||
pub quant: Option<String>,
|
||||
/// Compute dtype of the unquantized GPU components: `"bf16"`
|
||||
/// (default) or `"f32"`. Cards without fast bf16 (Pascal) need f32.
|
||||
#[serde(default)]
|
||||
pub compute: Option<String>,
|
||||
/// Where each component runs.
|
||||
#[serde(default)]
|
||||
pub placement: AudioPlacement,
|
||||
/// Which planner LM to load: a snapshot subfolder (for example
|
||||
/// `"acestep-5Hz-lm-1.7B"`), a repo id of its own (the 0.6B planner is
|
||||
/// `"ACE-Step/acestep-5Hz-lm-0.6B"`), or `"none"`. Unset takes the
|
||||
/// largest the snapshot has.
|
||||
#[serde(default)]
|
||||
pub planner: Option<String>,
|
||||
/// Treat the device as having this much free memory for the pipeline,
|
||||
/// in MiB. Under a budget the VAE decodes in smaller chunks.
|
||||
#[serde(default)]
|
||||
pub vram_budget_mb: Option<u64>,
|
||||
/// Make the budget real by holding the rest of the device's free
|
||||
/// memory while the pipeline is loaded, so a larger card fails exactly
|
||||
/// where a card of the budget's size would. For rehearsing a small
|
||||
/// card's profile on a large one; never for serving.
|
||||
#[serde(default)]
|
||||
pub enforce_vram_budget: bool,
|
||||
/// Longest song this host accepts, in seconds, when it is less than
|
||||
/// the model's own limit.
|
||||
#[serde(default)]
|
||||
pub max_duration_secs: Option<f64>,
|
||||
}
|
||||
|
||||
/// Per-component placement (#385): `"gpu"` (the model's device, the
|
||||
/// default) or `"cpu"`. CPU components compute in f32.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AudioPlacement {
|
||||
#[serde(default)]
|
||||
pub text_encoder: Option<String>,
|
||||
#[serde(default)]
|
||||
pub planner: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dit: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vae: Option<String>,
|
||||
}
|
||||
|
||||
/// `[harness.candle.image]` settings (#198).
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
//! All encoder layers share the same architecture: pre-norm (RmsNorm) bidirectional
|
||||
//! GQA attention followed by a gated SiLU MLP, matching the Qwen3 block design.
|
||||
|
||||
use super::linear::{Linear, linear_b, linear_no_bias};
|
||||
use candle_core::{DType, Device, Module, Result, Tensor};
|
||||
use candle_nn::{Activation, VarBuilder};
|
||||
use candle_transformers::models::with_tracing::{Linear, RmsNorm, linear_b, linear_no_bias};
|
||||
use candle_transformers::models::with_tracing::RmsNorm;
|
||||
use candle_transformers::utils::repeat_kv;
|
||||
|
||||
use super::AceStepConfig;
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
//! The planner's chain of thought, as text (#384).
|
||||
//!
|
||||
//! The planner writes its plan as YAML between `<think>` tags. Upstream
|
||||
//! reads it back with `parse_lm_output`, then writes it out again with
|
||||
//! `yaml.dump` to build the prompt the audio codes are generated from. Those
|
||||
//! bytes are model input, so the dump has to be reproduced exactly,
|
||||
//! including where PyYAML folds a long caption and when it quotes a value.
|
||||
//!
|
||||
//! Reference: `acestep/llm_inference.py` (`parse_lm_output`,
|
||||
//! `_format_metadata_as_cot`) and `acestep/constrained_logits_processor.py`
|
||||
//! (`postprocess_caption`) at ACE-Step 1.5 `ca1e85fe`; PyYAML's
|
||||
//! `emitter.py` for the scalar writers.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A plan field's value as upstream holds it: `int(...)` where the text is
|
||||
/// a whole number, the text otherwise. The distinction decides how YAML
|
||||
/// writes it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MetaValue {
|
||||
Int(i64),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
impl MetaValue {
|
||||
pub fn as_text(&self) -> String {
|
||||
match self {
|
||||
Self::Int(i) => i.to_string(),
|
||||
Self::Str(s) => s.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plan's fields, by name.
|
||||
pub type Metadata = BTreeMap<String, MetaValue>;
|
||||
|
||||
/// Fields the codes-phase prompt carries, in the order YAML sorts them.
|
||||
const COT_FIELDS: [&str; 6] = [
|
||||
"bpm",
|
||||
"caption",
|
||||
"duration",
|
||||
"keyscale",
|
||||
"language",
|
||||
"timesignature",
|
||||
];
|
||||
|
||||
/// Join a YAML-folded caption back into one line: every line trimmed,
|
||||
/// blank lines dropped, joined with single spaces.
|
||||
pub fn postprocess_caption(caption: &str) -> String {
|
||||
caption
|
||||
.split('\n')
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Python's `str.isdigit` for the ASCII digits upstream's values use.
|
||||
fn is_digits(s: &str) -> bool {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Read a plan out of the planner's output, as `parse_lm_output` does.
|
||||
///
|
||||
/// The CoT phase stops before `</think>`, so the reasoning section is the
|
||||
/// text between the tags when both are present, else everything before the
|
||||
/// first audio code. A field is a line that does not start with whitespace
|
||||
/// and has a colon; indented lines continue the previous field.
|
||||
pub fn parse_plan(output: &str) -> Metadata {
|
||||
let reasoning = match (output.find("<think>"), output.find("</think>")) {
|
||||
(Some(s), Some(e)) if e > s => output[s + "<think>".len()..e].trim().to_string(),
|
||||
_ => output
|
||||
.split("<|audio_code_")
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let mut meta = Metadata::new();
|
||||
let mut key: Option<String> = None;
|
||||
let mut lines: Vec<&str> = Vec::new();
|
||||
let save = |key: &mut Option<String>, lines: &mut Vec<&str>, meta: &mut Metadata| {
|
||||
if let Some(k) = key.take()
|
||||
&& !lines.is_empty()
|
||||
{
|
||||
let value = lines.join("\n");
|
||||
let v = match k.as_str() {
|
||||
"bpm" | "duration" => match value.trim().parse::<i64>() {
|
||||
Ok(i) => MetaValue::Int(i),
|
||||
Err(_) => MetaValue::Str(value.trim().to_string()),
|
||||
},
|
||||
"caption" => MetaValue::Str(postprocess_caption(&value)),
|
||||
"genres" | "keyscale" | "language" | "timesignature" => {
|
||||
MetaValue::Str(value.trim().to_string())
|
||||
}
|
||||
_ => {
|
||||
lines.clear();
|
||||
return;
|
||||
}
|
||||
};
|
||||
meta.insert(k, v);
|
||||
}
|
||||
lines.clear();
|
||||
};
|
||||
for line in reasoning.split('\n') {
|
||||
if line.trim().starts_with('<') {
|
||||
continue;
|
||||
}
|
||||
let starts_blank = line.chars().next().is_some_and(char::is_whitespace);
|
||||
if !line.is_empty() && !starts_blank && line.contains(':') {
|
||||
save(&mut key, &mut lines, &mut meta);
|
||||
let (k, v) = line.split_once(':').unwrap_or((line, ""));
|
||||
key = Some(k.trim().to_lowercase());
|
||||
if !v.trim().is_empty() {
|
||||
lines.push(v);
|
||||
}
|
||||
} else if (line.starts_with(' ') || line.starts_with('\t')) && key.is_some() {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
save(&mut key, &mut lines, &mut meta);
|
||||
meta
|
||||
}
|
||||
|
||||
/// The CoT block the codes phase is prompted with, as
|
||||
/// `_format_metadata_as_cot` builds it: `<think>\n{yaml}\n</think>`.
|
||||
pub fn format_plan(meta: &Metadata) -> String {
|
||||
let mut yaml = String::new();
|
||||
for key in COT_FIELDS {
|
||||
let Some(value) = meta.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let value = match value {
|
||||
MetaValue::Str(s) if key == "timesignature" && s.ends_with("/4") => {
|
||||
MetaValue::Str(s.split('/').next().unwrap_or("").to_string())
|
||||
}
|
||||
v => v.clone(),
|
||||
};
|
||||
let value = match value {
|
||||
MetaValue::Str(s) if is_digits(&s) => match s.parse::<i64>() {
|
||||
Ok(i) => MetaValue::Int(i),
|
||||
Err(_) => MetaValue::Str(s),
|
||||
},
|
||||
v => v,
|
||||
};
|
||||
yaml.push_str(&yaml_entry(key, &value));
|
||||
}
|
||||
format!("<think>\n{}\n</think>", yaml.trim())
|
||||
}
|
||||
|
||||
// ── A PyYAML block-mapping entry ─────────────────────────────────────
|
||||
|
||||
/// PyYAML's default line width and mapping indent.
|
||||
const BEST_WIDTH: usize = 80;
|
||||
const INDENT: usize = 2;
|
||||
|
||||
/// One `key: value\n` line (or lines) of `yaml.dump(..., allow_unicode=True)`.
|
||||
fn yaml_entry(key: &str, value: &MetaValue) -> String {
|
||||
let mut w = Writer {
|
||||
out: format!("{key}:"),
|
||||
column: key.chars().count() + 1,
|
||||
};
|
||||
match value {
|
||||
MetaValue::Int(i) => {
|
||||
w.out.push(' ');
|
||||
w.out.push_str(&i.to_string());
|
||||
}
|
||||
MetaValue::Str(s) => match scalar_style(s) {
|
||||
Style::Plain => w.plain(s),
|
||||
Style::SingleQuoted => w.single_quoted(s),
|
||||
Style::DoubleQuoted => w.double_quoted(s),
|
||||
},
|
||||
}
|
||||
w.out.push('\n');
|
||||
w.out
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Style {
|
||||
Plain,
|
||||
SingleQuoted,
|
||||
DoubleQuoted,
|
||||
}
|
||||
|
||||
fn is_ws(c: Option<char>) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
None | Some('\0' | ' ' | '\t' | '\r' | '\n' | '\u{85}' | '\u{2028}' | '\u{2029}')
|
||||
)
|
||||
}
|
||||
|
||||
fn is_break(c: char) -> bool {
|
||||
matches!(c, '\n' | '\u{85}' | '\u{2028}' | '\u{2029}')
|
||||
}
|
||||
|
||||
/// `choose_scalar_style` for a block-mapping value: plain when PyYAML's
|
||||
/// analysis allows a block plain scalar and the text would read back as a
|
||||
/// string, else single-quoted when allowed, else double-quoted.
|
||||
fn scalar_style(s: &str) -> Style {
|
||||
if s.is_empty() {
|
||||
// An empty value would read back as null.
|
||||
return Style::SingleQuoted;
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut block_indicators = s.starts_with("---") || s.starts_with("...");
|
||||
let mut line_breaks = false;
|
||||
let mut special = false;
|
||||
let (mut leading_space, mut leading_break, mut trailing_space, mut trailing_break) =
|
||||
(false, false, false, false);
|
||||
let (mut break_space, mut space_break) = (false, false);
|
||||
let (mut previous_space, mut previous_break) = (false, false);
|
||||
let mut preceded_by_ws = true;
|
||||
for i in 0..n {
|
||||
let ch = chars[i];
|
||||
let followed_by_ws = is_ws(chars.get(i + 1).copied());
|
||||
if i == 0 {
|
||||
if "#,[]{}&*!|>'\"%@`".contains(ch) {
|
||||
block_indicators = true;
|
||||
}
|
||||
if "?:".contains(ch) && followed_by_ws {
|
||||
block_indicators = true;
|
||||
}
|
||||
if ch == '-' && followed_by_ws {
|
||||
block_indicators = true;
|
||||
}
|
||||
} else {
|
||||
if ch == ':' && followed_by_ws {
|
||||
block_indicators = true;
|
||||
}
|
||||
if ch == '#' && preceded_by_ws {
|
||||
block_indicators = true;
|
||||
}
|
||||
}
|
||||
if is_break(ch) {
|
||||
line_breaks = true;
|
||||
}
|
||||
if !(ch == '\n' || (' '..='~').contains(&ch)) {
|
||||
let printable = ch == '\u{85}'
|
||||
|| ('\u{A0}'..='\u{D7FF}').contains(&ch)
|
||||
|| ('\u{E000}'..='\u{FFFD}').contains(&ch)
|
||||
|| ('\u{10000}'..'\u{10FFFF}').contains(&ch);
|
||||
// allow_unicode=True: printable non-ASCII is fine.
|
||||
if !printable || ch == '\u{FEFF}' {
|
||||
special = true;
|
||||
}
|
||||
}
|
||||
if ch == ' ' {
|
||||
leading_space |= i == 0;
|
||||
trailing_space |= i == n - 1;
|
||||
break_space |= previous_break;
|
||||
previous_space = true;
|
||||
previous_break = false;
|
||||
} else if is_break(ch) {
|
||||
leading_break |= i == 0;
|
||||
trailing_break |= i == n - 1;
|
||||
space_break |= previous_space;
|
||||
previous_space = false;
|
||||
previous_break = true;
|
||||
} else {
|
||||
previous_space = false;
|
||||
previous_break = false;
|
||||
}
|
||||
preceded_by_ws = is_ws(Some(ch));
|
||||
}
|
||||
let mut allow_plain = true;
|
||||
let mut allow_single = true;
|
||||
if leading_space || leading_break || trailing_space || trailing_break {
|
||||
allow_plain = false;
|
||||
}
|
||||
if break_space {
|
||||
allow_plain = false;
|
||||
allow_single = false;
|
||||
}
|
||||
if space_break || special {
|
||||
allow_plain = false;
|
||||
allow_single = false;
|
||||
}
|
||||
if line_breaks || block_indicators {
|
||||
allow_plain = false;
|
||||
}
|
||||
if allow_plain && resolves_as_string(s) {
|
||||
Style::Plain
|
||||
} else if allow_single {
|
||||
Style::SingleQuoted
|
||||
} else {
|
||||
Style::DoubleQuoted
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a plain scalar with this text would read back as a string under
|
||||
/// PyYAML's YAML 1.1 implicit resolvers (bool, int, float, null, merge,
|
||||
/// value, timestamp). Anything else needs quoting.
|
||||
fn resolves_as_string(s: &str) -> bool {
|
||||
const BOOLS: [&str; 18] = [
|
||||
"yes", "Yes", "YES", "no", "No", "NO", "true", "True", "TRUE", "false", "False", "FALSE",
|
||||
"on", "On", "ON", "off", "Off", "OFF",
|
||||
];
|
||||
const NULLS: [&str; 4] = ["~", "null", "Null", "NULL"];
|
||||
if BOOLS.contains(&s) || NULLS.contains(&s) || s == "<<" || s == "=" {
|
||||
return false;
|
||||
}
|
||||
!(is_yaml_int(s) || is_yaml_float(s) || is_yaml_timestamp(s))
|
||||
}
|
||||
|
||||
fn strip_sign(s: &str) -> &str {
|
||||
s.strip_prefix(['-', '+']).unwrap_or(s)
|
||||
}
|
||||
|
||||
/// PyYAML's int resolver: binary, octal, decimal, hex and sexagesimal,
|
||||
/// with `_` separators.
|
||||
fn is_yaml_int(s: &str) -> bool {
|
||||
let b = strip_sign(s);
|
||||
let all = |t: &str, ok: fn(char) -> bool| !t.is_empty() && t.chars().all(|c| ok(c) || c == '_');
|
||||
if let Some(t) = b.strip_prefix("0b") {
|
||||
return all(t, |c| c == '0' || c == '1');
|
||||
}
|
||||
if let Some(t) = b.strip_prefix("0x") {
|
||||
return all(t, |c| c.is_ascii_hexdigit());
|
||||
}
|
||||
if b == "0" {
|
||||
return true;
|
||||
}
|
||||
if let Some(t) = b.strip_prefix('0') {
|
||||
return all(t, |c| ('0'..='7').contains(&c));
|
||||
}
|
||||
if b.starts_with(|c: char| ('1'..='9').contains(&c)) {
|
||||
if all(b, |c| c.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
// Sexagesimal: [1-9][0-9_]*(:[0-5]?[0-9])+
|
||||
let mut parts = b.split(':');
|
||||
let head = parts.next().unwrap_or("");
|
||||
let rest: Vec<&str> = parts.collect();
|
||||
return !rest.is_empty()
|
||||
&& all(head, |c| c.is_ascii_digit())
|
||||
&& rest.iter().all(|p| {
|
||||
let p: Vec<char> = p.chars().collect();
|
||||
match p.as_slice() {
|
||||
[d] => d.is_ascii_digit(),
|
||||
[a, d] => ('0'..='5').contains(a) && d.is_ascii_digit(),
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// PyYAML's float resolver, including `.inf`, `.nan` and sexagesimal.
|
||||
fn is_yaml_float(s: &str) -> bool {
|
||||
let b = strip_sign(s);
|
||||
if matches!(b, ".inf" | ".Inf" | ".INF") {
|
||||
return true;
|
||||
}
|
||||
if matches!(s, ".nan" | ".NaN" | ".NAN") {
|
||||
return true;
|
||||
}
|
||||
// [-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)? and \.[0-9][0-9_]*(?:[eE][-+][0-9]+)?
|
||||
let (mantissa, exp) = match b.find(['e', 'E']) {
|
||||
Some(i) => (&b[..i], Some(&b[i + 1..])),
|
||||
None => (b, None),
|
||||
};
|
||||
if let Some(e) = exp {
|
||||
let digits = e.strip_prefix(['-', '+']);
|
||||
if !digits.is_some_and(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let Some((int, frac)) = mantissa.split_once('.') else {
|
||||
// Sexagesimal float: [0-9][0-9_]*(:[0-5]?[0-9])+\.[0-9_]*
|
||||
return false;
|
||||
};
|
||||
let digits_ = |t: &str| t.chars().all(|c| c.is_ascii_digit() || c == '_');
|
||||
if int.is_empty() {
|
||||
frac.starts_with(|c: char| c.is_ascii_digit()) && digits_(frac)
|
||||
} else {
|
||||
int.starts_with(|c: char| c.is_ascii_digit()) && digits_(int) && digits_(frac)
|
||||
}
|
||||
}
|
||||
|
||||
/// PyYAML's timestamp resolver, enough to catch dates and date-times.
|
||||
fn is_yaml_timestamp(s: &str) -> bool {
|
||||
let c: Vec<char> = s.chars().collect();
|
||||
c.len() >= 8
|
||||
&& c[..4].iter().all(char::is_ascii_digit)
|
||||
&& c[4] == '-'
|
||||
&& c[5].is_ascii_digit()
|
||||
&& c.iter().skip(5).take_while(|x| x.is_ascii_digit()).count() <= 2
|
||||
&& s[5..].contains('-')
|
||||
}
|
||||
|
||||
/// The emitter state a scalar writer needs: the text so far and the
|
||||
/// current column.
|
||||
struct Writer {
|
||||
out: String,
|
||||
column: usize,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn write(&mut self, s: &str) {
|
||||
self.column += s.chars().count();
|
||||
self.out.push_str(s);
|
||||
}
|
||||
|
||||
/// `write_indent` mid-scalar: a line break and the mapping indent.
|
||||
fn indent(&mut self) {
|
||||
self.out.push('\n');
|
||||
self.out.push_str(&" ".repeat(INDENT));
|
||||
self.column = INDENT;
|
||||
}
|
||||
|
||||
/// `write_plain` with `split=True`: a run of exactly one space past
|
||||
/// the best width becomes a line break.
|
||||
fn plain(&mut self, text: &str) {
|
||||
self.write(" ");
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let (mut spaces, mut start, mut end) = (false, 0usize, 0usize);
|
||||
while end <= chars.len() {
|
||||
let ch = chars.get(end).copied();
|
||||
if spaces {
|
||||
if ch != Some(' ') {
|
||||
if start + 1 == end && self.column > BEST_WIDTH {
|
||||
self.indent();
|
||||
} else {
|
||||
self.write(&chars[start..end].iter().collect::<String>());
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
} else if ch.is_none() || ch == Some(' ') {
|
||||
self.write(&chars[start..end].iter().collect::<String>());
|
||||
start = end;
|
||||
}
|
||||
if let Some(c) = ch {
|
||||
spaces = c == ' ';
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `write_single_quoted` with `split=True` (no line breaks reach it:
|
||||
/// captions are joined to one line first).
|
||||
fn single_quoted(&mut self, text: &str) {
|
||||
self.write(" '");
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
let (mut spaces, mut start, mut end) = (false, 0usize, 0usize);
|
||||
while end <= n {
|
||||
let ch = chars.get(end).copied();
|
||||
if spaces {
|
||||
if ch != Some(' ') {
|
||||
if start + 1 == end && self.column > BEST_WIDTH && start != 0 && end != n {
|
||||
self.indent();
|
||||
} else {
|
||||
self.write(&chars[start..end].iter().collect::<String>());
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
} else if (ch.is_none() || ch == Some(' ') || ch == Some('\'')) && start < end {
|
||||
self.write(&chars[start..end].iter().collect::<String>());
|
||||
start = end;
|
||||
}
|
||||
if ch == Some('\'') {
|
||||
self.write("''");
|
||||
start = end + 1;
|
||||
}
|
||||
if let Some(c) = ch {
|
||||
spaces = c == ' ';
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
self.write("'");
|
||||
}
|
||||
|
||||
/// Double quotes for text with control characters. PyYAML folds these
|
||||
/// too; a plan never contains them, so this writes one line.
|
||||
fn double_quoted(&mut self, text: &str) {
|
||||
let mut s = String::from(" \"");
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'"' => s.push_str("\\\""),
|
||||
'\\' => s.push_str("\\\\"),
|
||||
'\n' => s.push_str("\\n"),
|
||||
'\t' => s.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => s.push_str(&format!("\\x{:02X}", c as u32)),
|
||||
c => s.push(c),
|
||||
}
|
||||
}
|
||||
s.push('"');
|
||||
self.write(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_planner() -> serde_json::Value {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src/harness/testdata/acestep/vocal_en_30s_plan__cpu_f32.json");
|
||||
let meta: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
meta["planner"].clone()
|
||||
}
|
||||
|
||||
/// The codes-phase prompt upstream built from its own CoT output: the
|
||||
/// plan read back, then dumped as YAML, folded at column 80.
|
||||
#[test]
|
||||
fn the_plan_round_trips_to_upstream_s_codes_prompt() {
|
||||
let planner = fixture_planner();
|
||||
let cot_out = planner[0]["output_text"].as_str().unwrap();
|
||||
let codes_prompt = planner[1]["formatted_prompt"].as_str().unwrap();
|
||||
let plan = parse_plan(cot_out);
|
||||
assert_eq!(plan["bpm"], MetaValue::Int(130));
|
||||
assert_eq!(plan["duration"], MetaValue::Int(30));
|
||||
assert_eq!(plan["timesignature"], MetaValue::Str("4".into()));
|
||||
let MetaValue::Str(caption) = &plan["caption"] else {
|
||||
panic!("caption is text");
|
||||
};
|
||||
assert!(caption.starts_with("An energetic indie rock track"));
|
||||
assert!(!caption.contains('\n'), "the caption is unfolded");
|
||||
|
||||
let cot = format_plan(&plan);
|
||||
let start = codes_prompt.find("<think>").unwrap();
|
||||
let want = &codes_prompt[start..codes_prompt.len() - "\n\n".len()];
|
||||
assert_eq!(cot, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_that_would_read_back_as_something_else_are_quoted() {
|
||||
assert_eq!(
|
||||
yaml_entry("language", &MetaValue::Str("no".into())),
|
||||
"language: 'no'\n"
|
||||
);
|
||||
assert_eq!(
|
||||
yaml_entry("language", &MetaValue::Str("en".into())),
|
||||
"language: en\n"
|
||||
);
|
||||
assert_eq!(
|
||||
yaml_entry("keyscale", &MetaValue::Str("F# minor".into())),
|
||||
"keyscale: F# minor\n"
|
||||
);
|
||||
assert_eq!(
|
||||
yaml_entry("caption", &MetaValue::Str("Lo-fi: dusty drums".into())),
|
||||
"caption: 'Lo-fi: dusty drums'\n"
|
||||
);
|
||||
assert_eq!(
|
||||
yaml_entry("caption", &MetaValue::Str("'quoted' start".into())),
|
||||
"caption: '''quoted'' start'\n"
|
||||
);
|
||||
assert_eq!(
|
||||
yaml_entry("caption", &MetaValue::Str("1.5".into())),
|
||||
"caption: '1.5'\n"
|
||||
);
|
||||
assert_eq!(yaml_entry("bpm", &MetaValue::Int(92)), "bpm: 92\n");
|
||||
}
|
||||
|
||||
/// Recorded from PyYAML 6 (`yaml.dump({"caption": v}, allow_unicode=True,
|
||||
/// sort_keys=True)`).
|
||||
#[test]
|
||||
fn scalars_match_pyyaml() {
|
||||
let folded = "A track: ".to_string() + &"word ".repeat(30) + "end";
|
||||
let cases: Vec<(String, &str)> = vec![
|
||||
("no".into(), "caption: 'no'\n"),
|
||||
("en".into(), "caption: en\n"),
|
||||
("F# minor".into(), "caption: F# minor\n"),
|
||||
(
|
||||
"Lo-fi: dusty drums".into(),
|
||||
"caption: 'Lo-fi: dusty drums'\n",
|
||||
),
|
||||
("'quoted' start".into(), "caption: '''quoted'' start'\n"),
|
||||
("1.5".into(), "caption: '1.5'\n"),
|
||||
(
|
||||
folded,
|
||||
"caption: 'A track: word word word word word word word word word word word word word\n word word word word word word word word word word word word word word word word\n word end'\n",
|
||||
),
|
||||
("yes".into(), "caption: 'yes'\n"),
|
||||
("2024-01-02".into(), "caption: '2024-01-02'\n"),
|
||||
("1:30".into(), "caption: '1:30'\n"),
|
||||
("0x1F".into(), "caption: '0x1F'\n"),
|
||||
("~".into(), "caption: '~'\n"),
|
||||
("null".into(), "caption: 'null'\n"),
|
||||
("- dash start".into(), "caption: '- dash start'\n"),
|
||||
("tag #x".into(), "caption: 'tag #x'\n"),
|
||||
("a#b".into(), "caption: a#b\n"),
|
||||
("ends:".into(), "caption: 'ends:'\n"),
|
||||
];
|
||||
for (v, want) in cases {
|
||||
assert_eq!(
|
||||
yaml_entry("caption", &MetaValue::Str(v.clone())),
|
||||
want,
|
||||
"{v:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quoted_caption_folds_like_a_plain_one() {
|
||||
let caption = "A track: ".to_string() + &"word ".repeat(30) + "end";
|
||||
let entry = yaml_entry("caption", &MetaValue::Str(caption));
|
||||
let lines: Vec<&str> = entry.trim_end().split('\n').collect();
|
||||
assert!(lines[0].starts_with("caption: 'A track: word"));
|
||||
assert!(lines.len() > 1);
|
||||
assert!(lines[1..].iter().all(|l| l.starts_with(" ")));
|
||||
assert!(lines[0].chars().count() > BEST_WIDTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genres_and_unknown_fields_never_reach_the_prompt() {
|
||||
let plan = parse_plan("<think>\nbpm: 90\ngenres: jazz\nmood: calm\n</think>");
|
||||
assert!(plan.contains_key("genres"));
|
||||
assert!(!plan.contains_key("mood"));
|
||||
assert_eq!(format_plan(&plan), "<think>\nbpm: 90\n</think>");
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,12 @@
|
||||
//!
|
||||
//! Reference: <https://huggingface.co/ACE-Step/ACE-Step-v1-3.5B>
|
||||
|
||||
use super::linear::{Linear, linear_b, linear_no_bias};
|
||||
use candle_core::{D, DType, Device, Module, Result, Tensor};
|
||||
use candle_nn::{
|
||||
Activation, Conv1d, Conv1dConfig, ConvTranspose1d, ConvTranspose1dConfig, VarBuilder,
|
||||
};
|
||||
use candle_transformers::models::with_tracing::{Linear, RmsNorm, linear_b, linear_no_bias};
|
||||
use candle_transformers::models::with_tracing::RmsNorm;
|
||||
use candle_transformers::utils::repeat_kv;
|
||||
|
||||
use super::AceStepConfig;
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
//! Constrained decoding of the planner's chain of thought (#384).
|
||||
//!
|
||||
//! A port of upstream's `MetadataConstrainedLogitsProcessor` as the CoT
|
||||
//! phase uses it: genres skipped, caption and language generated, no
|
||||
//! user-provided fields, and generation stopped where `</think>` would
|
||||
//! begin (`stop_at_reasoning`). It walks
|
||||
//!
|
||||
//! ```text
|
||||
//! <think> \n bpm: <30..300> \n caption: <free text> \n duration: <10..600> \n
|
||||
//! keyscale: <note[acc] major|minor> \n language: <code> \n timesignature: <2|3|4|6> \n
|
||||
//! ```
|
||||
//!
|
||||
//! and ends in EOS. Each step it masks the logits to what the current state
|
||||
//! allows; after sampling it advances on the token chosen. Field values are
|
||||
//! matched as token-id sequences against prefix trees built from how the
|
||||
//! tokenizer actually splits `"bpm: 120"` and the like, as upstream does.
|
||||
//!
|
||||
//! Reference: `acestep/constrained_logits_processor.py` at ACE-Step 1.5
|
||||
//! `ca1e85fe` (`_process_single_sequence`, `update_state`, and the prefix
|
||||
//! tree builders).
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use candle_core::Result;
|
||||
|
||||
/// Upstream's `BPM_MIN..=BPM_MAX`.
|
||||
const BPM: (u32, u32) = (30, 300);
|
||||
/// Upstream's `DURATION_MIN..=DURATION_MAX`.
|
||||
const DURATION: (u32, u32) = (10, 600);
|
||||
const TIME_SIGNATURES: [&str; 4] = ["2", "3", "4", "6"];
|
||||
const KEYSCALE_NOTES: [&str; 7] = ["A", "B", "C", "D", "E", "F", "G"];
|
||||
const KEYSCALE_ACCIDENTALS: [&str; 5] = ["", "#", "b", "♯", "♭"];
|
||||
const KEYSCALE_MODES: [&str; 2] = ["major", "minor"];
|
||||
/// Upstream's `VALID_LANGUAGES`, `unknown` included.
|
||||
pub const LANGUAGES: [&str; 51] = [
|
||||
"ar", "az", "bg", "bn", "ca", "cs", "da", "de", "el", "en", "es", "fa", "fi", "fr", "he", "hi",
|
||||
"hr", "ht", "hu", "id", "is", "it", "ja", "ko", "la", "lt", "ms", "ne", "nl", "no", "pa", "pl",
|
||||
"pt", "ro", "ru", "sa", "sk", "sr", "sv", "sw", "ta", "te", "th", "tl", "tr", "uk", "ur", "vi",
|
||||
"yue", "zh", "unknown",
|
||||
];
|
||||
/// Caption tokens before the caption is forced to end.
|
||||
const MAX_CAPTION_TOKENS: usize = 512;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum State {
|
||||
ThinkTag,
|
||||
NewlineAfterThink,
|
||||
BpmName,
|
||||
BpmValue,
|
||||
CaptionName,
|
||||
CaptionValue,
|
||||
DurationName,
|
||||
DurationValue,
|
||||
KeyscaleName,
|
||||
KeyscaleValue,
|
||||
LanguageName,
|
||||
LanguageValue,
|
||||
TimesigName,
|
||||
TimesigValue,
|
||||
ThinkEndTag,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn fixed(self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Self::ThinkTag => "<think>",
|
||||
Self::NewlineAfterThink => "\n",
|
||||
Self::BpmName => "bpm:",
|
||||
Self::CaptionName => "caption:",
|
||||
Self::DurationName => "duration:",
|
||||
Self::KeyscaleName => "keyscale:",
|
||||
Self::LanguageName => "language:",
|
||||
Self::TimesigName => "timesignature:",
|
||||
Self::ThinkEndTag => "</think>",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Field order with genres skipped.
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::ThinkTag => Self::NewlineAfterThink,
|
||||
Self::NewlineAfterThink => Self::BpmName,
|
||||
Self::BpmName => Self::BpmValue,
|
||||
Self::BpmValue => Self::CaptionName,
|
||||
Self::CaptionName => Self::CaptionValue,
|
||||
Self::CaptionValue => Self::DurationName,
|
||||
Self::DurationName => Self::DurationValue,
|
||||
Self::DurationValue => Self::KeyscaleName,
|
||||
Self::KeyscaleName => Self::KeyscaleValue,
|
||||
Self::KeyscaleValue => Self::LanguageName,
|
||||
Self::LanguageName => Self::LanguageValue,
|
||||
Self::LanguageValue => Self::TimesigName,
|
||||
Self::TimesigName => Self::TimesigValue,
|
||||
Self::TimesigValue => Self::ThinkEndTag,
|
||||
Self::ThinkEndTag | Self::Completed => Self::Completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Token-id prefix → the tokens that may follow it.
|
||||
type PrefixTree = HashMap<Vec<u32>, BTreeSet<u32>>;
|
||||
|
||||
/// The tokenizer, as the state machine needs it.
|
||||
pub trait Vocab {
|
||||
/// Encode without special tokens added (special tokens in the text
|
||||
/// still encode to their ids).
|
||||
fn encode(&self, text: &str) -> Result<Vec<u32>>;
|
||||
/// One token's text, special tokens kept.
|
||||
fn decode(&self, token: u32) -> Result<String>;
|
||||
}
|
||||
|
||||
/// Build a prefix tree for `values` as they tokenize after `"{field}: "`,
|
||||
/// keyed from where `"{field}:"` ends. A value whose tokens do not start
|
||||
/// with the field's own tokens is skipped, as upstream does.
|
||||
fn prefix_tree(
|
||||
vocab: &dyn Vocab,
|
||||
field: &str,
|
||||
values: &[String],
|
||||
newline: u32,
|
||||
first_must_be_note: bool,
|
||||
) -> Result<PrefixTree> {
|
||||
let context = vocab.encode(&format!("{field}:"))?;
|
||||
let mut tree = PrefixTree::new();
|
||||
for value in values {
|
||||
let full = vocab.encode(&format!("{field}: {value}"))?;
|
||||
if full.len() < context.len() || full[..context.len()] != context[..] {
|
||||
continue;
|
||||
}
|
||||
let ids = &full[context.len()..];
|
||||
if ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if first_must_be_note {
|
||||
let first = vocab.decode(ids[0])?;
|
||||
let c = first
|
||||
.trim_start()
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.to_ascii_uppercase());
|
||||
if !c.is_some_and(|c| "ABCDEFG".contains(c)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for i in 0..=ids.len() {
|
||||
let entry = tree.entry(ids[..i].to_vec()).or_default();
|
||||
entry.insert(if i < ids.len() { ids[i] } else { newline });
|
||||
}
|
||||
}
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Keep only `allowed`; with nothing allowed, every logit is `-inf`.
|
||||
fn whitelist(logits: &mut [f32], allowed: &[u32]) {
|
||||
let keep: Vec<(usize, f32)> = allowed
|
||||
.iter()
|
||||
.filter_map(|&t| logits.get(t as usize).map(|&v| (t as usize, v)))
|
||||
.collect();
|
||||
logits.fill(f32::NEG_INFINITY);
|
||||
for (i, v) in keep {
|
||||
logits[i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// `torch.argmax`: the first maximum.
|
||||
fn argmax(logits: &[f32]) -> u32 {
|
||||
let mut best = 0;
|
||||
for (i, &v) in logits.iter().enumerate() {
|
||||
if v > logits[best] {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
best as u32
|
||||
}
|
||||
|
||||
/// The CoT phase's constraint, one per generation.
|
||||
pub struct MetaFsm {
|
||||
state: State,
|
||||
position: usize,
|
||||
acc_ids: Vec<u32>,
|
||||
acc_value: String,
|
||||
caption_after_newline: bool,
|
||||
caption_tokens: usize,
|
||||
caption_ending: bool,
|
||||
pending_field: String,
|
||||
|
||||
newline: u32,
|
||||
eos: u32,
|
||||
backtick: Option<u32>,
|
||||
audio_codes: (u32, u32),
|
||||
bpm: PrefixTree,
|
||||
duration: PrefixTree,
|
||||
timesig: PrefixTree,
|
||||
keyscale: PrefixTree,
|
||||
language: PrefixTree,
|
||||
}
|
||||
|
||||
impl MetaFsm {
|
||||
/// `eos` is `<|im_end|>`; `audio_codes` the inclusive id range of the
|
||||
/// `<|audio_code_N|>` tokens, which a caption may never contain.
|
||||
pub fn new(vocab: &dyn Vocab, eos: u32, audio_codes: (u32, u32)) -> Result<Self> {
|
||||
let last = |s: &str| -> Result<Option<u32>> { Ok(vocab.encode(s)?.last().copied()) };
|
||||
let newline =
|
||||
last("\n")?.ok_or_else(|| candle_core::Error::Msg("newline has no token".into()))?;
|
||||
let range = |(lo, hi): (u32, u32)| (lo..=hi).map(|v| v.to_string()).collect::<Vec<_>>();
|
||||
let mut keyscales = Vec::new();
|
||||
for note in KEYSCALE_NOTES {
|
||||
for acc in KEYSCALE_ACCIDENTALS {
|
||||
for mode in KEYSCALE_MODES {
|
||||
keyscales.push(format!("{note}{acc} {mode}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let strings = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
Ok(Self {
|
||||
state: State::ThinkTag,
|
||||
position: 0,
|
||||
acc_ids: Vec::new(),
|
||||
acc_value: String::new(),
|
||||
caption_after_newline: false,
|
||||
caption_tokens: 0,
|
||||
caption_ending: false,
|
||||
pending_field: String::new(),
|
||||
newline,
|
||||
eos,
|
||||
backtick: last("`")?,
|
||||
audio_codes,
|
||||
bpm: prefix_tree(vocab, "bpm", &range(BPM), newline, false)?,
|
||||
duration: prefix_tree(vocab, "duration", &range(DURATION), newline, false)?,
|
||||
timesig: prefix_tree(
|
||||
vocab,
|
||||
"timesignature",
|
||||
&strings(&TIME_SIGNATURES),
|
||||
newline,
|
||||
false,
|
||||
)?,
|
||||
keyscale: prefix_tree(vocab, "keyscale", &keyscales, newline, true)?,
|
||||
language: prefix_tree(vocab, "language", &strings(&LANGUAGES), newline, false)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_completed(&self) -> bool {
|
||||
self.state == State::Completed
|
||||
}
|
||||
|
||||
/// The longest prefix of what remains of a fixed string that is one
|
||||
/// token; failing that, the first token of the remainder.
|
||||
fn fixed_token(&self, vocab: &dyn Vocab, fixed: &str) -> Result<Option<u32>> {
|
||||
let rest: Vec<char> = fixed.chars().skip(self.position).collect();
|
||||
if rest.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
for end in (1..=rest.len()).rev() {
|
||||
let ids = vocab.encode(&rest[..end].iter().collect::<String>())?;
|
||||
if ids.len() == 1 {
|
||||
return Ok(Some(ids[0]));
|
||||
}
|
||||
}
|
||||
Ok(vocab
|
||||
.encode(&rest.iter().collect::<String>())?
|
||||
.first()
|
||||
.copied())
|
||||
}
|
||||
|
||||
fn transition(&mut self) {
|
||||
self.state = self.state.next();
|
||||
self.position = 0;
|
||||
self.acc_value.clear();
|
||||
self.acc_ids.clear();
|
||||
self.caption_after_newline = false;
|
||||
self.caption_tokens = 0;
|
||||
self.caption_ending = false;
|
||||
self.pending_field.clear();
|
||||
}
|
||||
|
||||
fn tree(&self) -> &PrefixTree {
|
||||
match self.state {
|
||||
State::BpmValue => &self.bpm,
|
||||
State::DurationValue => &self.duration,
|
||||
State::TimesigValue => &self.timesig,
|
||||
State::KeyscaleValue => &self.keyscale,
|
||||
_ => &self.language,
|
||||
}
|
||||
}
|
||||
|
||||
fn allows_newline(&self) -> bool {
|
||||
self.tree()
|
||||
.get(&self.acc_ids)
|
||||
.is_some_and(|s| s.contains(&self.newline))
|
||||
}
|
||||
|
||||
fn continuations(&self) -> Vec<u32> {
|
||||
self.tree()
|
||||
.get(&self.acc_ids)
|
||||
.map(|s| s.iter().copied().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Mask `logits` to what the current state allows.
|
||||
pub fn constrain(&mut self, vocab: &dyn Vocab, logits: &mut [f32]) -> Result<()> {
|
||||
if self.state == State::Completed {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(fixed) = self.state.fixed() {
|
||||
if self.state == State::ThinkEndTag {
|
||||
// stop_at_reasoning: end where `</think>` would begin.
|
||||
whitelist(logits, &[self.eos]);
|
||||
return Ok(());
|
||||
}
|
||||
match self.fixed_token(vocab, fixed)? {
|
||||
Some(t) => whitelist(logits, &[t]),
|
||||
None => {
|
||||
self.transition();
|
||||
if self.state.fixed().is_none() {
|
||||
logits.fill(0.0);
|
||||
return self.constrain(vocab, logits);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match self.state {
|
||||
State::BpmValue | State::DurationValue => {
|
||||
let mut allowed = self.continuations();
|
||||
if self.allows_newline() {
|
||||
allowed.push(self.newline);
|
||||
}
|
||||
whitelist(logits, &allowed);
|
||||
}
|
||||
State::CaptionValue => {
|
||||
if self.caption_after_newline {
|
||||
self.caption_after_newline = false;
|
||||
let top = vocab.decode(argmax(logits))?;
|
||||
if top.chars().next().is_some_and(|c| c != ' ' && c != '\t') {
|
||||
// The model is starting the next field's name: let
|
||||
// it, and pick the field up from what it writes.
|
||||
self.caption_ending = true;
|
||||
self.pending_field.clear();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if self.caption_ending {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(b) = self.backtick
|
||||
&& let Some(v) = logits.get_mut(b as usize)
|
||||
{
|
||||
*v = f32::NEG_INFINITY;
|
||||
}
|
||||
let (lo, hi) = self.audio_codes;
|
||||
for v in logits.iter_mut().take(hi as usize + 1).skip(lo as usize) {
|
||||
*v = f32::NEG_INFINITY;
|
||||
}
|
||||
if self.caption_tokens >= MAX_CAPTION_TOKENS {
|
||||
whitelist(logits, &[self.newline]);
|
||||
}
|
||||
}
|
||||
State::KeyscaleValue | State::TimesigValue => {
|
||||
if self.allows_newline() {
|
||||
whitelist(logits, &[self.newline]);
|
||||
} else {
|
||||
let allowed = self.continuations();
|
||||
if allowed.is_empty() && self.state == State::KeyscaleValue {
|
||||
whitelist(logits, &[self.newline]);
|
||||
} else {
|
||||
whitelist(logits, &allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
State::LanguageValue => {
|
||||
if self.acc_ids.is_empty() {
|
||||
// The single best first token among every language's.
|
||||
let candidates = self.continuations();
|
||||
match candidates.iter().copied().reduce(|best, t| {
|
||||
let v = |t: u32| logits.get(t as usize).copied().unwrap_or(f32::MIN);
|
||||
if v(t) > v(best) { t } else { best }
|
||||
}) {
|
||||
Some(t) => whitelist(logits, &[t]),
|
||||
None => whitelist(logits, &[self.newline]),
|
||||
}
|
||||
} else if self.allows_newline() {
|
||||
whitelist(logits, &[self.newline]);
|
||||
} else {
|
||||
let allowed = self.continuations();
|
||||
if allowed.is_empty() {
|
||||
whitelist(logits, &[self.newline]);
|
||||
} else {
|
||||
whitelist(logits, &allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance on the token that was chosen.
|
||||
pub fn update(&mut self, vocab: &dyn Vocab, token: u32) -> Result<()> {
|
||||
if self.state == State::Completed {
|
||||
return Ok(());
|
||||
}
|
||||
let text = vocab.decode(token)?;
|
||||
if let Some(fixed) = self.state.fixed() {
|
||||
self.position += text.chars().count();
|
||||
if self.state == State::ThinkEndTag && token == self.eos {
|
||||
self.state = State::Completed;
|
||||
} else if self.position >= fixed.chars().count() {
|
||||
self.transition();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match self.state {
|
||||
State::BpmValue
|
||||
| State::DurationValue
|
||||
| State::TimesigValue
|
||||
| State::KeyscaleValue
|
||||
| State::LanguageValue => {
|
||||
if token == self.newline {
|
||||
self.transition();
|
||||
} else {
|
||||
self.acc_ids.push(token);
|
||||
self.acc_value.push_str(&text);
|
||||
}
|
||||
}
|
||||
State::CaptionValue => {
|
||||
self.caption_tokens += 1;
|
||||
self.acc_value.push_str(&text);
|
||||
self.caption_after_newline = text.contains('\n');
|
||||
if self.caption_ending {
|
||||
self.pending_field.push_str(&text);
|
||||
if text.contains(':') {
|
||||
let name = self
|
||||
.pending_field
|
||||
.trim()
|
||||
.trim_end_matches(':')
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let target = match name.as_str() {
|
||||
"duration" => Some(State::DurationValue),
|
||||
"keyscale" => Some(State::KeyscaleValue),
|
||||
"language" => Some(State::LanguageValue),
|
||||
"timesignature" => Some(State::TimesigValue),
|
||||
_ => None,
|
||||
};
|
||||
match target {
|
||||
Some(s) => {
|
||||
self.transition();
|
||||
self.state = s;
|
||||
}
|
||||
None => self.transition(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A vocabulary that splits text into characters, except that a space
|
||||
/// joins the character after it (as `" G"` is one token in the real
|
||||
/// one), with `<think>`, `</think>` and `<|im_end|>` as specials.
|
||||
/// Enough to drive every state; the real tokenizer is exercised by the
|
||||
/// planner parity test.
|
||||
struct Chars;
|
||||
const SPECIALS: [&str; 3] = ["<think>", "</think>", "<|im_end|>"];
|
||||
const BASE: u32 = 1000;
|
||||
const SPACED: u32 = 3000;
|
||||
|
||||
impl Vocab for Chars {
|
||||
fn encode(&self, text: &str) -> Result<Vec<u32>> {
|
||||
if let Some(i) = SPECIALS.iter().position(|s| *s == text) {
|
||||
return Ok(vec![BASE + i as u32]);
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut ids = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == ' '
|
||||
&& i + 1 < chars.len()
|
||||
&& (chars[i + 1] as u32) < 128
|
||||
&& chars[i + 1] != ' '
|
||||
{
|
||||
ids.push(SPACED + chars[i + 1] as u32);
|
||||
i += 2;
|
||||
} else {
|
||||
ids.push(chars[i] as u32);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
fn decode(&self, token: u32) -> Result<String> {
|
||||
Ok(match token {
|
||||
t if (BASE..BASE + 3).contains(&t) => SPECIALS[(t - BASE) as usize].to_string(),
|
||||
t if (SPACED..SPACED + 128).contains(&t) => {
|
||||
format!(" {}", char::from_u32(t - SPACED).unwrap_or('?'))
|
||||
}
|
||||
t => char::from_u32(t).unwrap_or('?').to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the machine with a model that always prefers the next
|
||||
/// character of `wanted`; return what it generates.
|
||||
fn run(wanted: &str) -> String {
|
||||
let v = Chars;
|
||||
let eos = BASE + 2;
|
||||
let mut fsm = MetaFsm::new(&v, eos, (2000, 2010)).unwrap();
|
||||
let want: Vec<u32> = {
|
||||
let mut ids = vec![BASE];
|
||||
ids.extend(v.encode(wanted).unwrap());
|
||||
ids
|
||||
};
|
||||
let mut out = String::new();
|
||||
for step in 0..400 {
|
||||
let mut logits = vec![0f32; 4000];
|
||||
if let Some(&t) = want.get(step) {
|
||||
logits[t as usize] = 10.0;
|
||||
}
|
||||
fsm.constrain(&v, &mut logits).unwrap();
|
||||
let t = argmax(&logits);
|
||||
fsm.update(&v, t).unwrap();
|
||||
if t == eos {
|
||||
out.push_str("<EOS>");
|
||||
break;
|
||||
}
|
||||
out.push_str(&v.decode(t).unwrap());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_well_formed_plan_passes_through_and_stops_before_the_close_tag() {
|
||||
let plan = "\nbpm: 120\ncaption: A calm piano piece\n with soft strings\nduration: 30\nkeyscale: C major\nlanguage: en\ntimesignature: 4\n";
|
||||
assert_eq!(run(plan), format!("<think>{plan}<EOS>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_and_off_vocabulary_values_are_steered() {
|
||||
// bpm 999 is out of range; keyscale H is not a note; language xx
|
||||
// is not a code. The model's preference is followed only where the
|
||||
// grammar allows it.
|
||||
let plan = "\nbpm: 999\ncaption: x\nduration: 30\nkeyscale: H major\nlanguage: xx\ntimesignature: 5\n";
|
||||
let got = run(plan);
|
||||
assert!(got.starts_with("<think>\nbpm: 99"), "{got}");
|
||||
let bpm: u32 = got["<think>\nbpm: ".len()..]
|
||||
.split('\n')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!((BPM.0..=BPM.1).contains(&bpm), "{got}");
|
||||
assert!(got.contains("\nkeyscale: A"), "{got}");
|
||||
let lang = got
|
||||
.split("language: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('\n')
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(LANGUAGES.contains(&lang), "{got}");
|
||||
let ts = got
|
||||
.split("timesignature: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('\n')
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(TIME_SIGNATURES.contains(&ts), "{got}");
|
||||
assert!(got.ends_with("<EOS>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! A linear layer that is either dense or quantized in situ (#385).
|
||||
//!
|
||||
//! The vendored modules build their projections through `linear_b` /
|
||||
//! `linear_no_bias`, the same names `candle_transformers::with_tracing`
|
||||
//! exports, so switching them over is an import change and the vendored
|
||||
//! code stays diffable against upstream.
|
||||
//!
|
||||
//! Whether a layer is quantized is decided while it is built, by
|
||||
//! [`with_quant`]. A scope rather than a parameter, because the setting
|
||||
//! would otherwise have to be threaded through every constructor of the
|
||||
//! DiT, the condition encoder, the text encoder and the planner. Loads
|
||||
//! run on one thread (the device worker), so the scope cannot leak into
|
||||
//! another model's load.
|
||||
//!
|
||||
//! A quantized layer runs through candle's `QMatMul`, which wants f32
|
||||
//! activations: build the component with an f32 `VarBuilder`.
|
||||
|
||||
use candle_core::quantized::{GgmlDType, QMatMul};
|
||||
use candle_core::{Module, Result, Tensor};
|
||||
use candle_nn::VarBuilder;
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
thread_local! {
|
||||
static QUANT: Cell<Option<GgmlDType>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Build with `quant` applied to every linear layer `f` creates. A layer
|
||||
/// whose input width does not divide the block size stays dense.
|
||||
pub fn with_quant<R>(quant: Option<GgmlDType>, f: impl FnOnce() -> R) -> R {
|
||||
let previous = QUANT.with(|q| q.replace(quant));
|
||||
let out = f();
|
||||
QUANT.with(|q| q.set(previous));
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Linear {
|
||||
Dense(candle_nn::Linear),
|
||||
Quantized {
|
||||
weight: QMatMul,
|
||||
bias: Option<Tensor>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Linear {
|
||||
/// A dense layer from existing weights (tied heads, identity
|
||||
/// projections). Never quantized: the weight is shared or synthetic.
|
||||
pub fn from_weights(weight: Tensor, bias: Option<Tensor>) -> Self {
|
||||
Self::Dense(candle_nn::Linear::new(weight, bias))
|
||||
}
|
||||
|
||||
fn build(weight: Tensor, bias: Option<Tensor>) -> Result<Self> {
|
||||
let quant = QUANT.with(Cell::get);
|
||||
let width = weight.dims().last().copied().unwrap_or(0);
|
||||
match quant {
|
||||
Some(dtype) if width > 0 && width.is_multiple_of(dtype.block_size()) => {
|
||||
let qt = crate::harness::tp::isq::quantize_parallel(&weight, dtype)
|
||||
.map_err(|e| candle_core::Error::Msg(format!("{e:#}")))?;
|
||||
Ok(Self::Quantized {
|
||||
weight: QMatMul::from_arc(Arc::new(qt))?,
|
||||
bias,
|
||||
})
|
||||
}
|
||||
_ => Ok(Self::Dense(candle_nn::Linear::new(weight, bias))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_quantized(&self) -> bool {
|
||||
matches!(self, Self::Quantized { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl Module for Linear {
|
||||
fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
match self {
|
||||
Self::Dense(l) => l.forward(x),
|
||||
Self::Quantized { weight, bias } => {
|
||||
// The quantized kernels read a contiguous (batch, rows, k)
|
||||
// or (rows, k) input.
|
||||
let y = match x.dims() {
|
||||
[a, b, c, k] => weight
|
||||
.forward(&x.contiguous()?.reshape((a * b, *c, *k))?)?
|
||||
.reshape((*a, *b, *c, ()))?,
|
||||
_ => weight.forward(&x.contiguous()?)?,
|
||||
};
|
||||
match bias {
|
||||
Some(b) => y.broadcast_add(b),
|
||||
None => Ok(y),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn linear_b(d1: usize, d2: usize, bias: bool, vb: VarBuilder) -> Result<Linear> {
|
||||
let weight = vb.get((d2, d1), "weight")?;
|
||||
let bias = if bias {
|
||||
Some(vb.get(d2, "bias")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Linear::build(weight, bias)
|
||||
}
|
||||
|
||||
pub fn linear_no_bias(d1: usize, d2: usize, vb: VarBuilder) -> Result<Linear> {
|
||||
linear_b(d1, d2, false, vb)
|
||||
}
|
||||
|
||||
pub fn linear(d1: usize, d2: usize, vb: VarBuilder) -> Result<Linear> {
|
||||
linear_b(d1, d2, true, vb)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use candle_core::{DType, Device};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn vb_with(weight: Tensor) -> VarBuilder<'static> {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("weight".to_string(), weight);
|
||||
VarBuilder::from_tensors(map, DType::F32, &Device::Cpu)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantization_is_scoped_and_skips_widths_that_do_not_tile() -> Result<()> {
|
||||
let w = Tensor::randn(0f32, 1.0, (64, 64), &Device::Cpu)?;
|
||||
let dense = linear_no_bias(64, 64, vb_with(w.clone()))?;
|
||||
assert!(!dense.is_quantized(), "no scope, no quantization");
|
||||
|
||||
let q = with_quant(Some(GgmlDType::Q8_0), || {
|
||||
linear_no_bias(64, 64, vb_with(w.clone()))
|
||||
})?;
|
||||
assert!(q.is_quantized());
|
||||
|
||||
let narrow = Tensor::randn(0f32, 1.0, (64, 6), &Device::Cpu)?;
|
||||
let kept = with_quant(Some(GgmlDType::Q8_0), || {
|
||||
linear_no_bias(6, 64, vb_with(narrow))
|
||||
})?;
|
||||
assert!(!kept.is_quantized(), "6 does not tile a 32-block");
|
||||
|
||||
let after = linear_no_bias(64, 64, vb_with(w))?;
|
||||
assert!(!after.is_quantized(), "the scope must end with the call");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q8_output_tracks_the_dense_layer() -> Result<()> {
|
||||
let w = Tensor::randn(0f32, 0.05, (128, 256), &Device::Cpu)?;
|
||||
let x = Tensor::randn(0f32, 1.0, (3, 256), &Device::Cpu)?;
|
||||
let dense = linear_no_bias(256, 128, vb_with(w.clone()))?.forward(&x)?;
|
||||
let q = with_quant(Some(GgmlDType::Q8_0), || {
|
||||
linear_no_bias(256, 128, vb_with(w))
|
||||
})?
|
||||
.forward(&x)?;
|
||||
let err = (dense.clone() - q)?.abs()?.max_all()?.to_scalar::<f32>()?;
|
||||
let scale = dense.abs()?.max_all()?.to_scalar::<f32>()?;
|
||||
assert!(err / scale < 0.02, "q8_0 relative error {}", err / scale);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,10 @@
|
||||
//! - [GitHub Repository](https://github.com/ACE-Step/ACE-Step-1.5)
|
||||
|
||||
pub mod condition;
|
||||
pub mod cot;
|
||||
pub mod dit;
|
||||
pub mod fsm;
|
||||
pub mod linear;
|
||||
pub mod lm;
|
||||
pub mod model;
|
||||
pub mod pipeline;
|
||||
|
||||
@@ -18,6 +18,17 @@ use anyhow::Result;
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use candle_nn::VarBuilder;
|
||||
|
||||
/// The DiT's task instruction for a text-to-music render (upstream's
|
||||
/// `TASK_INSTRUCTIONS["text2music"]`).
|
||||
pub const TEXT2MUSIC_INSTRUCTION: &str =
|
||||
"Fill the audio semantic mask based on the given conditions:";
|
||||
/// The instruction once audio codes condition the render. Upstream turns a
|
||||
/// text-to-music request into a cover when the planner supplies codes
|
||||
/// (`_resolve_generate_music_task`), and so does a reference cover.
|
||||
pub const COVER_INSTRUCTION: &str = "Generate audio semantic tokens based on the given conditions:";
|
||||
/// Upstream's `TASK_INSTRUCTIONS["repaint"]`.
|
||||
pub const REPAINT_INSTRUCTION: &str = "Repaint the mask area based on the given conditions:";
|
||||
|
||||
/// Metadata from LM CoT output, used to enrich the DiT text prompt.
|
||||
///
|
||||
/// When the LM generates chain-of-thought metadata (BPM, keyscale, etc.),
|
||||
@@ -94,6 +105,21 @@ pub struct AudioOutput {
|
||||
pub sample_rate: u32,
|
||||
/// The denoised latents `(1, T, acoustic_dim)` the VAE decoded.
|
||||
pub latents: Tensor,
|
||||
/// Wall time per phase. Each phase ends with a device synchronize, so
|
||||
/// queued GPU work is charged to the phase that queued it.
|
||||
pub timing: PhaseTiming,
|
||||
}
|
||||
|
||||
/// Wall time of one render's phases, in milliseconds.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct PhaseTiming {
|
||||
pub encode_ms: u64,
|
||||
pub denoise_ms: u64,
|
||||
pub decode_ms: u64,
|
||||
}
|
||||
|
||||
fn elapsed_ms(since: std::time::Instant) -> u64 {
|
||||
since.elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// High-level pipeline: text prompt → stereo audio.
|
||||
@@ -107,8 +133,16 @@ pub struct AceStepPipeline {
|
||||
silence_latent: Tensor,
|
||||
dit_config: AceStepConfig,
|
||||
vae_config: VaeConfig,
|
||||
/// The DiT's device and dtype. Conditioning, latents and the planner's
|
||||
/// hints live here.
|
||||
device: Device,
|
||||
dtype: DType,
|
||||
/// Where the text encoder and the VAE run, which may differ from the
|
||||
/// DiT's (#385). Only their small outputs cross between devices: the
|
||||
/// prompt's hidden states and the denoised latents.
|
||||
text_device: Device,
|
||||
vae_device: Device,
|
||||
vae_dtype: DType,
|
||||
}
|
||||
|
||||
struct LmState {
|
||||
@@ -119,7 +153,8 @@ struct LmState {
|
||||
impl AceStepPipeline {
|
||||
/// Load all components.
|
||||
///
|
||||
/// - `dit_vb` / `vae_vb` / `text_vb`: VarBuilders for each model
|
||||
/// - `dit_vb` / `vae_vb` / `text_vb`: VarBuilders for each model. Each
|
||||
/// component runs on its builder's device and dtype.
|
||||
/// - `text_config`: Qwen3 config (for the embedding model, NOT the LM)
|
||||
/// - `tokenizer_path`: `tokenizer.json` for the text encoder
|
||||
/// - `silence_latent`: `(1, T, acoustic_dim)` pre-loaded
|
||||
@@ -138,6 +173,9 @@ impl AceStepPipeline {
|
||||
) -> Result<Self> {
|
||||
let device = dit_vb.device().clone();
|
||||
let dtype = dit_vb.dtype();
|
||||
let text_device = text_vb.device().clone();
|
||||
let vae_device = vae_vb.device().clone();
|
||||
let vae_dtype = vae_vb.dtype();
|
||||
|
||||
let text_encoder = qwen3::Model::new(text_config, text_vb)?;
|
||||
let tokenizer = tokenizers::Tokenizer::from_file(tokenizer_path)
|
||||
@@ -162,6 +200,9 @@ impl AceStepPipeline {
|
||||
vae_config: vae_config.clone(),
|
||||
device,
|
||||
dtype,
|
||||
text_device,
|
||||
vae_device,
|
||||
vae_dtype,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,18 +219,27 @@ impl AceStepPipeline {
|
||||
lyrics: &str,
|
||||
params: &GenerationParams,
|
||||
) -> Result<AudioOutput> {
|
||||
let t = std::time::Instant::now();
|
||||
let instruction = if params.lm_hints_25hz.is_some() {
|
||||
COVER_INSTRUCTION
|
||||
} else {
|
||||
TEXT2MUSIC_INSTRUCTION
|
||||
};
|
||||
let (text_hs, text_mask, lyric_hs, lyric_mask) = self.encode_text(
|
||||
instruction,
|
||||
prompt,
|
||||
lyrics,
|
||||
params.duration_secs,
|
||||
params.lm_metadata.as_ref(),
|
||||
params.language.as_deref(),
|
||||
)?;
|
||||
self.device.synchronize()?;
|
||||
let encode_ms = elapsed_ms(t);
|
||||
|
||||
let latent_frames = self.latent_frames(params.duration_secs);
|
||||
let timbre_frames = 750.min(self.silence_latent.dim(1)?);
|
||||
|
||||
self.generate_inner(
|
||||
let mut out = self.generate_inner(
|
||||
&text_hs,
|
||||
&text_mask,
|
||||
&lyric_hs,
|
||||
@@ -198,7 +248,9 @@ impl AceStepPipeline {
|
||||
&self.silence_latent.narrow(1, 0, timbre_frames)?,
|
||||
params,
|
||||
latent_frames,
|
||||
)
|
||||
)?;
|
||||
out.timing.encode_ms = encode_ms;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generate a cover conditioned on reference audio `(1, 2, T)` at 48kHz.
|
||||
@@ -221,6 +273,7 @@ impl AceStepPipeline {
|
||||
params: &GenerationParams,
|
||||
) -> Result<AudioOutput> {
|
||||
let (text_hs, text_mask, lyric_hs, lyric_mask) = self.encode_text(
|
||||
COVER_INSTRUCTION,
|
||||
prompt,
|
||||
lyrics,
|
||||
params.duration_secs,
|
||||
@@ -232,7 +285,11 @@ impl AceStepPipeline {
|
||||
.vae_encoder
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("VAE encoder not loaded (pass load_encoder=true)"))?;
|
||||
let ref_latent = vae.encode_mean(reference_audio)?.transpose(1, 2)?;
|
||||
let ref_latent = vae
|
||||
.encode_mean(reference_audio)?
|
||||
.transpose(1, 2)?
|
||||
.to_device(&self.device)?
|
||||
.to_dtype(self.dtype)?;
|
||||
|
||||
let latent_frames = self.latent_frames(params.duration_secs);
|
||||
let ref_len = ref_latent.dim(1)?;
|
||||
@@ -246,7 +303,11 @@ impl AceStepPipeline {
|
||||
|
||||
// Timbre: use separate timbre audio if provided, otherwise reference audio
|
||||
let refer_audio_packed = if let Some(timbre) = timbre_audio {
|
||||
let timbre_latent = vae.encode_mean(timbre)?.transpose(1, 2)?;
|
||||
let timbre_latent = vae
|
||||
.encode_mean(timbre)?
|
||||
.transpose(1, 2)?
|
||||
.to_device(&self.device)?
|
||||
.to_dtype(self.dtype)?;
|
||||
let timbre_len = timbre_latent.dim(1)?;
|
||||
let timbre_frames = 750.min(timbre_len);
|
||||
timbre_latent.narrow(1, 0, timbre_frames)?
|
||||
@@ -289,6 +350,7 @@ impl AceStepPipeline {
|
||||
params: &GenerationParams,
|
||||
) -> Result<AudioOutput> {
|
||||
let (text_hs, text_mask, lyric_hs, lyric_mask) = self.encode_text(
|
||||
REPAINT_INSTRUCTION,
|
||||
prompt,
|
||||
lyrics,
|
||||
params.duration_secs,
|
||||
@@ -300,7 +362,11 @@ impl AceStepPipeline {
|
||||
.vae_encoder
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("VAE encoder not loaded (pass load_encoder=true)"))?;
|
||||
let src_latent = vae.encode_mean(source_audio)?.transpose(1, 2)?; // (1, T_lat, D)
|
||||
let src_latent = vae
|
||||
.encode_mean(source_audio)?
|
||||
.transpose(1, 2)?
|
||||
.to_device(&self.device)?
|
||||
.to_dtype(self.dtype)?; // (1, T_lat, D)
|
||||
|
||||
let latent_frames = self.latent_frames(params.duration_secs);
|
||||
let hop = self.vae_config.hop_length() as f64;
|
||||
@@ -394,6 +460,9 @@ impl AceStepPipeline {
|
||||
lm_vb: VarBuilder,
|
||||
lm_tokenizer_path: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
// The planner runs on its builder's device; only its integer codes
|
||||
// reach the DiT.
|
||||
let lm_device = lm_vb.device().clone();
|
||||
let lm_model = qwen3::ModelForCausalLM::new(lm_config, lm_vb)?;
|
||||
let lm_tokenizer = tokenizers::Tokenizer::from_file(lm_tokenizer_path)
|
||||
.map_err(|e| anyhow::anyhow!("LM tokenizer: {e}"))?;
|
||||
@@ -403,7 +472,7 @@ impl AceStepPipeline {
|
||||
.map_err(|e| candle_core::Error::Msg(format!("encode {s:?}: {e}")))?;
|
||||
Ok(enc.get_ids().to_vec())
|
||||
})?;
|
||||
let pipeline = LmPipeline::new(lm_model, token_ids, self.device.clone());
|
||||
let pipeline = LmPipeline::new(lm_model, token_ids, lm_device);
|
||||
self.lm = Some(LmState {
|
||||
pipeline,
|
||||
tokenizer: lm_tokenizer,
|
||||
@@ -427,15 +496,9 @@ impl AceStepPipeline {
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("LM not loaded — call load_lm() first"))?;
|
||||
|
||||
let prompt_text = LmPipeline::format_prompt(prompt, lyrics);
|
||||
let prompt_enc = lm
|
||||
.tokenizer
|
||||
.encode(prompt_text.as_str(), false)
|
||||
.map_err(|e| anyhow::anyhow!("LM encode: {e}"))?;
|
||||
|
||||
let decode_fn = |t: u32| -> candle_core::Result<String> {
|
||||
let decode_fn = |ids: &[u32]| -> candle_core::Result<String> {
|
||||
lm.tokenizer
|
||||
.decode(&[t], false)
|
||||
.decode(ids, false)
|
||||
.map_err(|e| candle_core::Error::Msg(format!("{e}")))
|
||||
};
|
||||
let encode_fn = |s: &str| -> candle_core::Result<Vec<u32>> {
|
||||
@@ -447,7 +510,8 @@ impl AceStepPipeline {
|
||||
};
|
||||
|
||||
let output = lm.pipeline.generate(
|
||||
prompt_enc.get_ids(),
|
||||
prompt,
|
||||
lyrics,
|
||||
duration_secs,
|
||||
lm_config,
|
||||
&decode_fn,
|
||||
@@ -479,6 +543,7 @@ impl AceStepPipeline {
|
||||
/// lyrics under their language. `language` defaults to `"unknown"`,
|
||||
/// upstream's own default when the caller names none.
|
||||
pub fn format_prompts(
|
||||
instruction: &str,
|
||||
prompt: &str,
|
||||
lyrics: &str,
|
||||
duration_secs: f64,
|
||||
@@ -496,7 +561,7 @@ impl AceStepPipeline {
|
||||
.and_then(|m| m.keyscale.as_deref())
|
||||
.unwrap_or("N/A");
|
||||
let caption_text = format!(
|
||||
"# Instruction\nFill the audio semantic mask based on the given conditions:\n\n\
|
||||
"# Instruction\n{instruction}\n\n\
|
||||
# Caption\n{caption}\n\n# Metas\n\
|
||||
- bpm: {bpm}\n- timesignature: {timesig}\n- keyscale: {keyscale}\n- duration: {} seconds\n\
|
||||
<|endoftext|>\n",
|
||||
@@ -534,28 +599,44 @@ impl AceStepPipeline {
|
||||
|
||||
fn encode_text(
|
||||
&mut self,
|
||||
instruction: &str,
|
||||
prompt: &str,
|
||||
lyrics: &str,
|
||||
duration_secs: f64,
|
||||
metadata: Option<&LmMetadata>,
|
||||
language: Option<&str>,
|
||||
) -> Result<(Tensor, Tensor, Tensor, Tensor)> {
|
||||
let (caption_text, lyric_text) =
|
||||
Self::format_prompts(prompt, lyrics, duration_secs, metadata, language);
|
||||
let (caption_text, lyric_text) = Self::format_prompts(
|
||||
instruction,
|
||||
prompt,
|
||||
lyrics,
|
||||
duration_secs,
|
||||
metadata,
|
||||
language,
|
||||
);
|
||||
let encoding = self
|
||||
.tokenizer
|
||||
.encode(caption_text.as_str(), true)
|
||||
.map_err(|e| anyhow::anyhow!("tokenization: {e}"))?;
|
||||
let prompt_tokens = Tensor::new(encoding.get_ids(), &self.device)?.unsqueeze(0)?;
|
||||
let text_hidden_states = self.text_encoder.forward(&prompt_tokens, 0)?;
|
||||
let prompt_tokens = Tensor::new(encoding.get_ids(), &self.text_device)?.unsqueeze(0)?;
|
||||
let text_hidden_states = self
|
||||
.text_encoder
|
||||
.forward(&prompt_tokens, 0)?
|
||||
.to_device(&self.device)?
|
||||
.to_dtype(self.dtype)?;
|
||||
self.text_encoder.clear_kv_cache();
|
||||
|
||||
let lyric_encoding = self
|
||||
.tokenizer
|
||||
.encode(lyric_text.as_str(), true)
|
||||
.map_err(|e| anyhow::anyhow!("lyric tokenization: {e}"))?;
|
||||
let lyric_tokens = Tensor::new(lyric_encoding.get_ids(), &self.device)?.unsqueeze(0)?;
|
||||
let lyric_hidden_states = self.text_encoder.embed_tokens(&lyric_tokens)?;
|
||||
let lyric_tokens =
|
||||
Tensor::new(lyric_encoding.get_ids(), &self.text_device)?.unsqueeze(0)?;
|
||||
let lyric_hidden_states = self
|
||||
.text_encoder
|
||||
.embed_tokens(&lyric_tokens)?
|
||||
.to_device(&self.device)?
|
||||
.to_dtype(self.dtype)?;
|
||||
|
||||
let text_mask = Tensor::new(encoding.get_attention_mask(), &self.device)?
|
||||
.unsqueeze(0)?
|
||||
@@ -638,6 +719,7 @@ impl AceStepPipeline {
|
||||
.num_steps
|
||||
.unwrap_or(if params.is_turbo { 8 } else { 50 });
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let xt = self.model.generate_latents(
|
||||
&condition,
|
||||
None,
|
||||
@@ -657,17 +739,32 @@ impl AceStepPipeline {
|
||||
self.dtype,
|
||||
)?;
|
||||
|
||||
self.device.synchronize()?;
|
||||
let denoise_ms = elapsed_ms(t);
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let latents = xt
|
||||
.transpose(1, 2)?
|
||||
.to_device(&self.vae_device)?
|
||||
.to_dtype(self.vae_dtype)?;
|
||||
let audio = self.vae_decoder.tiled_decode(
|
||||
&xt.transpose(1, 2)?,
|
||||
&latents,
|
||||
params.vae_chunk_frames,
|
||||
params.vae_chunk_overlap,
|
||||
)?;
|
||||
let audio = audio.squeeze(0)?;
|
||||
self.vae_device.synchronize()?;
|
||||
let decode_ms = elapsed_ms(t);
|
||||
|
||||
Ok(AudioOutput {
|
||||
audio,
|
||||
sample_rate: self.vae_config.sampling_rate as u32,
|
||||
latents: xt,
|
||||
timing: PhaseTiming {
|
||||
encode_ms: 0,
|
||||
denoise_ms,
|
||||
decode_ms,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use super::linear::{Linear, linear_b, linear_no_bias};
|
||||
use candle_core::{DType, Device, Module, Result, Tensor};
|
||||
use candle_nn::{Activation, VarBuilder, kv_cache::ConcatKvCache};
|
||||
use candle_transformers::{
|
||||
models::with_tracing::{Linear, RmsNorm, linear_b, linear_no_bias},
|
||||
utils::repeat_kv,
|
||||
};
|
||||
use candle_transformers::{models::with_tracing::RmsNorm, utils::repeat_kv};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
//! - `AttentionPooler` / `AudioTokenizer`: structurally correct, minor
|
||||
//! float precision differences propagate through FSQ rounding.
|
||||
|
||||
use super::linear::{Linear, linear, linear_no_bias};
|
||||
use candle_core::{D, DType, Module, Result, Tensor};
|
||||
use candle_nn::{Activation, VarBuilder};
|
||||
use candle_transformers::models::with_tracing::{Linear, RmsNorm, linear, linear_no_bias};
|
||||
use candle_transformers::models::with_tracing::RmsNorm;
|
||||
use candle_transformers::utils::repeat_kv;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -255,23 +256,18 @@ impl AttentionPooler {
|
||||
|
||||
/// Residual Finite Scalar Quantization.
|
||||
///
|
||||
/// Matches `vector_quantize_pytorch.ResidualFSQ` with a single top-level
|
||||
/// `project_in` (dim → codebook_dim) and `project_out` (codebook_dim → dim).
|
||||
/// Each FSQ layer independently quantizes each codebook dimension to its
|
||||
/// corresponding number of levels using `tanh` bounding and rounding.
|
||||
/// Matches `vector_quantize_pytorch.ResidualFSQ` 1.27 as ACE-Step builds it
|
||||
/// (one quantizer, the library defaults): a top-level `project_in`
|
||||
/// (dim → codebook_dim) and `project_out` (codebook_dim → dim), a soft clamp
|
||||
/// of the projected input to `±(1 + 1/(L-1))`, and an FSQ layer constructed
|
||||
/// with `preserve_symmetry=True` and a hard-clamped bound. Symmetric FSQ puts
|
||||
/// level `k` of `L` at `2k/(L-1) - 1`, spanning `[-1, 1]`; the asymmetric
|
||||
/// formula (`(k - L/2) / (L/2)`) is not what the checkpoint was trained with.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResidualFSQ {
|
||||
project_in: Linear,
|
||||
project_out: Linear,
|
||||
/// Precomputed: (levels - 1) * (1 + eps) / 2 for each codebook dim
|
||||
half_l: Vec<f32>,
|
||||
/// Precomputed: levels // 2
|
||||
half_width: Vec<f32>,
|
||||
/// Precomputed: 0.5 for even levels, 0.0 for odd
|
||||
offset: Vec<f32>,
|
||||
/// Precomputed: atanh(offset / half_l) shift
|
||||
shift: Vec<f32>,
|
||||
/// Precomputed basis for index calculation: [1, L0, L0*L1, ...]
|
||||
/// Basis for the flat index: `[1, L0, L0*L1, ...]`.
|
||||
basis: Vec<i64>,
|
||||
/// Quantization levels per codebook dimension (e.g. [8, 8, 8, 5, 5, 5]).
|
||||
levels: Vec<usize>,
|
||||
@@ -290,22 +286,6 @@ impl ResidualFSQ {
|
||||
let project_in = linear(dim, codebook_dim, vb.pp("project_in"))?;
|
||||
let project_out = linear(codebook_dim, dim, vb.pp("project_out"))?;
|
||||
|
||||
let eps = 1e-3f32;
|
||||
let half_l: Vec<f32> = levels
|
||||
.iter()
|
||||
.map(|&l| (l as f32 - 1.0) * (1.0 + eps) / 2.0)
|
||||
.collect();
|
||||
let half_width: Vec<f32> = levels.iter().map(|&l| (l / 2) as f32).collect();
|
||||
let offset: Vec<f32> = levels
|
||||
.iter()
|
||||
.map(|&l| if l % 2 == 0 { 0.5 } else { 0.0 })
|
||||
.collect();
|
||||
let shift: Vec<f32> = half_l
|
||||
.iter()
|
||||
.zip(&offset)
|
||||
.map(|(&hl, &o)| (o / hl).atanh())
|
||||
.collect();
|
||||
|
||||
let mut basis = vec![1i64];
|
||||
for i in 0..levels.len() - 1 {
|
||||
basis.push(basis[i] * levels[i] as i64);
|
||||
@@ -314,76 +294,54 @@ impl ResidualFSQ {
|
||||
Ok(Self {
|
||||
project_in,
|
||||
project_out,
|
||||
half_l,
|
||||
half_width,
|
||||
offset,
|
||||
shift,
|
||||
basis,
|
||||
levels: levels.to_vec(),
|
||||
model_dtype,
|
||||
})
|
||||
}
|
||||
|
||||
/// FSQ bound + round: `round(tanh(z + shift) * half_l - offset) / half_width`
|
||||
fn fsq_quantize(&self, z: &Tensor) -> Result<Tensor> {
|
||||
let dev = z.device();
|
||||
let half_l = Tensor::new(&self.half_l[..], dev)?
|
||||
.unsqueeze(0)?
|
||||
.unsqueeze(0)?;
|
||||
let offset = Tensor::new(&self.offset[..], dev)?
|
||||
.unsqueeze(0)?
|
||||
.unsqueeze(0)?;
|
||||
let shift = Tensor::new(&self.shift[..], dev)?
|
||||
.unsqueeze(0)?
|
||||
.unsqueeze(0)?;
|
||||
let half_width = Tensor::new(&self.half_width[..], dev)?
|
||||
.unsqueeze(0)?
|
||||
.unsqueeze(0)?;
|
||||
/// A per-codebook-dimension constant as a `(1, 1, d)` f32 tensor.
|
||||
fn per_dim(&self, dev: &candle_core::Device, f: impl Fn(f32) -> f32) -> Result<Tensor> {
|
||||
let v: Vec<f32> = self.levels.iter().map(|&l| f(l as f32)).collect();
|
||||
Tensor::new(&v[..], dev)?.unsqueeze(0)?.unsqueeze(0)
|
||||
}
|
||||
|
||||
// bound: tanh(z + shift) * half_l - offset, then round, then / half_width
|
||||
let bounded = z
|
||||
.broadcast_add(&shift)?
|
||||
.tanh()?
|
||||
.broadcast_mul(&half_l)?
|
||||
.broadcast_sub(&offset)?;
|
||||
let rounded = bounded.round()?;
|
||||
rounded.broadcast_div(&half_width)
|
||||
/// Soft clamp, then the symmetric hard-clamped bound:
|
||||
/// `2/(L-1) * floor((L-1) * (clamp(z, -1, 1) + 1) / 2 + 0.5) - 1`.
|
||||
/// Returns `(codes, level_indices)`.
|
||||
fn fsq_quantize(&self, z: &Tensor) -> Result<(Tensor, Tensor)> {
|
||||
let dev = z.device();
|
||||
let z = z.to_dtype(DType::F32)?;
|
||||
let clamp = self.per_dim(dev, |l| 1.0 + 1.0 / (l - 1.0))?;
|
||||
let z = z.broadcast_div(&clamp)?.tanh()?.broadcast_mul(&clamp)?;
|
||||
let lm1 = self.per_dim(dev, |l| l - 1.0)?;
|
||||
let levels = z
|
||||
.clamp(-1f32, 1f32)?
|
||||
.affine(0.5, 0.5)?
|
||||
.broadcast_mul(&lm1)?
|
||||
.affine(1.0, 0.5)?
|
||||
.floor()?;
|
||||
let codes = levels.broadcast_div(&lm1)?.affine(2.0, -1.0)?;
|
||||
Ok((codes, levels))
|
||||
}
|
||||
|
||||
/// Quantize continuous representations into discrete tokens.
|
||||
/// Input: `(B, N, dim)`. Returns `(quantized_out, indices)` where
|
||||
/// `quantized_out` has the same shape as input and `indices` is `(B, N, num_quantizers)`.
|
||||
pub fn forward(&self, x: &Tensor) -> Result<(Tensor, Tensor)> {
|
||||
// project_in: (B, N, dim) -> (B, N, codebook_dim)
|
||||
let projected = x.apply(&self.project_in)?;
|
||||
|
||||
// FSQ quantize
|
||||
let codes = self.fsq_quantize(&projected)?; // (B, N, codebook_dim)
|
||||
|
||||
// Compute indices from codes: scale_and_shift then dot with basis
|
||||
let dev = x.device();
|
||||
let half_width = Tensor::new(&self.half_width[..], dev)?
|
||||
let (codes, levels) = self.fsq_quantize(&projected)?;
|
||||
let basis: Vec<f32> = self.basis.iter().map(|&b| b as f32).collect();
|
||||
let basis = Tensor::new(&basis[..], x.device())?
|
||||
.unsqueeze(0)?
|
||||
.unsqueeze(0)?;
|
||||
let offset = Tensor::new(&self.offset[..], dev)?
|
||||
.unsqueeze(0)?
|
||||
.unsqueeze(0)?;
|
||||
let basis = Tensor::new(&self.basis[..], dev)?
|
||||
.unsqueeze(0)?
|
||||
.unsqueeze(0)?;
|
||||
// scale_and_shift: codes * half_width + offset
|
||||
let shifted = codes.broadcast_mul(&half_width)?.broadcast_add(&offset)?;
|
||||
let indices = shifted
|
||||
.to_dtype(DType::F32)?
|
||||
.broadcast_mul(&basis.to_dtype(DType::F32)?)?
|
||||
let indices = levels
|
||||
.broadcast_mul(&basis)?
|
||||
.sum(2)?
|
||||
.round()?
|
||||
.to_dtype(DType::I64)?
|
||||
.unsqueeze(2)?; // (B, N, 1)
|
||||
|
||||
// project_out: (B, N, codebook_dim) -> (B, N, dim)
|
||||
let quantized_out = codes.apply(&self.project_out)?;
|
||||
|
||||
let quantized_out = codes.to_dtype(self.model_dtype)?.apply(&self.project_out)?;
|
||||
Ok((quantized_out, indices))
|
||||
}
|
||||
|
||||
@@ -393,42 +351,30 @@ impl ResidualFSQ {
|
||||
codes.apply(&self.project_out)
|
||||
}
|
||||
|
||||
/// Reconstruct from packed indices.
|
||||
/// Input: `(B, N, 1)` → Output: `(B, N, dim)`.
|
||||
///
|
||||
/// Inverse of the forward path: decompose flat index into per-level values
|
||||
/// via basis, then invert scale_and_shift, then `project_out`.
|
||||
pub fn get_output_from_indices(&self, indices: &Tensor) -> Result<Tensor> {
|
||||
/// The codes for packed indices: level `k = (index / basis) % L` of each
|
||||
/// dimension, at `2k/(L-1) - 1`. Input `(B, N, 1)` → `(B, N, codebook_dim)`.
|
||||
pub fn codes_from_indices(&self, indices: &Tensor) -> Result<Tensor> {
|
||||
let dev = indices.device();
|
||||
let indices = indices.squeeze(D::Minus1)?.to_dtype(DType::F32)?;
|
||||
let codebook_dim = self.levels.len();
|
||||
|
||||
// Decompose: level_val[i] = floor(index / basis[i]) % levels[i]
|
||||
// F32 has enough precision for indices up to 64000 (max codebook size).
|
||||
let mut code_dims = Vec::with_capacity(codebook_dim);
|
||||
for i in 0..codebook_dim {
|
||||
let b = self.basis[i] as f32;
|
||||
let l = self.levels[i] as f32;
|
||||
let dim_val = indices.affine(1.0 / b as f64, 0.0)?.floor()?;
|
||||
// modulo: dim_val - floor(dim_val / l) * l
|
||||
let dim_val = (&dim_val
|
||||
- dim_val
|
||||
.affine(1.0 / l as f64, 0.0)?
|
||||
.floor()?
|
||||
.affine(l as f64, 0.0)?)?;
|
||||
code_dims.push(dim_val);
|
||||
// F32 holds every index exactly: the codebook has 64000 entries.
|
||||
let mut levels = Vec::with_capacity(self.levels.len());
|
||||
for (i, &l) in self.levels.iter().enumerate() {
|
||||
let l = l as f64;
|
||||
let v = indices.affine(1.0 / self.basis[i] as f64, 0.0)?.floor()?;
|
||||
let v = (&v - v.affine(1.0 / l, 0.0)?.floor()?.affine(l, 0.0)?)?;
|
||||
levels.push(v);
|
||||
}
|
||||
let shifted = Tensor::stack(&code_dims, D::Minus1)?;
|
||||
let levels = Tensor::stack(&levels, D::Minus1)?;
|
||||
let lm1 = self.per_dim(dev, |l| l - 1.0)?;
|
||||
levels.broadcast_div(&lm1)?.affine(2.0, -1.0)
|
||||
}
|
||||
|
||||
// Inverse scale_and_shift (Python convention):
|
||||
// forward: shifted = codes * half_width + half_width → shifted ∈ [0, levels-1]
|
||||
// inverse: codes = (shifted - half_width) / half_width
|
||||
let hw = Tensor::new(&self.half_width[..], dev)?
|
||||
.unsqueeze(0)?
|
||||
.unsqueeze(0)?;
|
||||
let codes = shifted.broadcast_sub(&hw)?.broadcast_div(&hw)?;
|
||||
|
||||
codes.to_dtype(self.model_dtype)?.apply(&self.project_out)
|
||||
/// Reconstruct from packed indices.
|
||||
/// Input: `(B, N, 1)` → Output: `(B, N, dim)`.
|
||||
pub fn get_output_from_indices(&self, indices: &Tensor) -> Result<Tensor> {
|
||||
self.codes_from_indices(indices)?
|
||||
.to_dtype(self.model_dtype)?
|
||||
.apply(&self.project_out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,104 +383,93 @@ mod tests {
|
||||
use super::*;
|
||||
use candle_core::Device;
|
||||
|
||||
/// Build a minimal ResidualFSQ with identity projections for testing.
|
||||
/// A ResidualFSQ with identity projections, as the library builds one
|
||||
/// when `dim` equals the codebook dimension.
|
||||
fn test_fsq() -> Result<ResidualFSQ> {
|
||||
let dev = &Device::Cpu;
|
||||
let levels = vec![8, 8, 8, 5, 5, 5];
|
||||
let codebook_dim = levels.len();
|
||||
|
||||
// Identity-like project_in/project_out (codebook_dim x codebook_dim)
|
||||
let eye = Tensor::eye(codebook_dim, DType::F32, dev)?;
|
||||
let zeros = Tensor::zeros(codebook_dim, DType::F32, dev)?;
|
||||
|
||||
let eps = 1e-3f32;
|
||||
let half_l: Vec<f32> = levels
|
||||
.iter()
|
||||
.map(|&l| (l as f32 - 1.0) * (1.0 + eps) / 2.0)
|
||||
.collect();
|
||||
let half_width: Vec<f32> = levels.iter().map(|&l| (l / 2) as f32).collect();
|
||||
let offset: Vec<f32> = levels
|
||||
.iter()
|
||||
.map(|&l| if l % 2 == 0 { 0.5 } else { 0.0 })
|
||||
.collect();
|
||||
let shift: Vec<f32> = half_l
|
||||
.iter()
|
||||
.zip(&offset)
|
||||
.map(|(&hl, &o)| (o / hl).atanh())
|
||||
.collect();
|
||||
let mut basis = vec![1i64];
|
||||
for i in 0..levels.len() - 1 {
|
||||
basis.push(basis[i] * levels[i] as i64);
|
||||
}
|
||||
|
||||
Ok(ResidualFSQ {
|
||||
project_in: Linear::from_weights(eye.clone(), Some(zeros.clone())),
|
||||
project_out: Linear::from_weights(eye, Some(zeros)),
|
||||
half_l,
|
||||
half_width,
|
||||
offset,
|
||||
shift,
|
||||
basis,
|
||||
levels,
|
||||
model_dtype: DType::F32,
|
||||
})
|
||||
}
|
||||
|
||||
fn close(got: &[f32], want: &[f32]) {
|
||||
for (i, (g, w)) in got.iter().zip(want).enumerate() {
|
||||
assert!((g - w).abs() < 1e-5, "dim {i}: got {g}, want {w}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Recorded from vector_quantize_pytorch 1.27.20:
|
||||
/// `FSQ([8,8,8,5,5,5], preserve_symmetry=True).indices_to_codes(...)`.
|
||||
#[test]
|
||||
fn test_get_output_from_indices_roundtrip() -> Result<()> {
|
||||
// Test that manually-packed Python-convention indices correctly
|
||||
// reconstruct codes via get_output_from_indices.
|
||||
fn indices_decode_to_symmetric_codes() -> Result<()> {
|
||||
let fsq = test_fsq()?;
|
||||
let dev = &Device::Cpu;
|
||||
// levels = [8,8,8,5,5,5], half_width = [4,4,4,2,2,2]
|
||||
// basis = [1, 8, 64, 512, 2560, 12800]
|
||||
//
|
||||
// Choose codes: [0.25, -0.5, 0.75, 0.5, -1.0, 0.0]
|
||||
// Python scale_and_shift: shifted = codes * half_width + half_width
|
||||
// = [0.25*4+4, -0.5*4+4, 0.75*4+4, 0.5*2+2, -1.0*2+2, 0.0*2+2]
|
||||
// = [5, 2, 7, 3, 0, 2]
|
||||
// index = 5*1 + 2*8 + 7*64 + 3*512 + 0*2560 + 2*12800
|
||||
// = 5 + 16 + 448 + 1536 + 0 + 25600 = 27605
|
||||
let expected_codes: Vec<f32> = vec![0.25, -0.5, 0.75, 0.5, -1.0, 0.0];
|
||||
let index: i64 = 27605;
|
||||
|
||||
let indices = Tensor::new(&[[index]], dev)?.unsqueeze(2)?; // (1, 1, 1)
|
||||
let reconstructed = fsq.get_output_from_indices(&indices)?;
|
||||
let vals = reconstructed.squeeze(0)?.squeeze(0)?.to_vec1::<f32>()?;
|
||||
|
||||
for (i, (&got, &want)) in vals.iter().zip(expected_codes.iter()).enumerate() {
|
||||
assert!((got - want).abs() < 1e-5, "dim {i}: got={got}, want={want}");
|
||||
let cases: [(i64, [f32; 6]); 4] = [
|
||||
(0, [-1.0; 6]),
|
||||
(27605, [3.0 / 7.0, -3.0 / 7.0, 1.0, 0.5, -1.0, 0.0]),
|
||||
(32036, [1.0 / 7.0, 1.0 / 7.0, 1.0 / 7.0, 0.0, 0.0, 0.0]),
|
||||
(63999, [1.0; 6]),
|
||||
];
|
||||
for (index, want) in cases {
|
||||
let indices = Tensor::new(&[[index]], &Device::Cpu)?.unsqueeze(2)?;
|
||||
let got = fsq
|
||||
.get_output_from_indices(&indices)?
|
||||
.flatten_all()?
|
||||
.to_vec1::<f32>()?;
|
||||
close(&got, &want);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recorded from vector_quantize_pytorch 1.27.20:
|
||||
/// `ResidualFSQ(dim=6, levels=[8,8,8,5,5,5], num_quantizers=1)(x)`.
|
||||
#[test]
|
||||
fn test_get_output_from_indices_known_values() -> Result<()> {
|
||||
fn forward_matches_the_library() -> Result<()> {
|
||||
let fsq = test_fsq()?;
|
||||
let dev = &Device::Cpu;
|
||||
|
||||
// Index 0 should decompose to level_vals = [0, 0, 0, 0, 0, 0]
|
||||
// Python convention: codes = (level_vals - half_width) / half_width
|
||||
// For levels [8,8,8,5,5,5]: half_width=[4,4,4,2,2,2]
|
||||
// codes = [(0-4)/4, (0-4)/4, (0-4)/4, (0-2)/2, (0-2)/2, (0-2)/2]
|
||||
// = [-1, -1, -1, -1, -1, -1]
|
||||
let indices = Tensor::new(&[[0i64]], dev)?.unsqueeze(2)?; // (1, 1, 1)
|
||||
let result = fsq.get_output_from_indices(&indices)?;
|
||||
let vals = result.squeeze(0)?.squeeze(0)?.to_vec1::<f32>()?;
|
||||
assert!((vals[0] - (-1.0)).abs() < 1e-5, "val[0]={}", vals[0]);
|
||||
assert!((vals[3] - (-1.0)).abs() < 1e-5, "val[3]={}", vals[3]);
|
||||
|
||||
// Index that corresponds to center: half_width values for each level
|
||||
// level_vals = [4, 4, 4, 2, 2, 2]
|
||||
// packed index = 4*1 + 4*8 + 4*64 + 2*512 + 2*2560 + 2*12800
|
||||
// = 4 + 32 + 256 + 1024 + 5120 + 25600 = 32036
|
||||
// codes = [0, 0, 0, 0, 0, 0]
|
||||
let indices = Tensor::new(&[[32036i64]], dev)?.unsqueeze(2)?;
|
||||
let result = fsq.get_output_from_indices(&indices)?;
|
||||
let vals = result.squeeze(0)?.squeeze(0)?.to_vec1::<f32>()?;
|
||||
for (i, &v) in vals.iter().enumerate() {
|
||||
assert!(v.abs() < 1e-5, "center val[{i}]={v}, expected 0");
|
||||
}
|
||||
let x = Tensor::new(
|
||||
&[[
|
||||
[0.3f32, -0.9, 2.0, 0.1, -0.05, 0.6],
|
||||
[-3.0, 0.0, 0.49, -0.6, 0.9, -0.2],
|
||||
]],
|
||||
&Device::Cpu,
|
||||
)?;
|
||||
let (q, idx) = fsq.forward(&x)?;
|
||||
let q = q.flatten_all()?.to_vec1::<f32>()?;
|
||||
close(
|
||||
&q,
|
||||
&[
|
||||
3.0 / 7.0,
|
||||
-5.0 / 7.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.5,
|
||||
-1.0,
|
||||
1.0 / 7.0,
|
||||
3.0 / 7.0,
|
||||
-0.5,
|
||||
1.0,
|
||||
0.0,
|
||||
],
|
||||
);
|
||||
assert_eq!(idx.flatten_all()?.to_vec1::<i64>()?, vec![45005, 36704]);
|
||||
// And the indices decode back to the same codes.
|
||||
let back = fsq
|
||||
.get_output_from_indices(&idx)?
|
||||
.flatten_all()?
|
||||
.to_vec1::<f32>()?;
|
||||
close(&back, &q);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,10 @@ impl Module for OobleckEncoderBlock {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OobleckDecoderBlock {
|
||||
snake1: Snake1d,
|
||||
/// Built unpadded; the padding is trimmed from its output instead. See
|
||||
/// [`upsample`].
|
||||
conv_t1: ConvTranspose1d,
|
||||
trim: usize,
|
||||
res_unit1: OobleckResidualUnit,
|
||||
res_unit2: OobleckResidualUnit,
|
||||
res_unit3: OobleckResidualUnit,
|
||||
@@ -182,7 +185,7 @@ impl OobleckDecoderBlock {
|
||||
let snake1 = Snake1d::new(input_dim, vb.pp("snake1"))?;
|
||||
let cfg = ConvTranspose1dConfig {
|
||||
stride,
|
||||
padding: stride.div_ceil(2),
|
||||
padding: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let conv_t1 = encodec::conv_transpose1d_weight_norm(
|
||||
@@ -199,6 +202,7 @@ impl OobleckDecoderBlock {
|
||||
Ok(Self {
|
||||
snake1,
|
||||
conv_t1,
|
||||
trim: stride.div_ceil(2),
|
||||
res_unit1,
|
||||
res_unit2,
|
||||
res_unit3,
|
||||
@@ -208,14 +212,25 @@ impl OobleckDecoderBlock {
|
||||
|
||||
impl Module for OobleckDecoderBlock {
|
||||
fn forward(&self, xs: &Tensor) -> Result<Tensor> {
|
||||
xs.apply(&self.snake1)?
|
||||
.apply(&self.conv_t1)?
|
||||
upsample(&xs.apply(&self.snake1)?, &self.conv_t1, self.trim)?
|
||||
.apply(&self.res_unit1)?
|
||||
.apply(&self.res_unit2)?
|
||||
.apply(&self.res_unit3)
|
||||
}
|
||||
}
|
||||
|
||||
/// A transposed convolution with `padding = trim`, computed as the
|
||||
/// unpadded convolution with `trim` samples cut from each end, which is
|
||||
/// the same result. candle's CUDA backend only takes its matmul (col2im)
|
||||
/// path for unpadded transposed convolutions; with padding it falls back
|
||||
/// to a direct kernel that made the VAE decode some 30x slower than the
|
||||
/// denoise on an RTX 3060.
|
||||
fn upsample(xs: &Tensor, conv: &ConvTranspose1d, trim: usize) -> Result<Tensor> {
|
||||
let ys = xs.apply(conv)?;
|
||||
let len = ys.dim(2)?;
|
||||
ys.narrow(2, trim, len - 2 * trim)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OobleckEncoder
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -718,4 +733,39 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpadded_then_trimmed_matches_the_padded_transposed_conv() -> Result<()> {
|
||||
let dev = Device::Cpu;
|
||||
for stride in [2usize, 4, 5, 8, 10] {
|
||||
let k = 2 * stride;
|
||||
let pad = stride.div_ceil(2);
|
||||
let x = Tensor::randn(0f32, 1.0, (1, 6, 9), &dev)?;
|
||||
let w = Tensor::randn(0f32, 1.0, (6, 4, k), &dev)?;
|
||||
let b = Tensor::randn(0f32, 1.0, 4, &dev)?;
|
||||
let padded = ConvTranspose1d::new(
|
||||
w.clone(),
|
||||
Some(b.clone()),
|
||||
ConvTranspose1dConfig {
|
||||
stride,
|
||||
padding: pad,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let unpadded = ConvTranspose1d::new(
|
||||
w,
|
||||
Some(b),
|
||||
ConvTranspose1dConfig {
|
||||
stride,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let want = x.apply(&padded)?;
|
||||
let got = upsample(&x, &unpadded, pad)?;
|
||||
assert_eq!(want.dims(), got.dims(), "stride {stride}");
|
||||
let diff = (want - got)?.abs()?.max_all()?.to_scalar::<f32>()?;
|
||||
assert!(diff < 1e-4, "stride {stride}: max diff {diff}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! the worker's audio slab, one `Job::GenerateAudio` runs a whole song, and
|
||||
//! the reply is CPU-side samples. WAV encoding happens on the async side.
|
||||
|
||||
use super::arch::acestep::linear::with_quant;
|
||||
use super::arch::acestep::pipeline::{AceStepPipeline, GenerationParams, LmMetadata};
|
||||
use super::arch::acestep::{AceStepConfig, VaeConfig, lm::LmConfig, qwen3, sampling};
|
||||
use anyhow::{Context, Result};
|
||||
@@ -77,12 +78,109 @@ pub fn is_acestep_layout(filenames: &[&str]) -> bool {
|
||||
&& filenames.contains(&format!("{VAE_DIR}/diffusion_pytorch_model.safetensors").as_str())
|
||||
}
|
||||
|
||||
/// A resolved snapshot: the root directory every component sits under, and
|
||||
/// which planner (if any) it has.
|
||||
/// A resolved snapshot: the root directory every component sits under,
|
||||
/// which planner (if any) it has, and how this host lays it out.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AceStepFiles {
|
||||
pub root: PathBuf,
|
||||
pub planner: Option<String>,
|
||||
pub layout: AceStepLayout,
|
||||
}
|
||||
|
||||
/// Where a pipeline component runs (#385).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Place {
|
||||
/// The model's device.
|
||||
#[default]
|
||||
Gpu,
|
||||
/// The host CPU, in f32.
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
fn parse(component: &str, value: Option<&str>) -> Result<Self> {
|
||||
match value.map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("gpu") | Some("cuda") => Ok(Self::Gpu),
|
||||
Some("cpu") => Ok(Self::Cpu),
|
||||
Some(other) => anyhow::bail!(
|
||||
"audio placement for {component} is '{other}'; expected \"gpu\" or \"cpu\" \
|
||||
(a component on another card of the host is not supported yet)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an ACE-Step pipeline is laid out on this host (#385): weight
|
||||
/// quantization, compute precision, where each component runs, and the
|
||||
/// memory budget it must fit.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AceStepLayout {
|
||||
/// Quantizes the DiT's, the text encoder's and the planner's linear
|
||||
/// layers. Quantized pipelines compute in f32.
|
||||
pub quant: Option<candle_core::quantized::GgmlDType>,
|
||||
/// f32 compute on the GPU even without quantization.
|
||||
pub f32_compute: bool,
|
||||
pub text_encoder: Place,
|
||||
pub planner: Place,
|
||||
pub dit: Place,
|
||||
pub vae: Place,
|
||||
pub vram_budget_mb: Option<u64>,
|
||||
pub enforce_vram_budget: bool,
|
||||
}
|
||||
|
||||
impl AceStepLayout {
|
||||
pub fn from_config(cfg: &crate::config::AudioConfig) -> Result<Self> {
|
||||
let quant = match cfg.quant.as_deref().map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("") | Some("none") => None,
|
||||
Some("q8_0") | Some("q8") => Some(candle_core::quantized::GgmlDType::Q8_0),
|
||||
Some(other) => {
|
||||
anyhow::bail!("unsupported audio quant '{other}'; ACE-Step serves q8_0 or none")
|
||||
}
|
||||
};
|
||||
let f32_compute = match cfg
|
||||
.compute
|
||||
.as_deref()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
None | Some("bf16") => false,
|
||||
Some("f32") => true,
|
||||
Some(other) => {
|
||||
anyhow::bail!("unsupported audio compute '{other}'; expected bf16 or f32")
|
||||
}
|
||||
};
|
||||
if cfg.enforce_vram_budget && cfg.vram_budget_mb.is_none() {
|
||||
anyhow::bail!("enforce_vram_budget needs vram_budget_mb");
|
||||
}
|
||||
let p = &cfg.placement;
|
||||
Ok(Self {
|
||||
quant,
|
||||
f32_compute,
|
||||
text_encoder: Place::parse("text_encoder", p.text_encoder.as_deref())?,
|
||||
planner: Place::parse("planner", p.planner.as_deref())?,
|
||||
dit: Place::parse("dit", p.dit.as_deref())?,
|
||||
vae: Place::parse("vae", p.vae.as_deref())?,
|
||||
vram_budget_mb: cfg.vram_budget_mb,
|
||||
enforce_vram_budget: cfg.enforce_vram_budget,
|
||||
})
|
||||
}
|
||||
|
||||
/// The device and dtype a component placed at `place` runs with.
|
||||
fn component(&self, place: Place, gpu: &Device) -> (Device, DType) {
|
||||
match place {
|
||||
Place::Cpu => (Device::Cpu, DType::F32),
|
||||
Place::Gpu if gpu.is_cpu() || self.quant.is_some() || self.f32_compute => {
|
||||
(gpu.clone(), DType::F32)
|
||||
}
|
||||
Place::Gpu => (gpu.clone(), DType::BF16),
|
||||
}
|
||||
}
|
||||
|
||||
/// VAE decode chunk, in latent frames. A budget halves the default
|
||||
/// chunk, which roughly halves the decoder's transient memory.
|
||||
fn vae_chunk_frames(&self) -> Option<usize> {
|
||||
self.vram_budget_mb.map(|_| 64)
|
||||
}
|
||||
}
|
||||
|
||||
/// One generation's parameters, already validated against the model's limits.
|
||||
@@ -123,6 +221,10 @@ impl AudioGenResult {
|
||||
pub struct AceStepEngine {
|
||||
pipeline: AceStepPipeline,
|
||||
has_planner: bool,
|
||||
vae_chunk_frames: Option<usize>,
|
||||
/// Device memory held so the pipeline sees only its budget
|
||||
/// (`enforce_vram_budget`). Dropped with the engine.
|
||||
_ballast: Option<candle_core::Tensor>,
|
||||
}
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
|
||||
@@ -131,13 +233,18 @@ fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
|
||||
}
|
||||
|
||||
impl AceStepEngine {
|
||||
/// Build every component on `device`: bf16 on a GPU, f32 on the CPU.
|
||||
/// Build every component where `files.layout` places it: bf16 on a
|
||||
/// GPU, f32 on the CPU or when quantized or asked for.
|
||||
pub fn load(files: &AceStepFiles, device: &Device) -> Result<Self> {
|
||||
let dtype = if device.is_cpu() {
|
||||
DType::F32
|
||||
} else {
|
||||
DType::BF16
|
||||
let layout = &files.layout;
|
||||
let ballast = match (layout.enforce_vram_budget, layout.vram_budget_mb) {
|
||||
(true, Some(budget)) => hold_all_but(device, budget)?,
|
||||
_ => None,
|
||||
};
|
||||
let (dit_device, dtype) = layout.component(layout.dit, device);
|
||||
let (text_device, text_dtype) = layout.component(layout.text_encoder, device);
|
||||
let (vae_device, vae_dtype) = layout.component(layout.vae, device);
|
||||
let (planner_device, planner_dtype) = layout.component(layout.planner, device);
|
||||
let root = &files.root;
|
||||
let dit = root.join(DIT_DIR);
|
||||
let te = root.join(TEXT_ENCODER_DIR);
|
||||
@@ -150,19 +257,27 @@ impl AceStepEngine {
|
||||
// SAFETY: the snapshot is a read-only cache the daemon owns; the
|
||||
// files are not modified while mapped.
|
||||
let dit_vb = unsafe {
|
||||
VarBuilder::from_mmaped_safetensors(&[dit.join("model.safetensors")], dtype, device)?
|
||||
VarBuilder::from_mmaped_safetensors(
|
||||
&[dit.join("model.safetensors")],
|
||||
dtype,
|
||||
&dit_device,
|
||||
)?
|
||||
};
|
||||
let vae_vb = unsafe {
|
||||
VarBuilder::from_mmaped_safetensors(
|
||||
&[vae.join("diffusion_pytorch_model.safetensors")],
|
||||
dtype,
|
||||
device,
|
||||
vae_dtype,
|
||||
&vae_device,
|
||||
)?
|
||||
};
|
||||
// The embedding model's tensors carry a `model.` prefix the bare
|
||||
// qwen3 encoder does not expect.
|
||||
let text_vb = unsafe {
|
||||
VarBuilder::from_mmaped_safetensors(&[te.join("model.safetensors")], dtype, device)?
|
||||
VarBuilder::from_mmaped_safetensors(
|
||||
&[te.join("model.safetensors")],
|
||||
text_dtype,
|
||||
&text_device,
|
||||
)?
|
||||
}
|
||||
.rename_f(|n| n.strip_prefix("model.").unwrap_or(n).to_string());
|
||||
|
||||
@@ -170,25 +285,36 @@ impl AceStepEngine {
|
||||
&dit.join("silence_latent.pt"),
|
||||
dit_config.audio_acoustic_hidden_dim,
|
||||
dtype,
|
||||
device,
|
||||
)?;
|
||||
let mut pipeline = AceStepPipeline::new(
|
||||
&dit_config,
|
||||
&vae_config,
|
||||
&text_config,
|
||||
dit_vb,
|
||||
vae_vb,
|
||||
text_vb,
|
||||
&te.join("tokenizer.json"),
|
||||
silence_latent,
|
||||
false,
|
||||
&dit_device,
|
||||
)?;
|
||||
// The VAE builds outside the scope: its decoder is convolutions, and
|
||||
// the quantization scope only touches linear layers anyway.
|
||||
let mut pipeline = with_quant(layout.quant, || {
|
||||
AceStepPipeline::new(
|
||||
&dit_config,
|
||||
&vae_config,
|
||||
&text_config,
|
||||
dit_vb,
|
||||
vae_vb,
|
||||
text_vb,
|
||||
&te.join("tokenizer.json"),
|
||||
silence_latent,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
|
||||
// The planner is optional and runs only when asked for, so a planner
|
||||
// that will not load leaves a working engine without one rather
|
||||
// than failing the model.
|
||||
let has_planner = match &files.planner {
|
||||
Some(dir) => match Self::load_planner(&mut pipeline, &root.join(dir), dtype, device) {
|
||||
Some(dir) => match with_quant(layout.quant, || {
|
||||
Self::load_planner(
|
||||
&mut pipeline,
|
||||
&root.join(dir),
|
||||
planner_dtype,
|
||||
&planner_device,
|
||||
)
|
||||
}) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(planner = %dir, error = %format!("{e:#}"), "song planner failed to load; serving without it");
|
||||
@@ -197,9 +323,20 @@ impl AceStepEngine {
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
tracing::info!(
|
||||
quant = ?layout.quant,
|
||||
dit = ?(dit_device.location(), dtype),
|
||||
text_encoder = ?(text_device.location(), text_dtype),
|
||||
vae = ?(vae_device.location(), vae_dtype),
|
||||
planner = ?has_planner.then(|| (planner_device.location(), planner_dtype)),
|
||||
budget_mb = ?layout.vram_budget_mb,
|
||||
"ACE-Step pipeline laid out"
|
||||
);
|
||||
Ok(Self {
|
||||
pipeline,
|
||||
has_planner,
|
||||
vae_chunk_frames: layout.vae_chunk_frames(),
|
||||
_ballast: ballast,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,9 +344,8 @@ impl AceStepEngine {
|
||||
self.has_planner
|
||||
}
|
||||
|
||||
/// Load the planner LM into the pipeline. Its checkpoint stores the
|
||||
/// decoder's tensors without the `model.` prefix the causal-LM wrapper
|
||||
/// asks for, and ties the output head to the embeddings.
|
||||
/// Load the planner LM into the pipeline. The output head is tied to
|
||||
/// the embeddings.
|
||||
fn load_planner(
|
||||
pipeline: &mut AceStepPipeline,
|
||||
dir: &Path,
|
||||
@@ -219,8 +355,14 @@ impl AceStepEngine {
|
||||
let lm_config: qwen3::Config = read_json(&dir.join("config.json"))?;
|
||||
let lm_vb = unsafe {
|
||||
VarBuilder::from_mmaped_safetensors(&[dir.join("model.safetensors")], dtype, device)?
|
||||
}
|
||||
.rename_f(|n| n.strip_prefix("model.").unwrap_or(n).to_string());
|
||||
};
|
||||
// The 1.7B checkpoint stores the decoder without the `model.`
|
||||
// prefix; the 0.6B one keeps it. Strip it only where it is absent.
|
||||
let lm_vb = if lm_vb.contains_tensor("model.embed_tokens.weight") {
|
||||
lm_vb
|
||||
} else {
|
||||
lm_vb.rename_f(|n| n.strip_prefix("model.").unwrap_or(n).to_string())
|
||||
};
|
||||
pipeline.load_lm(&lm_config, lm_vb, &dir.join("tokenizer.json"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -240,17 +382,7 @@ impl AceStepEngine {
|
||||
self.pipeline
|
||||
.generate_lm_hints(&p.prompt, &p.lyrics, p.duration_secs, &cfg)?;
|
||||
timing.plan_ms = t.elapsed().as_millis() as u64;
|
||||
let metadata = LmMetadata {
|
||||
bpm: meta.get("bpm").cloned(),
|
||||
timesignature: meta.get("timesignature").cloned(),
|
||||
keyscale: meta.get("keyscale").cloned(),
|
||||
caption: meta.get("caption").cloned(),
|
||||
language: meta
|
||||
.get("language")
|
||||
.or_else(|| meta.get("vocal_language"))
|
||||
.cloned(),
|
||||
};
|
||||
(Some(hints), Some(metadata))
|
||||
(Some(hints), Some(lm_metadata(&meta)))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
@@ -268,12 +400,14 @@ impl AceStepEngine {
|
||||
lm_metadata,
|
||||
infer_method: sampling::InferMethod::Ode,
|
||||
language: p.language.clone(),
|
||||
vae_chunk_frames: self.vae_chunk_frames,
|
||||
..Default::default()
|
||||
};
|
||||
let t = Instant::now();
|
||||
let out = self.pipeline.text2music(&p.prompt, &p.lyrics, ¶ms)?;
|
||||
let audio = out.audio.to_dtype(DType::F32)?.to_device(&Device::Cpu)?;
|
||||
timing.generate_ms = t.elapsed().as_millis() as u64;
|
||||
timing.encode_ms = out.timing.encode_ms;
|
||||
timing.generate_ms = out.timing.denoise_ms;
|
||||
timing.decode_ms = out.timing.decode_ms;
|
||||
|
||||
let mut left = audio.get(0)?.to_vec1::<f32>()?;
|
||||
let mut right = audio.get(1)?.to_vec1::<f32>()?;
|
||||
@@ -290,6 +424,49 @@ impl AceStepEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The plan's fields the DiT prompt carries.
|
||||
fn lm_metadata(meta: &std::collections::BTreeMap<String, String>) -> LmMetadata {
|
||||
LmMetadata {
|
||||
bpm: meta.get("bpm").cloned(),
|
||||
timesignature: meta.get("timesignature").cloned(),
|
||||
keyscale: meta.get("keyscale").cloned(),
|
||||
caption: meta.get("caption").cloned(),
|
||||
language: meta
|
||||
.get("language")
|
||||
.or_else(|| meta.get("vocal_language"))
|
||||
.cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate all of `device`'s free memory except `budget_mb`, so what
|
||||
/// loads next sees a card of the budget's size (#385). `None` on the CPU,
|
||||
/// or when the device already has no more than the budget free.
|
||||
#[cfg(feature = "cuda")]
|
||||
fn hold_all_but(device: &Device, budget_mb: u64) -> Result<Option<candle_core::Tensor>> {
|
||||
if device.is_cpu() {
|
||||
return Ok(None);
|
||||
}
|
||||
// The device worker bound this device's context to the calling thread.
|
||||
let (free, _) = candle_core::cuda::cudarc::driver::result::mem_get_info()
|
||||
.map_err(|e| anyhow::anyhow!("mem_get_info: {e:?}"))?;
|
||||
let hold = (free as u64).saturating_sub(budget_mb * 1024 * 1024);
|
||||
if hold == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
tracing::warn!(
|
||||
held_mb = hold / (1024 * 1024),
|
||||
budget_mb,
|
||||
"holding device memory so the audio pipeline sees only its budget"
|
||||
);
|
||||
let t = candle_core::Tensor::zeros(hold as usize, DType::U8, device)?;
|
||||
Ok(Some(t))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cuda"))]
|
||||
fn hold_all_but(_device: &Device, _budget_mb: u64) -> Result<Option<candle_core::Tensor>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ── HeartMuLa (#393) ─────────────────────────────────────────────────
|
||||
|
||||
/// What HeartMuLa oss-3B serves: songs of 10 s to 4 min, as the evaluation
|
||||
@@ -566,6 +743,55 @@ pub fn encode_flac_pcm16(left: &[f32], right: &[f32], sample_rate: u32) -> anyho
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn layout(toml: &str) -> Result<AceStepLayout> {
|
||||
let cfg: crate::config::AudioConfig = toml::from_str(toml).unwrap();
|
||||
AceStepLayout::from_config(&cfg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_layout_is_bf16_on_the_gpu() {
|
||||
let l = layout("").unwrap();
|
||||
assert_eq!(l.quant, None);
|
||||
// A CPU stands in for the GPU here; what matters is the dtype rule.
|
||||
let gpu = Device::Cpu;
|
||||
assert_eq!(
|
||||
l.component(Place::Gpu, &gpu).1,
|
||||
DType::F32,
|
||||
"CPU is always f32"
|
||||
);
|
||||
assert_eq!(l.vae_chunk_frames(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pascal_recipe_parses_to_q8_with_f32_compute_and_a_budget() {
|
||||
let l = layout(
|
||||
r#"
|
||||
quant = "q8_0"
|
||||
vram_budget_mb = 8192
|
||||
enforce_vram_budget = true
|
||||
[placement]
|
||||
text_encoder = "cpu"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(l.quant, Some(candle_core::quantized::GgmlDType::Q8_0));
|
||||
assert_eq!(l.text_encoder, Place::Cpu);
|
||||
assert_eq!(l.dit, Place::Gpu);
|
||||
assert_eq!(l.vae_chunk_frames(), Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_errors_name_the_problem() {
|
||||
let e = format!("{:#}", layout(r#"quant = "q4_k""#).unwrap_err());
|
||||
assert!(e.contains("q4_k"), "{e}");
|
||||
let e = format!("{:#}", layout(r#"compute = "f16""#).unwrap_err());
|
||||
assert!(e.contains("f16"), "{e}");
|
||||
let e = format!("{:#}", layout("[placement]\ndit = \"cuda:1\"").unwrap_err());
|
||||
assert!(e.contains("dit") && e.contains("cuda:1"), "{e}");
|
||||
let e = format!("{:#}", layout("enforce_vram_budget = true").unwrap_err());
|
||||
assert!(e.contains("vram_budget_mb"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartmula_is_recognised_by_its_config() {
|
||||
assert!(is_heartmula_config(
|
||||
@@ -653,6 +879,7 @@ mod hardware {
|
||||
let files = AceStepFiles {
|
||||
root: root.into(),
|
||||
planner: None,
|
||||
layout: AceStepLayout::default(),
|
||||
};
|
||||
let t = Instant::now();
|
||||
let mut engine = AceStepEngine::load(&files, &Device::Cpu).expect("load");
|
||||
@@ -732,14 +959,32 @@ mod hardware {
|
||||
};
|
||||
let cases = std::env::var("ACESTEP_CASES")
|
||||
.unwrap_or_else(|_| "instr_10s_noplan,vocal_es_20s_noplan".into());
|
||||
// ACESTEP_LAYOUT (a `[harness.candle.audio]` body) and
|
||||
// ACESTEP_DEVICE=cuda measure another layout's drift from the
|
||||
// reference (#385). Only the default layout on the CPU is held to
|
||||
// parity; any other is reported, and held to a drift bound.
|
||||
let layout_toml = std::env::var("ACESTEP_LAYOUT").unwrap_or_default();
|
||||
let cfg: crate::config::AudioConfig = toml::from_str(&layout_toml).unwrap();
|
||||
let device = match std::env::var("ACESTEP_DEVICE").as_deref() {
|
||||
Ok("cuda") => Device::new_cuda(0).unwrap(),
|
||||
_ => Device::Cpu,
|
||||
};
|
||||
let parity = layout_toml.trim().is_empty() && device.is_cpu();
|
||||
// (case, this layout's drift, upstream bf16's drift)
|
||||
let mut drifts: Vec<(String, f32, f32)> = Vec::new();
|
||||
// A planned case (`*_plan`) runs the planner greedily first, as the
|
||||
// trace did; its plan and codes then condition the DiT (#384).
|
||||
let planned = cases.split(',').any(|c| c.ends_with("_plan"));
|
||||
let mut engine = AceStepEngine::load(
|
||||
&AceStepFiles {
|
||||
root: root.into(),
|
||||
planner: None,
|
||||
planner: planned.then(|| PLANNER_DIRS[0].to_string()),
|
||||
layout: AceStepLayout::from_config(&cfg).unwrap(),
|
||||
},
|
||||
&Device::Cpu,
|
||||
&device,
|
||||
)
|
||||
.expect("load");
|
||||
assert_eq!(engine.has_planner(), planned, "planner load");
|
||||
let dir =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/harness/testdata/acestep");
|
||||
for case in cases.split(',') {
|
||||
@@ -771,8 +1016,56 @@ mod hardware {
|
||||
let language = c["language"].as_str().unwrap();
|
||||
let duration = c["duration"].as_f64().unwrap();
|
||||
|
||||
let (text, lyric) =
|
||||
AceStepPipeline::format_prompts(caption, lyrics, duration, None, Some(language));
|
||||
let (hints, metadata) = if c["planner"].as_bool() == Some(true) {
|
||||
let cfg = crate::harness::arch::acestep::lm::LmConfig {
|
||||
temperature: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
// ACESTEP_PLAN_FROM_TRACE takes the trace's own codes and
|
||||
// plan instead of running the planner, to test what follows
|
||||
// the planner on its own (and in seconds rather than
|
||||
// minutes).
|
||||
let (hints, meta) = if std::env::var("ACESTEP_PLAN_FROM_TRACE").is_ok() {
|
||||
let codes = file
|
||||
.load("planner_audio_codes", &Device::Cpu)
|
||||
.unwrap()
|
||||
.to_vec1::<i64>()
|
||||
.unwrap();
|
||||
let cot = meta["planner"][0]["output_text"].as_str().unwrap();
|
||||
(
|
||||
engine.pipeline.audio_codes_to_latents(&codes).unwrap(),
|
||||
crate::harness::arch::acestep::lm::LmPipeline::parse_metadata(cot),
|
||||
)
|
||||
} else {
|
||||
engine
|
||||
.pipeline
|
||||
.generate_lm_hints(caption, lyrics, duration, &cfg)
|
||||
.unwrap()
|
||||
};
|
||||
let want_hints = file
|
||||
.load("gen_precomputed_lm_hints_25Hz", &Device::Cpu)
|
||||
.unwrap();
|
||||
eprintln!(
|
||||
"{case}: lm hints rel L2 {:.2e}",
|
||||
rel_l2(&hints.to_device(&Device::Cpu).unwrap(), &want_hints)
|
||||
);
|
||||
(Some(hints), Some(lm_metadata(&meta)))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let instruction = if hints.is_some() {
|
||||
crate::harness::arch::acestep::pipeline::COVER_INSTRUCTION
|
||||
} else {
|
||||
crate::harness::arch::acestep::pipeline::TEXT2MUSIC_INSTRUCTION
|
||||
};
|
||||
let (text, lyric) = AceStepPipeline::format_prompts(
|
||||
instruction,
|
||||
caption,
|
||||
lyrics,
|
||||
duration,
|
||||
metadata.as_ref(),
|
||||
Some(language),
|
||||
);
|
||||
let (text_ids, lyric_ids) = engine.pipeline.prompt_token_ids(&text, &lyric).unwrap();
|
||||
let ids = |k: &str| -> Vec<u32> {
|
||||
want[k]
|
||||
@@ -796,6 +1089,8 @@ mod hardware {
|
||||
infer_method: sampling::InferMethod::Ode,
|
||||
initial_noise: Some(want["noise"].clone()),
|
||||
language: Some(language.to_string()),
|
||||
lm_hints_25hz: hints,
|
||||
lm_metadata: metadata,
|
||||
..Default::default()
|
||||
};
|
||||
let t = Instant::now();
|
||||
@@ -803,17 +1098,57 @@ mod hardware {
|
||||
.pipeline
|
||||
.text2music(caption, lyrics, ¶ms)
|
||||
.unwrap();
|
||||
let latents = rel_l2(&out.latents, &want["target_latents"]);
|
||||
let cpu = |t: &candle_core::Tensor| t.to_device(&Device::Cpu).unwrap();
|
||||
let latents = rel_l2(&cpu(&out.latents), &want["target_latents"]);
|
||||
let wave_ref = &want["vae_waveform"];
|
||||
let n = wave_ref.dim(2).unwrap();
|
||||
let wave = out.audio.narrow(1, 0, n).unwrap().unsqueeze(0).unwrap();
|
||||
let wave = cpu(&out.audio)
|
||||
.narrow(1, 0, n)
|
||||
.unwrap()
|
||||
.unsqueeze(0)
|
||||
.unwrap();
|
||||
let waveform = rel_l2(&wave, wave_ref);
|
||||
eprintln!(
|
||||
"{case}: latents rel L2 {latents:.2e}, waveform rel L2 {waveform:.2e} ({:.1}s)",
|
||||
"{case} [{layout_toml:?} on {:?}]: latents rel L2 {latents:.2e}, waveform rel L2 {waveform:.2e} ({:.1}s)",
|
||||
device.location(),
|
||||
t.elapsed().as_secs_f32()
|
||||
);
|
||||
assert!(latents < 1e-3, "{case}: latents rel L2 {latents}");
|
||||
assert!(waveform < 1e-2, "{case}: waveform rel L2 {waveform}");
|
||||
if parity {
|
||||
assert!(latents < 1e-3, "{case}: latents rel L2 {latents}");
|
||||
assert!(waveform < 1e-2, "{case}: waveform rel L2 {waveform}");
|
||||
} else {
|
||||
// The yardstick is upstream's own: how far its bf16 GPU run
|
||||
// of the same case lands from its f32 CPU run. (Its f16 run
|
||||
// of a vocal case is NaN.)
|
||||
let envelope_file = unsafe {
|
||||
candle_core::safetensors::MmapedSafetensors::new(
|
||||
dir.join(format!("{case}__cuda_bf16.safetensors")),
|
||||
)
|
||||
}
|
||||
.unwrap();
|
||||
let upstream_bf16 = envelope_file.load("target_latents", &Device::Cpu).unwrap();
|
||||
let envelope = rel_l2(&upstream_bf16, &want["target_latents"]);
|
||||
eprintln!("{case}: upstream bf16 drifts {envelope:.2e}");
|
||||
drifts.push((case.to_string(), latents, envelope));
|
||||
}
|
||||
}
|
||||
// Turbo denoising is chaotic: per-case drift swings with tiny
|
||||
// numeric differences, so a layout is judged on its mean across
|
||||
// cases against upstream bf16's mean, and every case must be
|
||||
// finite.
|
||||
if !drifts.is_empty() {
|
||||
let n = drifts.len() as f32;
|
||||
let mean = drifts.iter().map(|d| d.1).sum::<f32>() / n;
|
||||
let envelope = drifts.iter().map(|d| d.2).sum::<f32>() / n;
|
||||
eprintln!("mean latents rel L2 {mean:.2e}; upstream bf16 mean {envelope:.2e}");
|
||||
assert!(
|
||||
drifts.iter().all(|d| d.1.is_finite()),
|
||||
"non-finite drift: {drifts:?}"
|
||||
);
|
||||
assert!(
|
||||
mean <= envelope,
|
||||
"mean drift {mean} beyond upstream bf16's {envelope}: {drifts:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,6 +1213,86 @@ mod hardware {
|
||||
assert_eq!(out.left, again.left, "same seed must render identically");
|
||||
}
|
||||
|
||||
/// Planner parity with upstream (#384): greedy decoding in f32 over the
|
||||
/// `vocal_en_30s_plan` case must reproduce upstream's CoT text exactly,
|
||||
/// and its audio codes (CFG 2.0, 150 of them). Ignored: needs
|
||||
/// ACESTEP_SNAPSHOT; ACESTEP_DEVICE=cuda runs it on the GPU.
|
||||
#[test]
|
||||
#[ignore = "needs ACESTEP_SNAPSHOT"]
|
||||
fn acestep_planner_matches_reference() {
|
||||
use crate::harness::arch::acestep::lm::{LmConfig, LmPipeline, TokenIds};
|
||||
let Ok(root) = std::env::var("ACESTEP_SNAPSHOT") else {
|
||||
return;
|
||||
};
|
||||
let device = match std::env::var("ACESTEP_DEVICE").as_deref() {
|
||||
Ok("cuda") => Device::new_cuda(0).unwrap(),
|
||||
_ => Device::Cpu,
|
||||
};
|
||||
let dir = PathBuf::from(root).join(PLANNER_DIRS[0]);
|
||||
let cfg: qwen3::Config = read_json(&dir.join("config.json")).unwrap();
|
||||
let vb = unsafe {
|
||||
VarBuilder::from_mmaped_safetensors(
|
||||
&[dir.join("model.safetensors")],
|
||||
DType::F32,
|
||||
&device,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
.rename_f(|n| n.strip_prefix("model.").unwrap_or(n).to_string());
|
||||
let model = qwen3::ModelForCausalLM::new(&cfg, vb).unwrap();
|
||||
let tok = tokenizers::Tokenizer::from_file(dir.join("tokenizer.json")).unwrap();
|
||||
let encode = |s: &str| -> candle_core::Result<Vec<u32>> {
|
||||
Ok(tok.encode(s, false).unwrap().get_ids().to_vec())
|
||||
};
|
||||
let decode =
|
||||
|ids: &[u32]| -> candle_core::Result<String> { Ok(tok.decode(ids, false).unwrap()) };
|
||||
let ids = TokenIds::discover(encode).unwrap();
|
||||
let mut lm = LmPipeline::new(model, ids, device);
|
||||
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src/harness/testdata/acestep/vocal_en_30s_plan__cpu_f32.json");
|
||||
let f: serde_json::Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let case = &f["case"];
|
||||
let config = LmConfig {
|
||||
temperature: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
let t = Instant::now();
|
||||
let out = lm
|
||||
.generate(
|
||||
case["caption"].as_str().unwrap(),
|
||||
case["lyrics"].as_str().unwrap(),
|
||||
case["duration"].as_f64().unwrap(),
|
||||
&config,
|
||||
&decode,
|
||||
&encode,
|
||||
)
|
||||
.unwrap();
|
||||
eprintln!("planner ran in {:.1}s", t.elapsed().as_secs_f32());
|
||||
|
||||
let want_cot = f["planner"][0]["output_text"].as_str().unwrap();
|
||||
// raw_text is the CoT, a newline, then the re-dumped plan and codes.
|
||||
let got_cot = &out.raw_text[..want_cot.len().min(out.raw_text.len())];
|
||||
assert_eq!(got_cot, want_cot, "CoT text");
|
||||
|
||||
let want_codes = crate::harness::arch::acestep::lm::parse_audio_codes_from_text(
|
||||
f["planner"][1]["output_text"].as_str().unwrap(),
|
||||
);
|
||||
let same = out
|
||||
.audio_codes
|
||||
.iter()
|
||||
.zip(&want_codes)
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
eprintln!(
|
||||
"codes: {} generated, {} expected, first {same} identical",
|
||||
out.audio_codes.len(),
|
||||
want_codes.len()
|
||||
);
|
||||
assert_eq!(out.audio_codes.len(), want_codes.len(), "code count");
|
||||
assert_eq!(out.audio_codes, want_codes, "audio codes");
|
||||
}
|
||||
|
||||
/// The planner LM loads from the real snapshot (its tensors carry no
|
||||
/// `model.` prefix). Ignored: needs ACESTEP_SNAPSHOT.
|
||||
#[test]
|
||||
@@ -899,4 +1314,149 @@ mod hardware {
|
||||
.rename_f(|n| n.strip_prefix("model.").unwrap_or(n).to_string());
|
||||
qwen3::ModelForCausalLM::new(&cfg, vb).expect("planner loads");
|
||||
}
|
||||
|
||||
/// Peak device memory and latency per song length, for a layout
|
||||
/// (#385). Run on a CUDA host:
|
||||
///
|
||||
/// ACESTEP_SNAPSHOT=… ACESTEP_LAYOUT='quant = "q8_0"' \
|
||||
/// ACESTEP_PLANNER=acestep-5Hz-lm-1.7B ACESTEP_DURATIONS=30,180 \
|
||||
/// cargo test -p neuron --features cuda --lib -- --ignored \
|
||||
/// acestep_profile_gpu --nocapture
|
||||
///
|
||||
/// `ACESTEP_PLANNER` is a snapshot subfolder or an absolute path; set,
|
||||
/// the planner runs. Peak memory is this process's, as nvidia-smi
|
||||
/// reports it, CUDA context included, so it is what a card of that
|
||||
/// size would have to hold. The pool is trimmed between songs, as the
|
||||
/// device worker does after every job.
|
||||
#[cfg(feature = "cuda")]
|
||||
#[test]
|
||||
#[ignore = "needs ACESTEP_SNAPSHOT and a CUDA device"]
|
||||
fn acestep_profile_gpu() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
let Ok(root) = std::env::var("ACESTEP_SNAPSHOT") else {
|
||||
eprintln!("ACESTEP_SNAPSHOT not set; skipping");
|
||||
return;
|
||||
};
|
||||
// The engine reports a planner that fails to load as a warning and
|
||||
// carries on without it; show it.
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::new("neuron=info"))
|
||||
.try_init();
|
||||
let cfg: crate::config::AudioConfig =
|
||||
toml::from_str(&std::env::var("ACESTEP_LAYOUT").unwrap_or_default()).unwrap();
|
||||
let planner = std::env::var("ACESTEP_PLANNER").ok();
|
||||
let durations: Vec<f64> = std::env::var("ACESTEP_DURATIONS")
|
||||
.unwrap_or_else(|_| "30,120,240,480".into())
|
||||
.split(',')
|
||||
.map(|d| d.trim().parse().unwrap())
|
||||
.collect();
|
||||
|
||||
let pid = std::process::id();
|
||||
let peak = Arc::new(AtomicU64::new(0));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let sampler = {
|
||||
let (peak, stop) = (peak.clone(), stop.clone());
|
||||
std::thread::spawn(move || {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let out = std::process::Command::new("nvidia-smi")
|
||||
.args([
|
||||
"--query-compute-apps=pid,used_memory",
|
||||
"--format=csv,noheader,nounits",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let mut f = line.split(',').map(str::trim);
|
||||
if f.next() == Some(pid.to_string().as_str())
|
||||
&& let Some(mb) = f.next().and_then(|m| m.parse::<u64>().ok())
|
||||
{
|
||||
peak.fetch_max(mb, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
})
|
||||
};
|
||||
let trim = || {
|
||||
use candle_core::cuda::cudarc::driver::result::{ctx, device, mem_pool};
|
||||
unsafe {
|
||||
ctx::synchronize().unwrap();
|
||||
let pool = device::get_default_mem_pool(device::get(0).unwrap()).unwrap();
|
||||
mem_pool::trim_to(pool, 0).unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
let device = Device::new_cuda(0).unwrap();
|
||||
let files = AceStepFiles {
|
||||
root: root.into(),
|
||||
planner: planner.clone(),
|
||||
layout: AceStepLayout::from_config(&cfg).unwrap(),
|
||||
};
|
||||
let t = Instant::now();
|
||||
let mut engine = AceStepEngine::load(&files, &device).expect("load");
|
||||
assert_eq!(
|
||||
engine.has_planner(),
|
||||
planner.is_some(),
|
||||
"the planner asked for did not load"
|
||||
);
|
||||
trim();
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
eprintln!(
|
||||
"PROFILE load layout={:?} planner={:?} secs={:.1} resident_mb={}",
|
||||
std::env::var("ACESTEP_LAYOUT").unwrap_or_default(),
|
||||
planner,
|
||||
t.elapsed().as_secs_f32(),
|
||||
peak.swap(0, Ordering::Relaxed)
|
||||
);
|
||||
for d in durations {
|
||||
let params = AudioGenParams {
|
||||
prompt: "Upbeat synth-pop with bright female vocals, punchy drums, 120 BPM".into(),
|
||||
lyrics: "[verse]\nCity lights are calling out my name\nEvery street a spark, every night a flame\n\
|
||||
[chorus]\nWe run, we run, until the morning comes\nHearts like drums, we run\n"
|
||||
.into(),
|
||||
duration_secs: d,
|
||||
seed: 7,
|
||||
steps: None,
|
||||
guidance_scale: None,
|
||||
planner: planner.is_some(),
|
||||
temperature: None,
|
||||
language: Some("en".into()),
|
||||
top_k: None,
|
||||
};
|
||||
let t = Instant::now();
|
||||
let res = engine.generate(¶ms);
|
||||
let secs = t.elapsed().as_secs_f32();
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
let mb = peak.swap(0, Ordering::Relaxed);
|
||||
match res {
|
||||
Ok(out) => {
|
||||
let rms = (out.left.iter().map(|s| s * s).sum::<f32>()
|
||||
/ out.left.len().max(1) as f32)
|
||||
.sqrt();
|
||||
eprintln!(
|
||||
"PROFILE song secs={d} wall={secs:.1} plan_ms={} encode_ms={} denoise_ms={} decode_ms={} peak_mb={mb} rms={rms:.4}",
|
||||
out.timing.plan_ms,
|
||||
out.timing.encode_ms,
|
||||
out.timing.generate_ms,
|
||||
out.timing.decode_ms
|
||||
);
|
||||
if let Ok(dir) = std::env::var("ACESTEP_OUT") {
|
||||
std::fs::write(
|
||||
format!("{dir}/song_{d}.flac"),
|
||||
encode_flac_pcm16(&out.left, &out.right, out.sample_rate).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("PROFILE song secs={d} wall={secs:.1} peak_mb={mb} error={e:#}")
|
||||
}
|
||||
}
|
||||
trim();
|
||||
}
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
sampler.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ pub struct CandleHarness {
|
||||
/// Image-pipeline settings (#198): text-encoder residency and the
|
||||
/// resolution ceiling.
|
||||
image_cfg: crate::config::ImageConfig,
|
||||
/// `[harness.candle.audio]` (#385).
|
||||
audio_cfg: crate::config::AudioConfig,
|
||||
}
|
||||
|
||||
/// Devices/capabilities snapshot of a model entering auto-recovery
|
||||
@@ -2522,6 +2524,7 @@ impl CandleHarness {
|
||||
reasoning_budget_cfg: config.reasoning_budget.clone(),
|
||||
admission_cfg: config.admission.clone(),
|
||||
image_cfg: config.image.clone(),
|
||||
audio_cfg: config.audio.clone(),
|
||||
});
|
||||
// Background auto-recovery task (#17). Holds a `Weak` so it can't
|
||||
// keep the harness alive. Spawned only when a tokio runtime is
|
||||
@@ -4682,6 +4685,20 @@ impl CandleHarness {
|
||||
|
||||
let has_planner =
|
||||
matches!(&files, super::audio::AudioFiles::AceStep(f) if f.planner.is_some());
|
||||
// A host may accept shorter songs than the model allows (#385), for
|
||||
// a card whose memory runs out first.
|
||||
let mut limits = limits;
|
||||
if let super::audio::AudioFiles::AceStep(_) = &files
|
||||
&& let Some(max) = self.audio_cfg.max_duration_secs
|
||||
{
|
||||
anyhow::ensure!(
|
||||
max >= limits.min_duration,
|
||||
"[harness.candle.audio] max_duration_secs {max} is below the model's minimum {}",
|
||||
limits.min_duration
|
||||
);
|
||||
limits.max_duration = limits.max_duration.min(max);
|
||||
limits.default_duration = limits.default_duration.min(limits.max_duration);
|
||||
}
|
||||
let handle = worker
|
||||
.load_audio(files, spec.model_id.clone())
|
||||
.await
|
||||
@@ -4741,8 +4758,38 @@ impl CandleHarness {
|
||||
.context("snapshot path shorter than its repo path")?
|
||||
.to_path_buf();
|
||||
}
|
||||
// `[harness.candle.audio] planner` names one planner, or none;
|
||||
// unset tries them largest first.
|
||||
let named = self.audio_cfg.planner.as_deref();
|
||||
let candidates: Vec<&str> = match named {
|
||||
Some(n) if n.eq_ignore_ascii_case("none") => Vec::new(),
|
||||
Some(n) => vec![n],
|
||||
None => super::audio::PLANNER_DIRS.to_vec(),
|
||||
};
|
||||
let mut planner = None;
|
||||
for dir in super::audio::PLANNER_DIRS {
|
||||
// A name with a slash is a repo of its own (the 0.6B planner ships
|
||||
// as `ACE-Step/acestep-5Hz-lm-0.6B`, not in the main snapshot),
|
||||
// fetched through the same registry; the engine joins an absolute
|
||||
// path onto the root unchanged.
|
||||
if let Some(id) = named.filter(|n| n.contains('/')) {
|
||||
let planner_repo = api.model(id.to_string());
|
||||
let mut dir = None;
|
||||
for file in ["config.json", "model.safetensors", "tokenizer.json"] {
|
||||
let path = planner_repo
|
||||
.get(file)
|
||||
.await
|
||||
.with_context(|| format!("fetch {file} from planner repo {id}"))?;
|
||||
dir = path.parent().map(|p| p.to_path_buf());
|
||||
}
|
||||
let dir = dir.context("planner repo resolved to no directory")?;
|
||||
planner = Some(dir.to_string_lossy().into_owned());
|
||||
}
|
||||
let candidates = if planner.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
candidates
|
||||
};
|
||||
for dir in candidates {
|
||||
let mut ok = true;
|
||||
for file in super::audio::planner_files(dir) {
|
||||
if let Err(e) = repo.get(&file).await {
|
||||
@@ -4756,7 +4803,19 @@ impl CandleHarness {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(super::audio::AceStepFiles { root, planner })
|
||||
if let (Some(n), None) = (named, &planner)
|
||||
&& !n.eq_ignore_ascii_case("none")
|
||||
{
|
||||
anyhow::bail!(
|
||||
"{display_id} has no planner '{n}' (named by [harness.candle.audio] planner)"
|
||||
);
|
||||
}
|
||||
let layout = super::audio::AceStepLayout::from_config(&self.audio_cfg)?;
|
||||
Ok(super::audio::AceStepFiles {
|
||||
root,
|
||||
planner,
|
||||
layout,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a HeartMuLa LM repo (#393) and the two sibling repos it
|
||||
@@ -5271,16 +5330,17 @@ impl CandleHarness {
|
||||
request
|
||||
.validate_limits(&audio.limits)
|
||||
.map_err(|detail| InferenceError::InvalidAudioParams { detail })?;
|
||||
// The planner runs only when asked for until its port is proven
|
||||
// against the reference (#384); `auto` means off until then.
|
||||
// `auto` (or nothing) follows the vendor default: plan the song
|
||||
// when the model has a planner. The port matches upstream's trace
|
||||
// (#384).
|
||||
let planner = match request.planner.as_deref() {
|
||||
Some("on") if !audio.has_planner => {
|
||||
return Err(InferenceError::InvalidAudioParams {
|
||||
detail: "this model has no song planner; use planner=off or auto".into(),
|
||||
});
|
||||
}
|
||||
Some("on") => true,
|
||||
_ => false,
|
||||
Some("off") => false,
|
||||
_ => audio.has_planner,
|
||||
};
|
||||
// A random seed small enough to type back in, so any render can be
|
||||
// reproduced from what the job reports.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Using decisions
|
||||
sidebar_label: Decisions
|
||||
description: Typed questions over text or JSON, answered with calibrated probabilities — the System-One endpoint and its playground.
|
||||
description: Typed questions over text or JSON, answered with calibrated probabilities — the System-One endpoint and the Decisions page.
|
||||
---
|
||||
|
||||
# Using decisions
|
||||
@@ -13,7 +13,7 @@ request. That makes it fast (tens of milliseconds), cheap, and
|
||||
predictable enough to put in front of a workflow: routing a ticket,
|
||||
screening a message, deciding whether to escalate.
|
||||
|
||||
Try it without writing code in [the playground](/playground).
|
||||
Try it without writing code on [the Decisions page](/decisions).
|
||||
|
||||
The endpoint is `POST https://helexa.ai/v1/systemone`, and it speaks the
|
||||
TypeSafe Jev protocol, so existing Jev clients work unchanged.
|
||||
|
||||
+369
-16
@@ -1888,53 +1888,209 @@ html[dir="rtl"] .hx-landing-composer textarea {
|
||||
/* -- Music (/music) -------------------------------------------------------- */
|
||||
|
||||
/* The music page reuses the image form controls; these cover what images
|
||||
* has no counterpart for: labelled text areas and a job list. */
|
||||
* has no counterpart for. */
|
||||
.hx-music-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.hx-music-field textarea,
|
||||
.hx-music input[type="number"] {
|
||||
.hx-music-field textarea {
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
padding: 0.45rem 0.6rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
min-height: 36px;
|
||||
resize: vertical;
|
||||
}
|
||||
.hx-music input[type="number"] {
|
||||
width: 7rem;
|
||||
/* Style beside lyrics on a wide screen: lyrics are short lines, so a
|
||||
* full-width box wastes most of its width. Both boxes share one height. */
|
||||
.hx-music-words {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hx-music-jobs {
|
||||
@media (min-width: 768px) {
|
||||
.hx-music-words.two {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
.hx-music-words.two .hx-music-field textarea {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
.hx-music-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.hx-music-tag {
|
||||
padding: 0.2rem 0.65rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
.hx-music-tag:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.hx-music-tag.on {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-accent-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.hx-music-length {
|
||||
flex: 1 1 14rem;
|
||||
max-width: 22rem;
|
||||
}
|
||||
.hx-music-length strong {
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hx-music-length input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
/* A disabled generate button must read as unavailable at a glance, not
|
||||
* as a slightly faded gradient. */
|
||||
.hx-music .hx-music-generate:disabled {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
box-shadow: inset 0 0 0 1px var(--color-border-subtle);
|
||||
opacity: 1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* Phone widths: the history button sits over the title's start. */
|
||||
@media (max-width: 768px) {
|
||||
.hx-music > h1 {
|
||||
padding-inline-start: 2.75rem;
|
||||
}
|
||||
}
|
||||
.hx-music-blocker {
|
||||
margin: -0.4rem 0 0;
|
||||
text-align: end;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.hx-music-spin {
|
||||
animation: hx-music-turn 0.9s linear infinite;
|
||||
}
|
||||
@keyframes hx-music-turn {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* The studio: what is happening now, below the form. Empty on arrival. */
|
||||
.hx-music-studio {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.hx-music-job {
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
.hx-music-studio:empty {
|
||||
display: none;
|
||||
}
|
||||
.hx-music-activity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.hx-music-job {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 0.9rem;
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
.hx-music-cover {
|
||||
width: 5.5rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
/* A record's label and groove sheen over the gradient. */
|
||||
.hx-music-cover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 18%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 0.35) 0 12%, transparent 13% 100%),
|
||||
repeating-radial-gradient(circle, rgb(0 0 0 / 0.12) 0 1px, transparent 1px 4px);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
.hx-music-cover .hx-music-eq {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #fff;
|
||||
height: 1.6rem;
|
||||
}
|
||||
.hx-music-job-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.hx-music-job header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hx-music-title {
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hx-music-close {
|
||||
flex-shrink: 0;
|
||||
margin-block-start: -0.25rem;
|
||||
}
|
||||
.hx-music-prompt {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hx-music-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.hx-music-status {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.hx-music-running .hx-music-status,
|
||||
.hx-music-queued .hx-music-status {
|
||||
@@ -1951,9 +2107,206 @@ html[dir="rtl"] .hx-landing-composer textarea {
|
||||
.hx-music-job .hx-image-card-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
.hx-music-job {
|
||||
grid-template-columns: 3.5rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.hx-music-cover {
|
||||
width: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Equaliser bars: the "something is being made" signal. */
|
||||
.hx-music-eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 1.1rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.hx-music-eq i {
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
transform-origin: bottom;
|
||||
animation: hx-music-bounce 0.9s ease-in-out infinite;
|
||||
}
|
||||
.hx-music-eq i:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.hx-music-eq i:nth-child(3) {
|
||||
animation-delay: -0.6s;
|
||||
}
|
||||
.hx-music-eq i:nth-child(4) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
@keyframes hx-music-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.25);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* The player: a round play button, a waveform that is the seek bar, the
|
||||
* time. */
|
||||
.hx-music-player {
|
||||
width: min(100%, 24rem);
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
.hx-music-play {
|
||||
flex-shrink: 0;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--gradient-helix);
|
||||
color: #fff;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.hx-music-play:hover:not(:disabled) {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
html[dir="rtl"] .hx-music-play svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.hx-music-wave {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 2.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hx-music-wave i {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
border-radius: 1px;
|
||||
background-color: var(--color-border-strong);
|
||||
transition: background-color 80ms linear;
|
||||
}
|
||||
.hx-music-wave i.on {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
.hx-music-player.standin .hx-music-wave i {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.hx-music-wave:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.hx-music-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hx-music-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgb(255 255 255 / 0.4);
|
||||
border-top-color: #fff;
|
||||
animation: hx-music-turn 0.8s linear infinite;
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
.hx-music-time {
|
||||
display: none;
|
||||
}
|
||||
.hx-music-wave {
|
||||
gap: 1px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hx-music-eq i,
|
||||
.hx-music-spin,
|
||||
.hx-music-spinner {
|
||||
animation: none;
|
||||
}
|
||||
.hx-music-eq i {
|
||||
transform: scaleY(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* History sidebar (#402): the chat sidebar, with collapsible sections. */
|
||||
.hx-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
.hx-group-count {
|
||||
margin-inline-start: auto;
|
||||
padding-inline: 0.4rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
html[dir="rtl"] .hx-flip-rtl {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.hx-music-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.hx-music-when {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.hx-music-dot {
|
||||
flex-shrink: 0;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-border-strong);
|
||||
}
|
||||
.hx-music-dot-succeeded {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
.hx-music-dot-queued,
|
||||
.hx-music-dot-running {
|
||||
background-color: var(--color-accent);
|
||||
animation: hx-music-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.hx-music-dot-failed {
|
||||
background-color: var(--color-accent-hot);
|
||||
}
|
||||
.hx-music-swatch {
|
||||
flex-shrink: 0;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
@keyframes hx-music-pulse {
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hx-music-dot-queued,
|
||||
.hx-music-dot-running {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Documentation (/docs) ------------------------------------------------ */
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function App() {
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/images" element={<Images />} />
|
||||
<Route path="/music" element={<Music />} />
|
||||
<Route path="/playground" element={<Playground />} />
|
||||
<Route path="/decisions" element={<Playground />} />
|
||||
{/* Documentation. The splat route lets the sidebar tree be
|
||||
arbitrarily deep without enumerating routes here — the
|
||||
page resolves the slug against the content tree. */}
|
||||
|
||||
@@ -115,12 +115,12 @@ const Header: React.FC = () => {
|
||||
{t("nav.music")}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/playground"
|
||||
to="/decisions"
|
||||
className={({ isActive }): string =>
|
||||
isActive ? "nav-link active" : "nav-link"
|
||||
}
|
||||
>
|
||||
{t("nav.playground")}
|
||||
{t("nav.decisions")}
|
||||
</NavLink>
|
||||
{/* Documentation is reachable on every host — this only
|
||||
decides whether it is advertised. The pages are still
|
||||
|
||||
@@ -106,6 +106,20 @@ export interface GeneratedImage {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local organisation of music jobs (#402). The jobs and their audio live on
|
||||
* the server; which project a song is filed under, and any name given to
|
||||
* it, are this browser's own, like chat projects.
|
||||
*/
|
||||
export interface MusicTrack {
|
||||
/** The server's job id. */
|
||||
jobId: string;
|
||||
owner: string;
|
||||
projectId: string | null; // null → filed by date
|
||||
/** A name the user gave the song; the prompt is shown otherwise. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Small key/value store: fingerprint, active conversation, anon usage. */
|
||||
export interface Meta {
|
||||
key: string;
|
||||
@@ -131,6 +145,8 @@ class HelexaDB extends Dexie {
|
||||
messages!: Table<Message, string>;
|
||||
meta!: Table<Meta, string>;
|
||||
images!: Table<GeneratedImage, string>;
|
||||
musicProjects!: Table<Project, string>;
|
||||
musicTracks!: Table<MusicTrack, string>;
|
||||
|
||||
constructor() {
|
||||
super("helexa");
|
||||
@@ -166,6 +182,13 @@ class HelexaDB extends Dexie {
|
||||
}
|
||||
}),
|
||||
);
|
||||
// v4 adds music projects and per-song filing (#402). Additive. Music
|
||||
// projects are their own store: a chat topic and a music project are
|
||||
// different things, and sharing one list would mix them.
|
||||
this.version(4).stores({
|
||||
musicProjects: "id, owner, [owner+archived], updatedAt",
|
||||
musicTracks: "jobId, owner, projectId",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type GeneratedImage,
|
||||
type Message,
|
||||
type MessageRole,
|
||||
type MusicTrack,
|
||||
type Project,
|
||||
type TurnBlock,
|
||||
} from "./db";
|
||||
@@ -193,3 +194,66 @@ export async function saveImage(
|
||||
export async function deleteImage(id: string): Promise<void> {
|
||||
await db.images.delete(id);
|
||||
}
|
||||
|
||||
// ── music projects (#402) ───────────────────────────────────────────
|
||||
|
||||
export async function listMusicProjects(owner: string): Promise<Project[]> {
|
||||
const rows = await db.musicProjects.where({ owner }).toArray();
|
||||
return rows
|
||||
.filter((p) => !p.archived)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt);
|
||||
}
|
||||
|
||||
export async function createMusicProject(owner: string, name: string): Promise<string> {
|
||||
const id = uuid();
|
||||
const ts = now();
|
||||
await db.musicProjects.add({
|
||||
id,
|
||||
owner,
|
||||
name,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
archived: false,
|
||||
sortOrder: ts,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function renameMusicProject(id: string, name: string): Promise<void> {
|
||||
await db.musicProjects.update(id, { name, updatedAt: now() });
|
||||
}
|
||||
|
||||
export async function archiveMusicProject(id: string): Promise<void> {
|
||||
// Its songs go back to the date sections rather than disappearing.
|
||||
await db.transaction("rw", db.musicProjects, db.musicTracks, async () => {
|
||||
await db.musicProjects.update(id, { archived: true, updatedAt: now() });
|
||||
await db.musicTracks.where({ projectId: id }).modify({ projectId: null });
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMusicTracks(owner: string): Promise<MusicTrack[]> {
|
||||
return db.musicTracks.where({ owner }).toArray();
|
||||
}
|
||||
|
||||
/** File a song under a project, or back under its date with `null`. */
|
||||
export async function moveMusicTrack(
|
||||
owner: string,
|
||||
jobId: string,
|
||||
projectId: string | null,
|
||||
): Promise<void> {
|
||||
const existing = await db.musicTracks.get(jobId);
|
||||
await db.musicTracks.put({ ...existing, jobId, owner, projectId });
|
||||
}
|
||||
|
||||
export async function renameMusicTrack(owner: string, jobId: string, title: string): Promise<void> {
|
||||
const existing = await db.musicTracks.get(jobId);
|
||||
await db.musicTracks.put({ projectId: null, ...existing, jobId, owner, title });
|
||||
}
|
||||
|
||||
/** Drop filing for songs the server no longer has (expired or never seen). */
|
||||
export async function pruneMusicTracks(owner: string, liveJobIds: Set<string>): Promise<void> {
|
||||
await db.musicTracks
|
||||
.where({ owner })
|
||||
.filter((t) => !liveJobIds.has(t.jobId))
|
||||
.delete();
|
||||
}
|
||||
|
||||
@@ -64,9 +64,9 @@ describe("decisions translations", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(Object.keys(COMMON))("%s names the playground in the nav", (lang) => {
|
||||
it.each(Object.keys(COMMON))("%s names the decisions page in the nav", (lang) => {
|
||||
const nav = COMMON[lang].nav as Tree;
|
||||
expect(typeof nav.playground).toBe("string");
|
||||
expect((nav.playground as string).trim()).not.toBe("");
|
||||
expect(typeof nav.decisions).toBe("string");
|
||||
expect((nav.decisions as string).trim()).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "ውይይት",
|
||||
"images": "ምስሎች",
|
||||
"music": "ሙዚቃ",
|
||||
"playground": "የሙከራ ሜዳ",
|
||||
"decisions": "ውሳኔዎች",
|
||||
"mission": "ተልእኮ",
|
||||
"login": "ይግቡ",
|
||||
"register": "ይመዝገቡ",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "የሙከራ ሜዳ",
|
||||
"lead": "ለውሳኔ ሞዴል ስለማንኛውም ጽሑፍ ወይም JSON ዓይነት ያላቸው ጥያቄዎችን ይጠይቁ፤ በአንድ ዙር የተስተካከሉ መልሶችን ያግኙ — ያለ የመነጨ ጽሑፍ።",
|
||||
"title": "ውሳኔዎች",
|
||||
"lead": "ስለማንኛውም ጽሑፍ ወይም JSON አዎ/አይደለም፣ ባለብዙ ምርጫ ወይም የነጥብ ጥያቄዎችን ይጠይቁ፤ በተስተካከለ እርግጠኝነት ፈጣን መልሶችን ያግኙ። እንደዚህ ያሉ ሞዴሎች አንዳንድ ጊዜ System One ሞዴሎች ይባላሉ።",
|
||||
"docsLink": "እንዴት እንደሚሠራ",
|
||||
"side": "ውጤቶች፣ ምሳሌዎች እና ኮድ",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "ሰርዝ",
|
||||
"cost": "ወጪ",
|
||||
"errorGeneric": "ዘፈኑን መፍጠር አልተቻለም።",
|
||||
"errorLoad": "ድምጹን መጫን አልተቻለም።"
|
||||
"errorLoad": "ድምጹን መጫን አልተቻለም።",
|
||||
"history": "ታሪክ",
|
||||
"period": {
|
||||
"today": "ዛሬ",
|
||||
"yesterday": "ትናንት",
|
||||
"week": "ያለፉት 7 ቀናት",
|
||||
"month": "ያለፉት 30 ቀናት",
|
||||
"older": "የቆዩ"
|
||||
},
|
||||
"length": "ርዝመት",
|
||||
"tags": "የስልት ሃሳቦች",
|
||||
"submitting": "ዘፈንዎ ወደ ስቱዲዮ እየተላከ ነው…",
|
||||
"needStyle": "ዘፈን ለመፍጠር ስልት ይግለጹ።",
|
||||
"needLength": "ከ{{min}} እስከ {{max}} ሰከንድ ያለ ርዝመት ይምረጡ።",
|
||||
"close": "ዝጋ",
|
||||
"pause": "ለአፍታ አቁም",
|
||||
"seek": "በዘፈኑ ውስጥ ያለ ቦታ"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "المحادثة",
|
||||
"images": "الصور",
|
||||
"music": "الموسيقى",
|
||||
"playground": "ساحة التجربة",
|
||||
"decisions": "القرارات",
|
||||
"mission": "المهمة",
|
||||
"login": "تسجيل الدخول",
|
||||
"register": "إنشاء حساب",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "ساحة التجربة",
|
||||
"lead": "اطرح على نموذج قرارات أسئلة محدّدة النوع عن أي نص أو JSON، واحصل على إجابات معايَرة في تمريرة واحدة — دون نص مولَّد.",
|
||||
"title": "القرارات",
|
||||
"lead": "اطرح أسئلة بنعم/لا أو اختيار من متعدد أو تقييم حول أي نص أو JSON، واحصل فورًا على إجابات بثقة معايَرة. تُسمّى هذه النماذج أحيانًا نماذج System One.",
|
||||
"docsLink": "كيف يعمل",
|
||||
"side": "النتائج والأمثلة والشيفرة",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "إلغاء",
|
||||
"cost": "التكلفة",
|
||||
"errorGeneric": "تعذّر إنشاء الأغنية.",
|
||||
"errorLoad": "تعذّر تحميل الصوت."
|
||||
"errorLoad": "تعذّر تحميل الصوت.",
|
||||
"history": "السجل",
|
||||
"period": {
|
||||
"today": "اليوم",
|
||||
"yesterday": "أمس",
|
||||
"week": "آخر 7 أيام",
|
||||
"month": "آخر 30 يومًا",
|
||||
"older": "أقدم"
|
||||
},
|
||||
"length": "المدة",
|
||||
"tags": "أفكار للأسلوب",
|
||||
"submitting": "جارٍ إرسال أغنيتك إلى الاستوديو…",
|
||||
"needStyle": "صِف أسلوبًا لإنشاء أغنية.",
|
||||
"needLength": "اختر مدة بين {{min}} و{{max}} ثانية.",
|
||||
"close": "إغلاق",
|
||||
"pause": "إيقاف مؤقت",
|
||||
"seek": "الموضع في الأغنية"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Чат",
|
||||
"images": "Изображения",
|
||||
"music": "Музика",
|
||||
"playground": "Пясъчник",
|
||||
"decisions": "Решения",
|
||||
"mission": "Мисия",
|
||||
"login": "Вход",
|
||||
"register": "Регистрация",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Пясъчник",
|
||||
"lead": "Задавайте на модел за вземане на решения типизирани въпроси за всякакъв текст или JSON и получавайте калибрирани отговори с едно преминаване — без генериран текст.",
|
||||
"title": "Решения",
|
||||
"lead": "Задавайте въпроси с да/не, с избор между възможности или с оценка за всякакъв текст или JSON и получавайте мигновено отговори с калибрирана увереност. Такива модели понякога се наричат модели System One.",
|
||||
"docsLink": "Как работи",
|
||||
"side": "Резултати, примери и код",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Отказ",
|
||||
"cost": "Цена",
|
||||
"errorGeneric": "Песента не можа да бъде създадена.",
|
||||
"errorLoad": "Аудиото не можа да бъде заредено."
|
||||
"errorLoad": "Аудиото не можа да бъде заредено.",
|
||||
"history": "История",
|
||||
"period": {
|
||||
"today": "Днес",
|
||||
"yesterday": "Вчера",
|
||||
"week": "Последните 7 дни",
|
||||
"month": "Последните 30 дни",
|
||||
"older": "По-стари"
|
||||
},
|
||||
"length": "Продължителност",
|
||||
"tags": "Идеи за стил",
|
||||
"submitting": "Изпращане на песента ви към студиото…",
|
||||
"needStyle": "Опишете стил, за да генерирате песен.",
|
||||
"needLength": "Изберете продължителност между {{min}} и {{max}} секунди.",
|
||||
"close": "Затвори",
|
||||
"pause": "Пауза",
|
||||
"seek": "Позиция в песента"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Xat",
|
||||
"images": "Imatges",
|
||||
"music": "Música",
|
||||
"playground": "Espai de proves",
|
||||
"decisions": "Decisions",
|
||||
"mission": "Missió",
|
||||
"login": "Inicia la sessió",
|
||||
"register": "Registra't",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Espai de proves",
|
||||
"lead": "Feu a un model de decisió preguntes tipades sobre qualsevol text o JSON i obtingueu respostes calibrades en una sola passada, sense text generat.",
|
||||
"title": "Decisions",
|
||||
"lead": "Feu preguntes de sí/no, d’opció múltiple o de puntuació sobre qualsevol text o JSON i obteniu a l’instant respostes amb una confiança calibrada. De vegades se’n diu models System One.",
|
||||
"docsLink": "Com funciona",
|
||||
"side": "Resultats, exemples i codi",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Cancel·la",
|
||||
"cost": "Cost",
|
||||
"errorGeneric": "No s’ha pogut generar la cançó.",
|
||||
"errorLoad": "No s’ha pogut carregar l’àudio."
|
||||
"errorLoad": "No s’ha pogut carregar l’àudio.",
|
||||
"history": "Historial",
|
||||
"period": {
|
||||
"today": "Avui",
|
||||
"yesterday": "Ahir",
|
||||
"week": "Darrers 7 dies",
|
||||
"month": "Darrers 30 dies",
|
||||
"older": "Més antics"
|
||||
},
|
||||
"length": "Durada",
|
||||
"tags": "Idees d’estil",
|
||||
"submitting": "S’està enviant la cançó a l’estudi…",
|
||||
"needStyle": "Descriviu un estil per generar una cançó.",
|
||||
"needLength": "Trieu una durada entre {{min}} i {{max}} segons.",
|
||||
"close": "Tanca",
|
||||
"pause": "Pausa",
|
||||
"seek": "Posició a la cançó"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Chat",
|
||||
"images": "Obrázky",
|
||||
"music": "Hudba",
|
||||
"playground": "Pískoviště",
|
||||
"decisions": "Rozhodnutí",
|
||||
"mission": "Mise",
|
||||
"login": "Přihlásit se",
|
||||
"register": "Registrovat se",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Pískoviště",
|
||||
"lead": "Pokládejte rozhodovacímu modelu typované otázky o libovolném textu nebo JSON a dostávejte kalibrované odpovědi v jediném průchodu — bez generovaného textu.",
|
||||
"title": "Rozhodnutí",
|
||||
"lead": "Ptejte se otázkami ano/ne, s výběrem z možností nebo s hodnocením na libovolný text či JSON a okamžitě získejte odpovědi s kalibrovanou jistotou. Takovým modelům se někdy říká modely System One.",
|
||||
"docsLink": "Jak to funguje",
|
||||
"side": "Výsledky, příklady a kód",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Zrušit",
|
||||
"cost": "Cena",
|
||||
"errorGeneric": "Skladbu se nepodařilo vygenerovat.",
|
||||
"errorLoad": "Zvuk se nepodařilo načíst."
|
||||
"errorLoad": "Zvuk se nepodařilo načíst.",
|
||||
"history": "Historie",
|
||||
"period": {
|
||||
"today": "Dnes",
|
||||
"yesterday": "Včera",
|
||||
"week": "Posledních 7 dní",
|
||||
"month": "Posledních 30 dní",
|
||||
"older": "Starší"
|
||||
},
|
||||
"length": "Délka",
|
||||
"tags": "Nápady na styl",
|
||||
"submitting": "Odesílám skladbu do studia…",
|
||||
"needStyle": "Popište styl, abyste mohli vygenerovat skladbu.",
|
||||
"needLength": "Zvolte délku mezi {{min}} a {{max}} sekundami.",
|
||||
"close": "Zavřít",
|
||||
"pause": "Pozastavit",
|
||||
"seek": "Pozice ve skladbě"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Chat",
|
||||
"images": "Billeder",
|
||||
"music": "Musik",
|
||||
"playground": "Legeplads",
|
||||
"decisions": "Beslutninger",
|
||||
"mission": "Mission",
|
||||
"login": "Log ind",
|
||||
"register": "Opret konto",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Legeplads",
|
||||
"lead": "Stil en beslutningsmodel typede spørgsmål om enhver tekst eller JSON, og få kalibrerede svar i ét gennemløb — uden genereret tekst.",
|
||||
"title": "Beslutninger",
|
||||
"lead": "Stil ja/nej-, multiple choice- eller pointspørgsmål om enhver tekst eller JSON, og få straks svar med kalibreret sikkerhed. Den slags modeller kaldes nogle gange System One-modeller.",
|
||||
"docsLink": "Sådan virker det",
|
||||
"side": "Resultater, eksempler og kode",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Annullér",
|
||||
"cost": "Pris",
|
||||
"errorGeneric": "Sangen kunne ikke genereres.",
|
||||
"errorLoad": "Lyden kunne ikke indlæses."
|
||||
"errorLoad": "Lyden kunne ikke indlæses.",
|
||||
"history": "Historik",
|
||||
"period": {
|
||||
"today": "I dag",
|
||||
"yesterday": "I går",
|
||||
"week": "Seneste 7 dage",
|
||||
"month": "Seneste 30 dage",
|
||||
"older": "Ældre"
|
||||
},
|
||||
"length": "Længde",
|
||||
"tags": "Stilidéer",
|
||||
"submitting": "Sender din sang til studiet…",
|
||||
"needStyle": "Beskriv en stil for at generere en sang.",
|
||||
"needLength": "Vælg en længde mellem {{min}} og {{max}} sekunder.",
|
||||
"close": "Luk",
|
||||
"pause": "Pause",
|
||||
"seek": "Position i sangen"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Chat",
|
||||
"images": "Bilder",
|
||||
"music": "Musik",
|
||||
"playground": "Playground",
|
||||
"decisions": "Entscheidungen",
|
||||
"mission": "Mission",
|
||||
"login": "Anmelden",
|
||||
"register": "Registrieren",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Playground",
|
||||
"lead": "Stellen Sie einem Entscheidungsmodell typisierte Fragen zu beliebigem Text oder JSON und erhalten Sie kalibrierte Antworten in einem Durchlauf – ganz ohne generierten Text.",
|
||||
"title": "Entscheidungen",
|
||||
"lead": "Stellen Sie Ja/Nein-, Auswahl- oder Bewertungsfragen zu beliebigem Text oder JSON und erhalten Sie sofort Antworten mit kalibrierter Konfidenz. Solche Modelle werden manchmal System-One-Modelle genannt.",
|
||||
"docsLink": "So funktioniert es",
|
||||
"side": "Ergebnisse, Beispiele und Code",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Abbrechen",
|
||||
"cost": "Kosten",
|
||||
"errorGeneric": "Der Song konnte nicht erzeugt werden.",
|
||||
"errorLoad": "Die Audiodatei konnte nicht geladen werden."
|
||||
"errorLoad": "Die Audiodatei konnte nicht geladen werden.",
|
||||
"history": "Verlauf",
|
||||
"period": {
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"week": "Letzte 7 Tage",
|
||||
"month": "Letzte 30 Tage",
|
||||
"older": "Älter"
|
||||
},
|
||||
"length": "Länge",
|
||||
"tags": "Stilideen",
|
||||
"submitting": "Ihr Song wird ans Studio gesendet…",
|
||||
"needStyle": "Beschreiben Sie einen Stil, um einen Song zu erzeugen.",
|
||||
"needLength": "Wählen Sie eine Länge zwischen {{min}} und {{max}} Sekunden.",
|
||||
"close": "Schließen",
|
||||
"pause": "Pause",
|
||||
"seek": "Position im Song"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Συνομιλία",
|
||||
"images": "Εικόνες",
|
||||
"music": "Μουσική",
|
||||
"playground": "Πεδίο δοκιμών",
|
||||
"decisions": "Αποφάσεις",
|
||||
"mission": "Αποστολή",
|
||||
"login": "Σύνδεση",
|
||||
"register": "Εγγραφή",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Πεδίο δοκιμών",
|
||||
"lead": "Κάντε σε ένα μοντέλο αποφάσεων τυποποιημένες ερωτήσεις για οποιοδήποτε κείμενο ή JSON και λάβετε βαθμονομημένες απαντήσεις σε ένα μόνο πέρασμα — χωρίς παραγόμενο κείμενο.",
|
||||
"title": "Αποφάσεις",
|
||||
"lead": "Κάντε ερωτήσεις ναι/όχι, πολλαπλής επιλογής ή βαθμολόγησης για οποιοδήποτε κείμενο ή JSON και λάβετε αμέσως απαντήσεις με βαθμονομημένη βεβαιότητα. Τέτοια μοντέλα ονομάζονται μερικές φορές μοντέλα System One.",
|
||||
"docsLink": "Πώς λειτουργεί",
|
||||
"side": "Αποτελέσματα, παραδείγματα και κώδικας",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Ακύρωση",
|
||||
"cost": "Κόστος",
|
||||
"errorGeneric": "Δεν ήταν δυνατή η δημιουργία του τραγουδιού.",
|
||||
"errorLoad": "Δεν ήταν δυνατή η φόρτωση του ήχου."
|
||||
"errorLoad": "Δεν ήταν δυνατή η φόρτωση του ήχου.",
|
||||
"history": "Ιστορικό",
|
||||
"period": {
|
||||
"today": "Σήμερα",
|
||||
"yesterday": "Χθες",
|
||||
"week": "Τελευταίες 7 ημέρες",
|
||||
"month": "Τελευταίες 30 ημέρες",
|
||||
"older": "Παλαιότερα"
|
||||
},
|
||||
"length": "Διάρκεια",
|
||||
"tags": "Ιδέες ύφους",
|
||||
"submitting": "Αποστολή του τραγουδιού σας στο στούντιο…",
|
||||
"needStyle": "Περιγράψτε ένα ύφος για να δημιουργήσετε τραγούδι.",
|
||||
"needLength": "Επιλέξτε διάρκεια από {{min}} έως {{max}} δευτερόλεπτα.",
|
||||
"close": "Κλείσιμο",
|
||||
"pause": "Παύση",
|
||||
"seek": "Θέση στο τραγούδι"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Chat",
|
||||
"images": "Images",
|
||||
"music": "Music",
|
||||
"playground": "Playground",
|
||||
"decisions": "Decisions",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Playground",
|
||||
"lead": "Ask a decision model typed questions about any text or JSON, and get calibrated answers in one pass — no generated text.",
|
||||
"title": "Decisions",
|
||||
"lead": "Ask yes/no, multiple-choice or scoring questions about any text or JSON, and get instant answers with calibrated confidence. Models like this are sometimes called System One models.",
|
||||
"docsLink": "How it works",
|
||||
"side": "Results, examples and code",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Cancel",
|
||||
"cost": "Cost",
|
||||
"errorGeneric": "The song could not be generated.",
|
||||
"errorLoad": "The audio could not be loaded."
|
||||
"errorLoad": "The audio could not be loaded.",
|
||||
"history": "History",
|
||||
"period": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"week": "Previous 7 days",
|
||||
"month": "Previous 30 days",
|
||||
"older": "Older"
|
||||
},
|
||||
"length": "Length",
|
||||
"tags": "Style ideas",
|
||||
"submitting": "Sending your song to the studio…",
|
||||
"needStyle": "Describe a style to generate a song.",
|
||||
"needLength": "Choose a length between {{min}} and {{max}} seconds.",
|
||||
"close": "Close",
|
||||
"pause": "Pause",
|
||||
"seek": "Position in the song"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Chat",
|
||||
"images": "Imágenes",
|
||||
"music": "Música",
|
||||
"playground": "Zona de pruebas",
|
||||
"decisions": "Decisiones",
|
||||
"mission": "Misión",
|
||||
"login": "Iniciar sesión",
|
||||
"register": "Registrarse",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Zona de pruebas",
|
||||
"lead": "Haga a un modelo de decisión preguntas tipadas sobre cualquier texto o JSON y obtenga respuestas calibradas en una sola pasada, sin texto generado.",
|
||||
"title": "Decisiones",
|
||||
"lead": "Haga preguntas de sí/no, de opción múltiple o de puntuación sobre cualquier texto o JSON y obtenga al instante respuestas con una confianza calibrada. A estos modelos a veces se les llama modelos System One.",
|
||||
"docsLink": "Cómo funciona",
|
||||
"side": "Resultados, ejemplos y código",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Cancelar",
|
||||
"cost": "Coste",
|
||||
"errorGeneric": "No se pudo generar la canción.",
|
||||
"errorLoad": "No se pudo cargar el audio."
|
||||
"errorLoad": "No se pudo cargar el audio.",
|
||||
"history": "Historial",
|
||||
"period": {
|
||||
"today": "Hoy",
|
||||
"yesterday": "Ayer",
|
||||
"week": "Últimos 7 días",
|
||||
"month": "Últimos 30 días",
|
||||
"older": "Anteriores"
|
||||
},
|
||||
"length": "Duración",
|
||||
"tags": "Ideas de estilo",
|
||||
"submitting": "Enviando su canción al estudio…",
|
||||
"needStyle": "Describa un estilo para generar una canción.",
|
||||
"needLength": "Elija una duración entre {{min}} y {{max}} segundos.",
|
||||
"close": "Cerrar",
|
||||
"pause": "Pausa",
|
||||
"seek": "Posición en la canción"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Vestlus",
|
||||
"images": "Pildid",
|
||||
"music": "Muusika",
|
||||
"playground": "Mänguväljak",
|
||||
"decisions": "Otsused",
|
||||
"mission": "Missioon",
|
||||
"login": "Logi sisse",
|
||||
"register": "Registreeru",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Mänguväljak",
|
||||
"lead": "Esitage otsustusmudelile tüübitud küsimusi mis tahes teksti või JSON-i kohta ja saage kalibreeritud vastused ühe läbimisega — ilma genereeritud tekstita.",
|
||||
"title": "Otsused",
|
||||
"lead": "Esita jah/ei-, valikvastustega või hindamisküsimusi mis tahes teksti või JSON-i kohta ja saa kohe vastused kalibreeritud kindlusega. Selliseid mudeleid nimetatakse mõnikord System One mudeliteks.",
|
||||
"docsLink": "Kuidas see töötab",
|
||||
"side": "Tulemused, näited ja kood",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Tühista",
|
||||
"cost": "Kulu",
|
||||
"errorGeneric": "Laulu ei õnnestunud luua.",
|
||||
"errorLoad": "Heli ei õnnestunud laadida."
|
||||
"errorLoad": "Heli ei õnnestunud laadida.",
|
||||
"history": "Ajalugu",
|
||||
"period": {
|
||||
"today": "Täna",
|
||||
"yesterday": "Eile",
|
||||
"week": "Viimased 7 päeva",
|
||||
"month": "Viimased 30 päeva",
|
||||
"older": "Vanemad"
|
||||
},
|
||||
"length": "Pikkus",
|
||||
"tags": "Stiiliideed",
|
||||
"submitting": "Saadan su laulu stuudiosse…",
|
||||
"needStyle": "Laulu loomiseks kirjelda stiili.",
|
||||
"needLength": "Vali pikkus vahemikus {{min}}–{{max}} sekundit.",
|
||||
"close": "Sulge",
|
||||
"pause": "Paus",
|
||||
"seek": "Asukoht laulus"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "گفتگو",
|
||||
"images": "تصاویر",
|
||||
"music": "موسیقی",
|
||||
"playground": "زمین آزمایش",
|
||||
"decisions": "تصمیمها",
|
||||
"mission": "مأموریت",
|
||||
"login": "ورود",
|
||||
"register": "ثبتنام",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "زمین آزمایش",
|
||||
"lead": "از یک مدل تصمیمگیری دربارهٔ هر متن یا JSON پرسشهای نوعدار بپرسید و در یک گذر پاسخهای کالیبرهشده بگیرید — بدون متن تولیدشده.",
|
||||
"title": "تصمیمها",
|
||||
"lead": "دربارهٔ هر متن یا JSON پرسشهای بله/خیر، چندگزینهای یا امتیازدهی بپرسید و فوراً پاسخهایی با اطمینان کالیبرهشده بگیرید. به چنین مدلهایی گاهی مدلهای System One میگویند.",
|
||||
"docsLink": "چطور کار میکند",
|
||||
"side": "نتایج، نمونهها و کد",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "لغو",
|
||||
"cost": "هزینه",
|
||||
"errorGeneric": "ساخت آهنگ ممکن نشد.",
|
||||
"errorLoad": "بارگذاری صدا ممکن نشد."
|
||||
"errorLoad": "بارگذاری صدا ممکن نشد.",
|
||||
"history": "تاریخچه",
|
||||
"period": {
|
||||
"today": "امروز",
|
||||
"yesterday": "دیروز",
|
||||
"week": "۷ روز گذشته",
|
||||
"month": "۳۰ روز گذشته",
|
||||
"older": "قدیمیتر"
|
||||
},
|
||||
"length": "مدت",
|
||||
"tags": "ایدههای سبک",
|
||||
"submitting": "در حال ارسال آهنگ شما به استودیو…",
|
||||
"needStyle": "برای ساخت آهنگ، یک سبک توصیف کنید.",
|
||||
"needLength": "مدتی بین {{min}} تا {{max}} ثانیه انتخاب کنید.",
|
||||
"close": "بستن",
|
||||
"pause": "مکث",
|
||||
"seek": "موقعیت در آهنگ"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Keskustelu",
|
||||
"images": "Kuvat",
|
||||
"music": "Musiikki",
|
||||
"playground": "Hiekkalaatikko",
|
||||
"decisions": "Päätökset",
|
||||
"mission": "Missio",
|
||||
"login": "Kirjaudu sisään",
|
||||
"register": "Rekisteröidy",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Hiekkalaatikko",
|
||||
"lead": "Esitä päätösmallille tyypitettyjä kysymyksiä mistä tahansa tekstistä tai JSONista ja saat kalibroidut vastaukset yhdellä läpikäynnillä — ilman tuotettua tekstiä.",
|
||||
"title": "Päätökset",
|
||||
"lead": "Esitä kyllä/ei-, monivalinta- tai pisteytyskysymyksiä mistä tahansa tekstistä tai JSONista ja saat heti vastaukset kalibroidulla varmuudella. Tällaisia malleja kutsutaan joskus System One -malleiksi.",
|
||||
"docsLink": "Miten se toimii",
|
||||
"side": "Tulokset, esimerkit ja koodi",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Peruuta",
|
||||
"cost": "Hinta",
|
||||
"errorGeneric": "Kappaletta ei voitu luoda.",
|
||||
"errorLoad": "Ääntä ei voitu ladata."
|
||||
"errorLoad": "Ääntä ei voitu ladata.",
|
||||
"history": "Historia",
|
||||
"period": {
|
||||
"today": "Tänään",
|
||||
"yesterday": "Eilen",
|
||||
"week": "Edelliset 7 päivää",
|
||||
"month": "Edelliset 30 päivää",
|
||||
"older": "Vanhemmat"
|
||||
},
|
||||
"length": "Pituus",
|
||||
"tags": "Tyyli-ideoita",
|
||||
"submitting": "Kappaletta lähetetään studioon…",
|
||||
"needStyle": "Kuvaile tyyli luodaksesi kappaleen.",
|
||||
"needLength": "Valitse pituus väliltä {{min}}–{{max}} sekuntia.",
|
||||
"close": "Sulje",
|
||||
"pause": "Tauko",
|
||||
"seek": "Kohta kappaleessa"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Chat",
|
||||
"images": "Images",
|
||||
"music": "Musique",
|
||||
"playground": "Bac à sable",
|
||||
"decisions": "Décisions",
|
||||
"mission": "Mission",
|
||||
"login": "Connexion",
|
||||
"register": "Inscription",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Bac à sable",
|
||||
"lead": "Posez à un modèle de décision des questions typées sur n’importe quel texte ou JSON, et obtenez des réponses calibrées en une seule passe — sans texte généré.",
|
||||
"title": "Décisions",
|
||||
"lead": "Posez des questions oui/non, à choix multiples ou de notation sur n’importe quel texte ou JSON, et obtenez instantanément des réponses avec une confiance calibrée. On appelle parfois ces modèles des modèles System One.",
|
||||
"docsLink": "Comment ça marche",
|
||||
"side": "Résultats, exemples et code",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Annuler",
|
||||
"cost": "Coût",
|
||||
"errorGeneric": "La chanson n’a pas pu être générée.",
|
||||
"errorLoad": "L’audio n’a pas pu être chargé."
|
||||
"errorLoad": "L’audio n’a pas pu être chargé.",
|
||||
"history": "Historique",
|
||||
"period": {
|
||||
"today": "Aujourd’hui",
|
||||
"yesterday": "Hier",
|
||||
"week": "7 derniers jours",
|
||||
"month": "30 derniers jours",
|
||||
"older": "Plus anciens"
|
||||
},
|
||||
"length": "Durée",
|
||||
"tags": "Idées de style",
|
||||
"submitting": "Envoi de votre chanson au studio…",
|
||||
"needStyle": "Décrivez un style pour générer une chanson.",
|
||||
"needLength": "Choisissez une durée entre {{min}} et {{max}} secondes.",
|
||||
"close": "Fermer",
|
||||
"pause": "Pause",
|
||||
"seek": "Position dans la chanson"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Comhrá",
|
||||
"images": "Íomhánna",
|
||||
"music": "Ceol",
|
||||
"playground": "Clós súgartha",
|
||||
"decisions": "Cinntí",
|
||||
"mission": "Misean",
|
||||
"login": "Sínigh isteach",
|
||||
"register": "Cláraigh",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Clós súgartha",
|
||||
"lead": "Cuir ceisteanna clóscríofa ar mhúnla cinntí faoi théacs nó JSON ar bith agus faigh freagraí calabraithe in aon phas amháin — gan téacs ginte.",
|
||||
"title": "Cinntí",
|
||||
"lead": "Cuir ceisteanna tá/níl, ilrogha nó scórála faoi théacs nó JSON ar bith agus faigh freagraí láithreacha le muinín calabraithe. Uaireanta tugtar samhlacha System One ar shamhlacha mar seo.",
|
||||
"docsLink": "Conas a oibríonn sé",
|
||||
"side": "Torthaí, samplaí agus cód",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Cealaigh",
|
||||
"cost": "Costas",
|
||||
"errorGeneric": "Níorbh fhéidir an t-amhrán a chruthú.",
|
||||
"errorLoad": "Níorbh fhéidir an fhuaim a lódáil."
|
||||
"errorLoad": "Níorbh fhéidir an fhuaim a lódáil.",
|
||||
"history": "Stair",
|
||||
"period": {
|
||||
"today": "Inniu",
|
||||
"yesterday": "Inné",
|
||||
"week": "Na 7 lá seo caite",
|
||||
"month": "Na 30 lá seo caite",
|
||||
"older": "Níos sine"
|
||||
},
|
||||
"length": "Fad",
|
||||
"tags": "Smaointe stíle",
|
||||
"submitting": "Do d’amhrán á sheoladh chuig an stiúideo…",
|
||||
"needStyle": "Déan cur síos ar stíl chun amhrán a ghiniúint.",
|
||||
"needLength": "Roghnaigh fad idir {{min}} agus {{max}} soicind.",
|
||||
"close": "Dún",
|
||||
"pause": "Sos",
|
||||
"seek": "Suíomh san amhrán"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Tattaunawa",
|
||||
"images": "Hotuna",
|
||||
"music": "Kiɗa",
|
||||
"playground": "Filin gwaji",
|
||||
"decisions": "Shawarwari",
|
||||
"mission": "Manufa",
|
||||
"login": "Shiga",
|
||||
"register": "Yi rajista",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Filin gwaji",
|
||||
"lead": "Yi wa samfurin yanke shawara tambayoyi masu nau’i game da kowane rubutu ko JSON, ka sami amsoshi da aka daidaita a zagaye ɗaya — ba tare da rubutun da aka ƙirƙira ba.",
|
||||
"title": "Shawarwari",
|
||||
"lead": "Yi tambayoyin e/a'a, na zaɓi daga zaɓuɓɓuka ko na maki game da kowane rubutu ko JSON, ka sami amsoshi nan take tare da tabbaci da aka daidaita. A wasu lokuta ana kiran irin waɗannan samfura samfuran System One.",
|
||||
"docsLink": "Yadda yake aiki",
|
||||
"side": "Sakamako, misalai da lamba",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Soke",
|
||||
"cost": "Farashi",
|
||||
"errorGeneric": "An kasa ƙirƙirar waƙar.",
|
||||
"errorLoad": "An kasa loda sautin."
|
||||
"errorLoad": "An kasa loda sautin.",
|
||||
"history": "Tarihi",
|
||||
"period": {
|
||||
"today": "Yau",
|
||||
"yesterday": "Jiya",
|
||||
"week": "Kwanaki 7 da suka gabata",
|
||||
"month": "Kwanaki 30 da suka gabata",
|
||||
"older": "Tsofaffi"
|
||||
},
|
||||
"length": "Tsawo",
|
||||
"tags": "Ra'ayoyin salo",
|
||||
"submitting": "Ana aika wakarka zuwa sitiriyo…",
|
||||
"needStyle": "Bayyana salo don ƙirƙirar waƙa.",
|
||||
"needLength": "Zaɓi tsawo tsakanin daƙiƙa {{min}} da {{max}}.",
|
||||
"close": "Rufe",
|
||||
"pause": "Dakata",
|
||||
"seek": "Matsayi a cikin waƙa"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "צ'אט",
|
||||
"images": "תמונות",
|
||||
"music": "מוזיקה",
|
||||
"playground": "ארגז חול",
|
||||
"decisions": "החלטות",
|
||||
"mission": "חזון",
|
||||
"login": "התחברות",
|
||||
"register": "הרשמה",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "ארגז חול",
|
||||
"lead": "שאלו מודל החלטות שאלות מוקלדות על כל טקסט או JSON וקבלו תשובות מכוילות במעבר אחד — בלי טקסט מחולל.",
|
||||
"title": "החלטות",
|
||||
"lead": "שאלו שאלות כן/לא, רב־ברירה או דירוג על כל טקסט או JSON וקבלו מיד תשובות עם ודאות מכוילת. מודלים כאלה נקראים לפעמים מודלי System One.",
|
||||
"docsLink": "איך זה עובד",
|
||||
"side": "תוצאות, דוגמאות וקוד",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "ביטול",
|
||||
"cost": "עלות",
|
||||
"errorGeneric": "לא ניתן היה ליצור את השיר.",
|
||||
"errorLoad": "לא ניתן היה לטעון את השמע."
|
||||
"errorLoad": "לא ניתן היה לטעון את השמע.",
|
||||
"history": "היסטוריה",
|
||||
"period": {
|
||||
"today": "היום",
|
||||
"yesterday": "אתמול",
|
||||
"week": "7 הימים האחרונים",
|
||||
"month": "30 הימים האחרונים",
|
||||
"older": "ישנים יותר"
|
||||
},
|
||||
"length": "אורך",
|
||||
"tags": "רעיונות לסגנון",
|
||||
"submitting": "השיר שלך נשלח לאולפן…",
|
||||
"needStyle": "תארו סגנון כדי ליצור שיר.",
|
||||
"needLength": "בחרו אורך בין {{min}} ל־{{max}} שניות.",
|
||||
"close": "סגירה",
|
||||
"pause": "השהיה",
|
||||
"seek": "מיקום בשיר"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Razgovor",
|
||||
"images": "Slike",
|
||||
"music": "Glazba",
|
||||
"playground": "Igralište",
|
||||
"decisions": "Odluke",
|
||||
"mission": "Misija",
|
||||
"login": "Prijava",
|
||||
"register": "Registracija",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Igralište",
|
||||
"lead": "Postavite modelu za odlučivanje tipizirana pitanja o bilo kojem tekstu ili JSON-u i dobijte kalibrirane odgovore u jednom prolazu — bez generiranog teksta.",
|
||||
"title": "Odluke",
|
||||
"lead": "Postavljajte pitanja da/ne, s višestrukim izborom ili s ocjenjivanjem o bilo kojem tekstu ili JSON-u i odmah dobijte odgovore s kalibriranom pouzdanošću. Takvi se modeli ponekad nazivaju modelima System One.",
|
||||
"docsLink": "Kako radi",
|
||||
"side": "Rezultati, primjeri i kod",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Odustani",
|
||||
"cost": "Trošak",
|
||||
"errorGeneric": "Pjesmu nije bilo moguće generirati.",
|
||||
"errorLoad": "Zvuk nije bilo moguće učitati."
|
||||
"errorLoad": "Zvuk nije bilo moguće učitati.",
|
||||
"history": "Povijest",
|
||||
"period": {
|
||||
"today": "Danas",
|
||||
"yesterday": "Jučer",
|
||||
"week": "Zadnjih 7 dana",
|
||||
"month": "Zadnjih 30 dana",
|
||||
"older": "Starije"
|
||||
},
|
||||
"length": "Trajanje",
|
||||
"tags": "Ideje za stil",
|
||||
"submitting": "Šaljem vašu pjesmu u studio…",
|
||||
"needStyle": "Opišite stil kako biste generirali pjesmu.",
|
||||
"needLength": "Odaberite trajanje između {{min}} i {{max}} sekundi.",
|
||||
"close": "Zatvori",
|
||||
"pause": "Pauza",
|
||||
"seek": "Položaj u pjesmi"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Csevegés",
|
||||
"images": "Képek",
|
||||
"music": "Zene",
|
||||
"playground": "Játszótér",
|
||||
"decisions": "Döntések",
|
||||
"mission": "Küldetés",
|
||||
"login": "Bejelentkezés",
|
||||
"register": "Regisztráció",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Játszótér",
|
||||
"lead": "Tegyen fel egy döntési modellnek típusos kérdéseket bármilyen szövegről vagy JSON-ról, és kapjon kalibrált válaszokat egyetlen lépésben — generált szöveg nélkül.",
|
||||
"title": "Döntések",
|
||||
"lead": "Tegyen fel igen/nem, feleletválasztós vagy pontozó kérdéseket bármilyen szövegről vagy JSON-ról, és azonnal kalibrált megbízhatóságú válaszokat kap. Az ilyen modelleket néha System One modelleknek nevezik.",
|
||||
"docsLink": "Hogyan működik",
|
||||
"side": "Eredmények, példák és kód",
|
||||
"tabs": {
|
||||
|
||||
@@ -33,5 +33,21 @@
|
||||
"cancel": "Mégse",
|
||||
"cost": "Költség",
|
||||
"errorGeneric": "A dalt nem sikerült létrehozni.",
|
||||
"errorLoad": "A hangot nem sikerült betölteni."
|
||||
"errorLoad": "A hangot nem sikerült betölteni.",
|
||||
"history": "Előzmények",
|
||||
"period": {
|
||||
"today": "Ma",
|
||||
"yesterday": "Tegnap",
|
||||
"week": "Elmúlt 7 nap",
|
||||
"month": "Elmúlt 30 nap",
|
||||
"older": "Régebbi"
|
||||
},
|
||||
"length": "Hossz",
|
||||
"tags": "Stílusötletek",
|
||||
"submitting": "A dal küldése a stúdióba…",
|
||||
"needStyle": "Írjon le egy stílust a dal létrehozásához.",
|
||||
"needLength": "Válasszon {{min}} és {{max}} másodperc közötti hosszt.",
|
||||
"close": "Bezárás",
|
||||
"pause": "Szünet",
|
||||
"seek": "Pozíció a dalban"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"chat": "Chat",
|
||||
"images": "Immagini",
|
||||
"music": "Musica",
|
||||
"playground": "Playground",
|
||||
"decisions": "Decisioni",
|
||||
"mission": "Missione",
|
||||
"login": "Accedi",
|
||||
"register": "Registrati",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Playground",
|
||||
"lead": "Poni a un modello decisionale domande tipizzate su qualsiasi testo o JSON e ottieni risposte calibrate in un solo passaggio, senza testo generato.",
|
||||
"title": "Decisioni",
|
||||
"lead": "Fai domande sì/no, a scelta multipla o di punteggio su qualsiasi testo o JSON e ottieni subito risposte con una confidenza calibrata. Modelli di questo tipo sono talvolta chiamati modelli System One.",
|
||||
"docsLink": "Come funziona",
|
||||
"side": "Risultati, esempi e codice",
|
||||
"tabs": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user