From ba04fbb934d91cbb9fe192c3a0700a2dba500a2d Mon Sep 17 00:00:00 2001 From: jettwang Date: Wed, 22 Jul 2026 17:17:23 +0800 Subject: [PATCH 1/2] feat: support scorekit sound textures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +- docs-site/src/getting-started.md | 4 +- docs-site/src/rendering.md | 15 +- docs-site/src/scene-protocol.md | 31 +++- docs-site/src/working-with-agent.md | 5 +- docs/roadmap.md | 7 +- src-tauri/src/agent.rs | 35 ++++- src-tauri/src/agent/tools.rs | 91 ++++++++++-- src-tauri/src/manifest.rs | 187 +++++++++++++++++++++++++ src-tauri/src/observation.rs | 47 ++++++- src-tauri/src/scorekit.rs | 21 ++- src-tauri/tests/fixtures/doctor.json | 2 +- src/lib/api.ts | 21 +++ src/lib/components/ScenePanel.svelte | 155 ++++++++++++++++---- src/lib/components/ScenePreview.svelte | 37 +++++ src/lib/i18n.svelte.ts | 22 +++ 16 files changed, 613 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index a520958..44cfba1 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ In one line: **the user speaks the vocabulary of emotion, the system works in th - **One project per window.** Opening scorebench means opening one project directory (scene YAML + rendered assets + agent memory). No multi-project tabs. - **Agent-first authoring.** The agent is the primary scene writer. Experienced users can also use the raw-source editor with explicit Validate and Save actions; there is no autosave. Parameter panels remain read-only observations. - **Playback & spectrum in the webview.** Decoding, FFT, and progress come from the browser's WebAudio API (`AnalyserNode`) — no Rust audio stack, no in-house DSP. +- **ScoreKit 0.3 sound textures.** Scenes can schedule looped ambience and one-shot effects through portable source keys; scorebench persists the selected texture profile, checks mappings, and passes it to ScoreKit without mixing audio itself. - **Project memory.** The agent maintains a rolling project summary; when the conversation exceeds the configured context budget it compacts automatically. ## Iron rules @@ -80,7 +81,7 @@ Install scorebench from [Releases](https://github.com/talkincode/scorebench/rele - Linux: use the `.deb` or `.AppImage` artifact. - Windows: use the `.msi` or `-setup.exe` artifact. -Install ScoreKit separately (for example with Homebrew `brew install talkincode/tap/scorekit`, or from [ScoreKit Releases](https://github.com/talkincode/scorekit/releases)), then confirm: +Install ScoreKit 0.3.x separately (for example with Homebrew `brew install talkincode/tap/scorekit`, or from [ScoreKit Releases](https://github.com/talkincode/scorekit/releases)), then confirm: ```bash brew trust --tap talkincode/tap # when installing ScoreKit via Homebrew tap diff --git a/docs-site/src/getting-started.md b/docs-site/src/getting-started.md index f21f574..ea50cf2 100644 --- a/docs-site/src/getting-started.md +++ b/docs-site/src/getting-started.md @@ -4,7 +4,7 @@ This chapter takes the shortest path through the complete loop: install the depe ## 1. Install scorebench and ScoreKit -scorebench needs the ScoreKit CLI at runtime. The desktop app does not bundle the CLI. +scorebench needs the ScoreKit CLI at runtime. The desktop app does not bundle the CLI. This version is tested with ScoreKit 0.3.x. ### macOS (Homebrew, recommended) @@ -93,7 +93,7 @@ The main workspace areas are: - **Source:** inspect the scene YAML or use explicit Validate and Save actions for manual raw-YAML editing. - **Preview:** inspect compiled musical parameters and the latest semantic diff. - **Review:** request structured advice from composing, arranging, production, and media-scoring perspectives. -- **Render:** select a backend, sample rate, format, gain, quality, and optional stems. +- **Render:** select a backend, sample rate, format, gain, quality, optional stems, and an optional sound-texture profile. - **Outputs:** load audio from `out/` and inspect the matching `meta.json` summary. For a first render, keep the defaults: diff --git a/docs-site/src/rendering.md b/docs-site/src/rendering.md index 73f5825..0429069 100644 --- a/docs-site/src/rendering.md +++ b/docs-site/src/rendering.md @@ -13,9 +13,12 @@ MIDI └── sfizz_render + SFZ profile │ ▼ - raw audio - │ loop seal / tail / export - ▼ + synth audio ──┐ + ├── texture profile → scheduled ambience / SFX + ▼ + raw mix + │ loop seal / tail / export + ▼ OGG or WAV + meta.json + optional stems ``` @@ -42,8 +45,9 @@ FluidSynth is the default. Switch to sfizz only when the musical structure is st | Quality | Vorbis quality from 0 to 10 | Primarily affects OGG size and encoding quality; default 5 | | Stems | Render every track as aligned audio | Enable for adaptive playback or downstream mixing | | SFZ profile | Maps instruments and articulations to `.sfz` files | Used only by sfizz; missing mappings fail the build | +| Texture profile | Maps portable texture source names to local audio files | Renderer-independent; required only when the scene declares `textures` | -The renderer and SFZ profile are stored in the project's `bench.json`, allowing the Agent to check instrument compatibility while writing a scene. The other controls are immediate Render-panel choices. +The renderer, SFZ profile, and texture profile are stored in the project's `bench.json`, allowing the Agent to check instrument and texture-source compatibility while writing a scene. The other controls are immediate Render-panel choices. ## Output files @@ -56,7 +60,8 @@ out/ └── forest.stems/ ├── 01-flute.ogg ├── 02-slow_strings.ogg - └── ... + ├── ... + └── 04-texture-river.ogg ``` `meta.json` is the machine-readable build result. It records the sample rate, total samples, loop range, audio asset, stems, and related metadata. scorebench uses this file to determine build success rather than parsing human-oriented ScoreKit stdout. diff --git a/docs-site/src/scene-protocol.md b/docs-site/src/scene-protocol.md index 2dfabca..396cdff 100644 --- a/docs-site/src/scene-protocol.md +++ b/docs-site/src/scene-protocol.md @@ -69,6 +69,7 @@ tracks: | `motifs` | Named melodic material | Referenced by `melody` tracks | | `performance` | Swing, legato, dynamics, and deterministic humanization | Optional | | `tracks` | Instrument tracks | 1–16, with at most one drum track | +| `textures` | Scheduled ambience, field recordings, and sound effects | Optional; portable source names are resolved by a separate texture profile | | `sections` | Named cues that share the scene's material | Optional; build emits one output per section | ScoreKit currently builds diatonic triads from the scene scale. Roman-numeral case is conventional: `VI` and `vi` select the same scale degree in the current protocol. This field is not a complete classical-harmony notation system. @@ -105,6 +106,34 @@ A motif is a list of `{ degree, beats }` entries: A melody repeats or truncates its motif to fill the scene or section exactly. Split a rest longer than 16 beats into multiple entries. +## Sound textures + +ScoreKit 0.3 adds deterministic non-instrument layers: + +```yaml +textures: + - source: river + mode: loop + start_beat: 0 + gain: 0.25 + - source: birds + mode: one_shot + at: [4, 20] + gain: 0.5 +``` + +`loop` repeats one source from `start_beat`; `one_shot` triggers it at each quarter-note beat in `at`. `gain` is a linear multiplier from 0 to 1. Source names are portable keys, never file paths. Bind them in a texture-profile YAML selected from scorebench's Render panel: + +```yaml +name: forest-recordings +root: recordings +sources: + river: river.flac + birds: birds.wav +``` + +The Agent receives the active profile's source keys and the observation panel reports missing mappings before build. ScoreKit still performs the authoritative validation and mixing. Enabling stems produces aligned texture stems alongside instrument stems. + ## Performance ```yaml @@ -139,7 +168,7 @@ A section can change `bars`, `tempo`, `loop`, and overall `intensity`, or silenc ## What does not belong in the scene protocol -- SoundFont, SFZ, and renderer paths. They are build parameters or renderer-profile data. +- SoundFont, SFZ, renderer, and recording paths. They belong in build parameters, renderer profiles, or texture profiles; scene textures use portable source keys. - Arbitrary `mood`, `danger`, or `avoid` fields without compile semantics. Keep them in the conversation or `story`. - Plugin chains, mastering, equalization, or post-processing instructions. - Arbitrary MIDI events, automation curves, or free-form per-note editing outside the schema. diff --git a/docs-site/src/working-with-agent.md b/docs-site/src/working-with-agent.md index ee2c88f..37ec068 100644 --- a/docs-site/src/working-with-agent.md +++ b/docs-site/src/working-with-agent.md @@ -40,11 +40,12 @@ You do not need to know every theory term. A phrase such as “a distant memory, - A **StylePack** is a scorebench creative preference package. It guides the Agent toward particular harmony, orchestration, form, and review criteria. - A **grammar profile** is a set of measurable ScoreKit aesthetic checks, such as tempo limits, voice count, and melody rest ratio. - A **renderer profile** maps ScoreKit instruments and articulations to local SFZ files for sfizz. +- A **texture profile** maps portable ambience and sound-effect source names to local audio files, independently of the renderer. -StylePack influences choices, grammar checks the compiled music, and a renderer profile controls timbre. None of them replaces the others. +StylePack influences choices, grammar checks the compiled music, a renderer profile controls instrumental timbre, and a texture profile binds scheduled recordings. None of them replaces the others. ## Project files and recovery Scenes, the project manifest, sessions, and Agent memory are plain files. When the Agent writes a scene, scorebench records semantic history and validates immediately. Invalid YAML may remain on disk with a visible error so the Agent can repair it. Put the project under Git if you want review and rollback. -The selected renderer and SFZ profile are stored in `bench.json`. Sample rate, gain, quality, format, and stems are immediate Render-panel choices. Never put secrets in `bench.json`. +The selected renderer, SFZ profile, and texture profile are stored in `bench.json`. Sample rate, gain, quality, format, and stems are immediate Render-panel choices. Never put secrets in `bench.json`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 68634d5..fcccb54 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -131,7 +131,7 @@ Rules (MUST): | --- | --- | --- | --- | | scorekit binary discovery | 1 | integration tests run via PATH-located binary | `locate_missing_everywhere_is_typed_error`, `locate_env_override_must_be_executable` (`scorekit.rs`) | | scorekit error contract (`--json` stderr) | 1 | `doctor_fixture_shape_holds`, `meta_fixture_shape_holds` (recorded fixtures) | `parses_recorded_io_error`, `falls_back_on_non_json_stderr` | -| Build param → CLI arg mapping | 1 | `build_params_render_full_arg_set` | n/a (pure function, no state) | +| Build param → CLI arg mapping | 1 | `build_params_render_full_arg_set` covers renderer and `--texture-profile` | n/a (pure function, no state) | | Project directory scan | 1 | `scan_finds_scenes_and_assets` | `scan_rejects_non_directory` | | Asset read containment (webview → disk) | 1 | `resolve_inside` accepts in-root paths | `resolve_inside_blocks_escape` rejects traversal | | Responses SSE transport | 1 | recorded text/tool/multi-tool fixtures; arbitrary chunk-boundary equivalence | 401/429 metadata, failed event, mid-stream disconnect, dead endpoint, cancellation | @@ -154,9 +154,10 @@ Rules (MUST): | Spectrum dynamics helpers | 2 | envelope/smoother/impact unit tests (`dynamics.test.ts`) | idle spectrum stays below quiet ceiling; impact decays without audio | | Mood spectrum (emotion-compiler acceptance view) | 2 | perceptual features distinguish major/minor/cluster/fifths chords (`features.test.ts`); V-A-T emotion matrix — sad/happy valence split, tense material raises tension, build-up detected on crescendo but not steady groove, five world archetypes each win on matching material, intent prior nudges valence, dominant switch respects dwell (`mood.test.ts`) | empty/zero frames stay neutral without NaN; missing `sampleRate`/`intentMode` options degrade to defaults; HUD is read-only and toggleable (`moodHud`) | | Review panel (multi-perspective critique) | 1 | recorded report fixture streams through scripted transport and parses into a typed report; evidence pack assembled from scene/validation/meta/memory/intents (`review.rs`) | non-JSON reply, empty-perspective report, unknown perspective id, transport failure, and missing terminal event are typed errors; evidence gathering rejects out-of-root scenes and tolerates unbuilt projects | -| Toolchain validation gate (write → validate → profile check) | 1 | `write_scene` auto-validates via real scorekit and reports inline; scene×profile compatibility detected from `bench.json` (`tools.rs`, `manifest.rs`); render config injected into system prompt with mapped instruments | invalid scene persists but is reported `invalid` with the machine error; missing scorekit degrades to `unavailable`; unreadable profile and unmapped instruments surface as warnings, never block the write | -| bench.json render config persistence | 1 | render round trip preserves unknown fields; GUI selection inherited by `build_scene` when params omitted | missing file loads defaults silently; corrupt JSON degrades to defaults with a warning, never an error | +| Toolchain validation gate (write → validate → profile check) | 1 | `write_scene` auto-validates via real scorekit and reports inline; scene×renderer/texture-profile compatibility detected from `bench.json` (`tools.rs`, `manifest.rs`); render config injected into system prompt with mapped instrument and texture source keys | invalid scene persists but is reported `invalid` with the machine error; missing scorekit degrades to `unavailable`; unreadable profiles and unmapped instruments/sources surface as warnings, never block the write | +| bench.json render config persistence | 1 | renderer, SFZ profile, and independent texture profile round trip while preserving unknown fields; GUI selection is inherited by `build_scene` when params are omitted | missing file loads defaults silently; corrupt JSON degrades to defaults with a warning, never an error | | Scene story display (scorekit ≥0.2 `story` field) | 2 | `inspect_scene` surfaces optional top-level `story` prose in the observation panel (`observation.rs`) | absent or non-string story renders nothing; field stays informational, never parsed | +| Sound texture composition (scorekit ≥0.3) | 1 | scene observation extracts loop/one-shot texture layers; `texture_sources_are_checked_against_independent_profile`; GUI and Agent builds pass `--texture-profile` and optional stems include texture tracks | textured scenes without a profile, unreadable profiles, and missing portable source keys are diagnosed before build; scorekit remains the final build authority | | Creative assist panel (prompt tags) | 2 | tag click appends its bilingual description to the chat input; selections light recommended pairings across categories (`assist.ts` unit tests: unique ids, valid recommends graph, bilingual coverage) | panel writes prompt text only — never scene YAML; empty selection recommends nothing | | Style pack library (structured presets) | 1 | built-in packs parse and validate; user pack save/list/find round trip with atomic write; rename via `previous_id` removes the old file (`styles.rs`) | invalid id/name/YAML rejected before write; built-in ids are read-only (no save/delete); corrupt user file skipped with a warning, never fatal | | Active style injection + conflict detection | 1 | `bench.json` `style.id` round trip (`manifest.rs`); active pack YAML injected into the system prompt with the mandatory conflict-detection protocol; `review.criteria` embedded in review evidence (`agent.rs`, `review.rs`) | dangling style reference degrades to no style with a warning event; chat and review still run | diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs index f65b958..13ca3f3 100644 --- a/src-tauri/src/agent.rs +++ b/src-tauri/src/agent.rs @@ -246,8 +246,8 @@ pub fn system_prompt( } /// Prompt block describing the render configuration persisted in bench.json, -/// including the instrument keys the active sfizz profile maps, so the model -/// composes with instruments the user's renderer can actually play. +/// including the instrument and texture source keys active profiles map, so +/// the model only composes material the configured toolchain can build. fn render_config_section(root: &Path) -> String { let Some(render) = manifest::load(root).0.render else { return String::new(); @@ -276,6 +276,28 @@ fn render_config_section(root: &Path) -> String { } _ => {} } + if let Some(profile) = render + .texture_profile + .as_deref() + .filter(|profile| !profile.trim().is_empty()) + { + match manifest::texture_profile_sources(root, profile) { + Ok((name, sources)) => { + let name = name.unwrap_or_else(|| profile.to_owned()); + section.push_str(&format!( + "texture profile: {profile} ({name})\n\ + texture sources mapped by this profile: {}\n\ + Use only these portable source keys in scene textures; any other key will FAIL the build.\n", + sources.join(", ") + )); + } + Err(error) => { + section.push_str(&format!( + "texture profile: {profile}\nWARNING: the texture profile could not be read ({error}); textured builds will fail until it is fixed.\n" + )); + } + } + } section.push('\n'); section } @@ -935,9 +957,14 @@ mod tests { "name: scoredata-open\ninstruments:\n piano:\n sustain: p.sfz\n strings:\n sustain: s.sfz\n", ) .unwrap(); + std::fs::write( + root.join("profiles/forest-textures.yaml"), + "name: forest\nsources:\n birds: birds.wav\n river: river.wav\n", + ) + .unwrap(); std::fs::write( root.join("bench.json"), - r#"{"render":{"renderer":"sfizz","profile":"profiles/open.yaml"}}"#, + r#"{"render":{"renderer":"sfizz","profile":"profiles/open.yaml","texture_profile":"profiles/forest-textures.yaml"}}"#, ) .unwrap(); let section = render_config_section(&root); @@ -945,6 +972,8 @@ mod tests { assert!(section.contains("renderer: sfizz")); assert!(section.contains("piano, strings")); assert!(section.contains("FAIL the sfizz build")); + assert!(section.contains("birds, river")); + assert!(section.contains("portable source keys")); let prompt = system_prompt(&root, "main", None).unwrap(); assert!(prompt.contains("ACTIVE RENDER CONFIGURATION")); diff --git a/src-tauri/src/agent/tools.rs b/src-tauri/src/agent/tools.rs index 1d20b93..17496de 100644 --- a/src-tauri/src/agent/tools.rs +++ b/src-tauri/src/agent/tools.rs @@ -42,7 +42,7 @@ pub fn definitions() -> Vec { ), function( "write_scene", - "Atomically write one scene YAML file inside the project. Runs `scorekit validate` and the renderer-profile compatibility check afterwards and reports the result inline; pass validate:false only when writing non-scene YAML (grammar or renderer profile files).", + "Atomically write one scene YAML file inside the project. Runs `scorekit validate` plus renderer/texture-profile compatibility checks afterwards and reports the result inline; pass validate:false only when writing non-scene YAML (grammar, renderer profile, or texture profile files).", json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"},"validate":{"type":"boolean","description":"Validate the written file as a scene (default true)."}},"required":["path","content"]}), ), function( @@ -69,7 +69,8 @@ pub fn definitions() -> Vec { "quality":{"type":"integer","minimum":0,"maximum":10}, "stems":{"type":"boolean"}, "soundfont":{"type":"string"}, - "profile":{"type":"string"} + "profile":{"type":"string"}, + "texture_profile":{"type":"string","description":"Project-relative scorekit texture profile path. Omit to inherit bench.json."} }, "required":["path"] }), @@ -163,10 +164,17 @@ fn execute_sync(root: &Path, call: &FunctionCall) -> Result { @@ -194,8 +202,12 @@ fn execute_sync(root: &Path, call: &FunctionCall) -> Result Some(explicit), // The project profile may live outside the root (GUI allows @@ -207,6 +219,18 @@ fn execute_sync(root: &Path, call: &FunctionCall) -> Result None, }; + let texture_profile = match ( + resolve_optional(root, args.texture_profile)?, + inherited_texture_profile, + ) { + (Some(explicit), _) => Some(explicit), + (None, Some(inherited)) => Some( + manifest::resolve_profile_path(root, &inherited) + .to_string_lossy() + .into_owned(), + ), + (None, None) => None, + }; let params = scorekit::BuildParams { renderer, sample_rate: args.sample_rate, @@ -215,6 +239,7 @@ fn execute_sync(root: &Path, call: &FunctionCall) -> Result Result Result { } output["render_profile"] = serde_json::to_value(&compat).map_err(BenchError::io)?; } + if let Some(compat) = texture_profile_check(root, &target) { + if !compat.is_compatible() { + summary = format!("{summary}; {}", compat.message()); + } + output["texture_profile"] = serde_json::to_value(&compat).map_err(BenchError::io)?; + } } else { output["validation"] = json!({"status": "skipped"}); } @@ -363,21 +395,33 @@ fn profile_check(root: &Path, scene_path: &Path) -> Option Option { + let render = manifest::load(root).0.render.unwrap_or_default(); + manifest::check_scene_texture_profile(root, scene_path, &render) +} + /// Fill build parameters the model omitted from the project render config. -/// Returns the inherited profile path, if any. Pure function for testing. +/// Returns inherited renderer and texture profile paths. Pure for testing. fn inherit_render_config( renderer: &mut Option, explicit_profile: &Option, + explicit_texture_profile: &Option, project_render: &manifest::RenderConfig, -) -> Option { +) -> (Option, Option) { if renderer.is_none() { renderer.clone_from(&project_render.renderer); } - if explicit_profile.is_none() && renderer.as_deref() == Some("sfizz") { + let profile = if explicit_profile.is_none() && renderer.as_deref() == Some("sfizz") { project_render.profile.clone() } else { None - } + }; + let texture_profile = if explicit_texture_profile.is_none() { + project_render.texture_profile.clone() + } else { + None + }; + (profile, texture_profile) } fn require_scene_path(path: &str) -> Result<(), BenchError> { @@ -444,6 +488,7 @@ struct BuildArgs { stems: Option, soundfont: Option, profile: Option, + texture_profile: Option, } #[cfg(test)] @@ -650,31 +695,43 @@ mod tests { let project_render = manifest::RenderConfig { renderer: Some("sfizz".into()), profile: Some("profiles/open.yaml".into()), + texture_profile: Some("profiles/forest-textures.yaml".into()), }; let mut renderer = None; - let inherited = inherit_render_config(&mut renderer, &None, &project_render); + let (inherited, texture) = + inherit_render_config(&mut renderer, &None, &None, &project_render); assert_eq!(renderer.as_deref(), Some("sfizz")); assert_eq!(inherited.as_deref(), Some("profiles/open.yaml")); + assert_eq!(texture.as_deref(), Some("profiles/forest-textures.yaml")); - // Explicit renderer wins; a non-sfizz renderer takes no profile. + // Explicit renderer wins; texture profiles are renderer-independent. let mut renderer = Some("fluidsynth".to_owned()); - let inherited = inherit_render_config(&mut renderer, &None, &project_render); + let (inherited, texture) = + inherit_render_config(&mut renderer, &None, &None, &project_render); assert_eq!(renderer.as_deref(), Some("fluidsynth")); assert!(inherited.is_none()); + assert_eq!(texture.as_deref(), Some("profiles/forest-textures.yaml")); // Explicit profile wins over the project profile. let mut renderer = None; let explicit = Some("other.yaml".to_owned()); - let inherited = inherit_render_config(&mut renderer, &explicit, &project_render); + let (inherited, texture) = + inherit_render_config(&mut renderer, &explicit, &explicit, &project_render); assert!(inherited.is_none()); + assert!(texture.is_none()); // Empty project config changes nothing. let mut renderer = None; - let inherited = - inherit_render_config(&mut renderer, &None, &manifest::RenderConfig::default()); + let (inherited, texture) = inherit_render_config( + &mut renderer, + &None, + &None, + &manifest::RenderConfig::default(), + ); assert!(renderer.is_none()); assert!(inherited.is_none()); + assert!(texture.is_none()); } #[test] @@ -720,6 +777,10 @@ mod tests { build.parameters["properties"]["path"]["type"], serde_json::json!("string") ); + assert_eq!( + build.parameters["properties"]["texture_profile"]["type"], + serde_json::json!(["string", "null"]) + ); let write = definitions() .into_iter() diff --git a/src-tauri/src/manifest.rs b/src-tauri/src/manifest.rs index 22d26cf..6b7022c 100644 --- a/src-tauri/src/manifest.rs +++ b/src-tauri/src/manifest.rs @@ -29,6 +29,9 @@ pub struct RenderConfig { /// absolute otherwise (mirrors the GUI build parameter). #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, + /// Texture profile path, independent of the synthesizer backend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub texture_profile: Option, } /// Persisted style pack selection: the id of a pack in the global style @@ -138,6 +141,50 @@ impl ProfileCompat { } } +/// Result of checking a scene's texture source keys against the active texture +/// profile. A scene with no textures produces no compatibility result. +#[derive(Debug, Clone, Serialize)] +pub struct TextureProfileCompat { + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_name: Option, + /// Portable source keys offered by the profile. + pub available: Vec, + /// Source keys used by the scene but absent from the profile. + pub missing: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl TextureProfileCompat { + pub fn is_compatible(&self) -> bool { + self.profile.is_some() && self.missing.is_empty() && self.error.is_none() + } + + pub fn message(&self) -> String { + let Some(profile) = self.profile.as_deref() else { + return "scene uses textures but no texture profile is configured; the scorekit build will fail".into(); + }; + let name = self.profile_name.as_deref().unwrap_or(profile); + if let Some(error) = &self.error { + return format!("texture profile `{name}` is unusable: {error}"); + } + if self.missing.is_empty() { + format!("all texture sources are mapped by texture profile `{name}`") + } else { + format!( + "texture profile `{name}` has no mapping for source(s) {}; the scorekit build will fail", + self.missing + .iter() + .map(|key| format!("`{key}`")) + .collect::>() + .join(", ") + ) + } + } +} + /// The profile only matters for the sfizz backend (SF2 renderers take a /// soundfont instead), matching scorekit's `--profile` contract. fn active_profile(render: &RenderConfig) -> Option<&str> { @@ -177,6 +224,25 @@ pub fn profile_instruments( Ok((wire.name, wire.instruments.into_keys().collect())) } +/// Load the portable source keys a scorekit texture profile exposes. +pub fn texture_profile_sources( + root: &Path, + profile: &str, +) -> Result<(Option, Vec), String> { + #[derive(Deserialize)] + struct ProfileWire { + #[serde(default)] + name: Option, + sources: BTreeMap, + } + let path = resolve_profile_path(root, profile); + let raw = std::fs::read_to_string(&path) + .map_err(|error| format!("cannot read `{}`: {error}", path.display()))?; + let wire: ProfileWire = serde_yaml::from_str(&raw) + .map_err(|error| format!("`{}` is not a texture profile: {error}", path.display()))?; + Ok((wire.name, wire.sources.into_keys().collect())) +} + /// Cross-check a scene's track instruments against the manifest's active /// renderer profile. `None` when no sfizz profile is configured or the scene /// YAML does not parse (scorekit validate owns that failure). @@ -214,6 +280,57 @@ pub fn check_scene_profile( }) } +/// Cross-check a scene's portable texture source keys against the profile that +/// scorekit will receive through `--texture-profile`. +pub fn check_scene_texture_profile( + root: &Path, + scene: &Path, + render: &RenderConfig, +) -> Option { + let used = scene_texture_sources(scene)?; + if used.is_empty() { + return None; + } + let Some(profile) = render + .texture_profile + .as_deref() + .filter(|profile| !profile.trim().is_empty()) + else { + return Some(TextureProfileCompat { + profile: None, + profile_name: None, + available: Vec::new(), + missing: used, + error: None, + }); + }; + let (profile_name, available) = match texture_profile_sources(root, profile) { + Ok(loaded) => loaded, + Err(error) => { + return Some(TextureProfileCompat { + profile: Some(profile.to_owned()), + profile_name: None, + available: Vec::new(), + missing: Vec::new(), + error: Some(error), + }); + } + }; + let mut missing = used + .into_iter() + .filter(|source| !available.contains(source)) + .collect::>(); + missing.sort_unstable(); + missing.dedup(); + Some(TextureProfileCompat { + profile: Some(profile.to_owned()), + profile_name, + available, + missing, + error: None, + }) +} + /// Track instrument names from scene YAML. Tolerant reader: unparseable /// scenes or tracks without a string instrument yield nothing here because /// `scorekit validate` is the authority for scene shape errors. @@ -229,6 +346,21 @@ fn scene_instruments(scene: &Path) -> Option> { ) } +fn scene_texture_sources(scene: &Path) -> Option> { + let raw = std::fs::read_to_string(scene).ok()?; + let value: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; + let Some(textures) = value.get("textures") else { + return Some(Vec::new()); + }; + let textures = textures.as_sequence()?; + Some( + textures + .iter() + .filter_map(|texture| texture.get("source")?.as_str().map(ToOwned::to_owned)) + .collect(), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -255,10 +387,28 @@ mod tests { .unwrap(); } + fn write_texture_profile(root: &Path) { + std::fs::create_dir_all(root.join("profiles")).unwrap(); + std::fs::write( + root.join("profiles/forest-textures.yaml"), + "name: forest\nsources:\n birds: audio/birds.wav\n river: audio/river.flac\n", + ) + .unwrap(); + } + fn sfizz_render() -> RenderConfig { RenderConfig { renderer: Some("sfizz".into()), profile: Some("profiles/open.yaml".into()), + texture_profile: Some("profiles/forest-textures.yaml".into()), + } + } + + fn textured_render() -> RenderConfig { + RenderConfig { + renderer: Some("fluidsynth".into()), + profile: None, + texture_profile: Some("profiles/forest-textures.yaml".into()), } } @@ -379,11 +529,13 @@ mod tests { let fluidsynth = RenderConfig { renderer: Some("fluidsynth".into()), profile: Some("profiles/open.yaml".into()), + texture_profile: None, }; assert!(check_scene_profile(&root, &scene, &fluidsynth).is_none()); let no_profile = RenderConfig { renderer: Some("sfizz".into()), profile: None, + texture_profile: None, }; assert!(check_scene_profile(&root, &scene, &no_profile).is_none()); assert!(check_scene_profile(&root, &scene, &RenderConfig::default()).is_none()); @@ -409,4 +561,39 @@ mod tests { assert!(check_scene_profile(&root, &root.join("scene.yaml"), &sfizz_render()).is_none()); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn texture_sources_are_checked_against_independent_profile() { + let root = temp_project(); + write_texture_profile(&root); + std::fs::write( + root.join("scene.yaml"), + "textures:\n - { source: river, mode: loop }\n - { source: wind, mode: one_shot, at: [4] }\n", + ) + .unwrap(); + let compat = + check_scene_texture_profile(&root, &root.join("scene.yaml"), &textured_render()) + .unwrap(); + assert!(!compat.is_compatible()); + assert_eq!(compat.available, vec!["birds", "river"]); + assert_eq!(compat.missing, vec!["wind"]); + assert_eq!(compat.profile_name.as_deref(), Some("forest")); + assert!(compat.message().contains("`wind`")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn textured_scene_requires_profile_but_plain_scene_does_not() { + let root = temp_project(); + let scene = root.join("scene.yaml"); + std::fs::write(&scene, "textures:\n - { source: river, mode: loop }\n").unwrap(); + let compat = check_scene_texture_profile(&root, &scene, &RenderConfig::default()).unwrap(); + assert!(!compat.is_compatible()); + assert!(compat.profile.is_none()); + assert!(compat.message().contains("no texture profile")); + + std::fs::write(&scene, "tracks: []\n").unwrap(); + assert!(check_scene_texture_profile(&root, &scene, &RenderConfig::default()).is_none()); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/src-tauri/src/observation.rs b/src-tauri/src/observation.rs index 4c6523e..c736e8e 100644 --- a/src-tauri/src/observation.rs +++ b/src-tauri/src/observation.rs @@ -20,6 +20,8 @@ pub struct SceneInspection { /// Compatibility with the project's persisted render configuration; /// `None` when no sfizz profile is active. pub render_profile: Option, + /// Texture source compatibility; `None` when the scene has no textures. + pub texture_profile: Option, pub last_diff: Option, } @@ -42,6 +44,7 @@ pub struct SceneDisplay { pub harmony: Vec, pub sections: Vec, pub tracks: Vec, + pub textures: Vec, pub has_performance: bool, } @@ -64,6 +67,15 @@ pub struct TrackDisplay { pub articulation: Option, } +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct TextureDisplay { + pub source: Option, + pub mode: Option, + pub gain: Option, + pub start_beat: Option, + pub at: Vec, +} + pub fn inspect_scene(root: &Path, rel_path: &str) -> Result { if !(rel_path.ends_with(".yaml") || rel_path.ends_with(".yml")) { return Err(BenchError::invalid("scene path must end in .yaml or .yml")); @@ -91,15 +103,15 @@ pub fn inspect_scene(root: &Path, rel_path: &str) -> Result SceneDisplay { articulation: string(track, "articulation"), }) .collect(), + textures: sequence(mapping, "textures") + .into_iter() + .filter_map(|value| value.as_mapping()) + .map(|texture| TextureDisplay { + source: string(texture, "source"), + mode: string(texture, "mode"), + gain: number(texture, "gain"), + start_beat: number(texture, "start_beat"), + at: sequence(texture, "at") + .into_iter() + .filter_map(Value::as_f64) + .collect(), + }) + .collect(), has_performance: get(mapping, "performance").is_some(), } } @@ -303,6 +329,19 @@ mod tests { assert_eq!(scene.sections[0].mute, vec![3, 4]); } + #[test] + fn parses_texture_layers_for_observation() { + let value: Value = serde_yaml::from_str( + "textures:\n - { source: river, mode: loop, gain: 0.25, start_beat: 2 }\n - { source: birds, mode: one_shot, at: [4, 12], gain: 0.5 }\n", + ) + .unwrap(); + let scene = display(value.as_mapping().unwrap()); + assert_eq!(scene.textures.len(), 2); + assert_eq!(scene.textures[0].source.as_deref(), Some("river")); + assert_eq!(scene.textures[0].start_beat, Some(2.0)); + assert_eq!(scene.textures[1].at, vec![4.0, 12.0]); + } + #[test] fn malformed_yaml_returns_observation_error_not_panic() { let root = std::env::temp_dir().join(format!( diff --git a/src-tauri/src/scorekit.rs b/src-tauri/src/scorekit.rs index ee218c2..3a60c4b 100644 --- a/src-tauri/src/scorekit.rs +++ b/src-tauri/src/scorekit.rs @@ -1,6 +1,6 @@ //! Subprocess boundary to the `scorekit` CLI. //! -//! Contract (recorded from scorekit 0.1.0, see `tests/fixtures/`): +//! Contract (recorded through scorekit 0.3.0, see `tests/fixtures/`): //! - success: exit 0; `build` writes `.meta.json` as the machine-readable result //! - failure: stderr carries one JSON object `{code, exit_code, field, location, message}` //! - `doctor --json`: stdout JSON report @@ -18,7 +18,7 @@ use crate::error::BenchError; /// Environment variable that pins the scorekit binary explicitly. pub const SCOREKIT_ENV: &str = "SCOREBENCH_SCOREKIT"; -pub const TESTED_SCOREKIT_RANGE: &str = ">=0.2.0, <0.3.0"; +pub const TESTED_SCOREKIT_RANGE: &str = ">=0.3.0, <0.4.0"; #[derive(Debug, Clone, Serialize)] pub struct Handshake { @@ -325,6 +325,7 @@ pub struct BuildParams { pub stems: Option, pub soundfont: Option, pub profile: Option, + pub texture_profile: Option, } impl BuildParams { @@ -351,6 +352,9 @@ impl BuildParams { if let Some(profile) = &self.profile { args.extend(["--profile".into(), profile.clone()]); } + if let Some(profile) = &self.texture_profile { + args.extend(["--texture-profile".into(), profile.clone()]); + } args } } @@ -452,6 +456,10 @@ mod tests { value.get("tools").is_some(), "doctor JSON must have `tools`" ); + assert_eq!( + value.get("scorekit_version").and_then(Value::as_str), + Some("0.3.0") + ); } #[test] @@ -489,6 +497,7 @@ mod tests { stems: Some(true), soundfont: None, profile: None, + texture_profile: Some("profiles/forest-textures.yaml".into()), }; assert_eq!( params.to_args(), @@ -501,7 +510,9 @@ mod tests { "0.7", "--quality", "6", - "--stems" + "--stems", + "--texture-profile", + "profiles/forest-textures.yaml" ] .into_iter() .map(String::from) @@ -522,7 +533,7 @@ mod tests { fn handshake_gates_machine_readable_version() { let report = serde_json::json!({ "ready": true, - "scorekit_version": "0.2.0", + "scorekit_version": "0.3.0", "hints": ["install a renderer"] }); let handshake = handshake_from_report(PathBuf::from("scorekit"), report); @@ -531,7 +542,7 @@ mod tests { let outdated = handshake_from_report( PathBuf::from("scorekit"), - serde_json::json!({"ready":true,"scorekit_version":"0.1.3","hints":[]}), + serde_json::json!({"ready":true,"scorekit_version":"0.2.3","hints":[]}), ); assert_eq!(outdated.compatible, Some(false)); assert!(outdated diff --git a/src-tauri/tests/fixtures/doctor.json b/src-tauri/tests/fixtures/doctor.json index 7f075c1..ebb781f 100644 --- a/src-tauri/tests/fixtures/doctor.json +++ b/src-tauri/tests/fixtures/doctor.json @@ -1 +1 @@ -{"hints":["Platform macos/aarch64: use release asset scorekit-aarch64-apple-darwin.tar.gz.","Install the standard toolchain with `brew install fluid-synth timidity ffmpeg`.","Apple Silicon: sfizz has no native Homebrew formula or official arm64 renderer; from a scorekit source checkout run `make sfizz`.","Run `make install-default-soundfont` when MuseScore_General.sf2 is missing; SFZ rendering still requires a user-supplied library/profile."],"platform":{"arch":"aarch64","os":"macos","release_asset":"scorekit-aarch64-apple-darwin.tar.gz","target":"aarch64-apple-darwin"},"ready":true,"requirements":{"ffmpeg":true,"renderer":true},"sound_library":{"default_soundfont":{"path":"/Users/wangjuntao/.local/share/scorekit/sounds/sf2/MuseScore_General.sf2","status":"ok"},"path":"/Users/wangjuntao/.local/share/scorekit/sounds","source":"platform default"},"tools":[{"name":"ffmpeg","path":"/opt/homebrew/bin/ffmpeg","role":"audio export","status":"ok","version":"ffmpeg version 8.1.1 Copyright (c) 2000-2026 the FFmpeg developers"},{"name":"fluidsynth","path":"/opt/homebrew/bin/fluidsynth","role":"SF2 renderer","status":"ok","version":"FluidSynth runtime version 2.5.6"},{"name":"timidity","path":"/opt/homebrew/bin/timidity","role":"alternate SF2 renderer","status":"ok","version":"TiMidity++ version 2.15.0"},{"name":"sfizz_render","path":"/Users/wangjuntao/.local/bin/sfizz_render","role":"SFZ renderer","status":"ok","version":"Render a midi file through an SFZ file using the sfizz library."}]} +{"hints":["Platform macos/aarch64: use release asset scorekit-aarch64-apple-darwin.tar.gz.","Install the standard toolchain with `brew install fluid-synth timidity ffmpeg`.","Apple Silicon: sfizz has no native Homebrew formula or official arm64 renderer; from a scorekit source checkout run `make sfizz`.","Run `make install-default-soundfont` when MuseScore_General.sf2 is missing; SFZ rendering still requires a user-supplied library/profile."],"platform":{"arch":"aarch64","os":"macos","release_asset":"scorekit-aarch64-apple-darwin.tar.gz","target":"aarch64-apple-darwin"},"ready":true,"requirements":{"ffmpeg":true,"renderer":true},"scorekit_version":"0.3.0","sound_library":{"default_soundfont":{"path":"/Users/wangjuntao/.local/share/scorekit/sounds/sf2/MuseScore_General.sf2","status":"ok"},"path":"/Users/wangjuntao/.local/share/scorekit/sounds","source":"platform default"},"tools":[{"name":"ffmpeg","path":"/opt/homebrew/bin/ffmpeg","role":"audio export","status":"ok","version":"ffmpeg version 8.1.1 Copyright (c) 2000-2026 the FFmpeg developers"},{"name":"fluidsynth","path":"/opt/homebrew/bin/fluidsynth","role":"SF2 renderer","status":"ok","version":"FluidSynth runtime version 2.5.6"},{"name":"timidity","path":"/opt/homebrew/bin/timidity","role":"alternate SF2 renderer","status":"ok","version":"TiMidity++ version 2.15.0"},{"name":"sfizz_render","path":"/Users/wangjuntao/.local/bin/sfizz_render","role":"SFZ renderer","status":"ok","version":"Render a midi file through an SFZ file using the sfizz library."}]} diff --git a/src/lib/api.ts b/src/lib/api.ts index 9eadcd1..eb36455 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -114,6 +114,14 @@ export interface TrackDisplay { articulation?: string | null; } +export interface TextureDisplay { + source?: string | null; + mode?: string | null; + gain?: number | null; + start_beat?: number | null; + at: number[]; +} + export interface SceneDisplay { title?: string | null; story?: string | null; @@ -125,6 +133,7 @@ export interface SceneDisplay { harmony: string[]; sections: SectionDisplay[]; tracks: TrackDisplay[]; + textures: TextureDisplay[]; has_performance: boolean; } @@ -133,6 +142,7 @@ export interface SceneInspection { parse_error?: string | null; validation: ValidationDisplay; render_profile?: RenderProfileCompat | null; + texture_profile?: TextureProfileCompat | null; last_diff?: unknown; } @@ -145,6 +155,7 @@ export interface ValidationDisplay { export interface RenderConfig { renderer?: string | null; profile?: string | null; + texture_profile?: string | null; } /** Matches the Rust `manifest::ProfileCompat` serde shape. */ @@ -156,6 +167,15 @@ export interface RenderProfileCompat { error?: string | null; } +/** Matches the Rust `manifest::TextureProfileCompat` serde shape. */ +export interface TextureProfileCompat { + profile?: string | null; + profile_name?: string | null; + available: string[]; + missing: string[]; + error?: string | null; +} + export interface ScorekitHandshake { found: boolean; ready: boolean; @@ -202,6 +222,7 @@ export interface BuildParams { stems?: boolean; soundfont?: string; profile?: string; + texture_profile?: string; } export interface PerspectiveReview { diff --git a/src/lib/components/ScenePanel.svelte b/src/lib/components/ScenePanel.svelte index 1f02f1c..a40726d 100644 --- a/src/lib/components/ScenePanel.svelte +++ b/src/lib/components/ScenePanel.svelte @@ -20,6 +20,7 @@ let stems = $state(false); let format = $state<"ogg" | "wav">("ogg"); let profilePath = $state(null); + let textureProfilePath = $state(null); let renderConfigRoot: string | null = null; let inspection = $state(null); let inspectionError = $state(null); @@ -35,6 +36,21 @@ { key: "gauge.energy", words: ["Calm", "Driving", "Intense"] }, ] as const; + function absoluteConfigPath(root: string, path: string | null | undefined): string | null { + if (!path) return null; + const absolute = + path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\"); + return absolute ? path : `${root}/${path}`; + } + + function manifestConfigPath(root: string, path: string | null): string | null { + if (!path) return null; + for (const prefix of [`${root}/`, `${root}\\`]) { + if (path.startsWith(prefix)) return path.slice(prefix.length); + } + return path; + } + $effect(() => { const root = bench.project?.root; const path = bench.selectedScene; @@ -55,33 +71,38 @@ }); // Load the persisted render selection (bench.json) once per project so the - // agent and the render panel agree on renderer + profile. + // agent and the render panel agree on renderer and both profile types. $effect(() => { const root = bench.project?.root; if (!root || root === renderConfigRoot) return; renderConfigRoot = root; void api.loadRenderConfig(root).then( ([config]) => { - if (renderConfigRoot !== root || !config) return; - if (config.renderer) renderer = config.renderer; - profilePath = config.profile - ? config.profile.startsWith("/") - ? config.profile - : `${root}/${config.profile}` - : null; + if (renderConfigRoot !== root) return; + renderer = config?.renderer ?? "fluidsynth"; + profilePath = absoluteConfigPath(root, config?.profile); + textureProfilePath = absoluteConfigPath(root, config?.texture_profile); + }, + (error) => { + if (renderConfigRoot !== root) return; + bench.buildFailed = true; + bench.buildStatus = errorText(error); }, - () => {}, ); }); function persistRenderConfig() { const root = bench.project?.root; if (!root) return; - const profile = - profilePath && profilePath.startsWith(root + "/") - ? profilePath.slice(root.length + 1) - : profilePath; - void api.saveRenderConfig(root, { renderer, profile }).catch(() => {}); + const profile = manifestConfigPath(root, profilePath); + const texture_profile = manifestConfigPath(root, textureProfilePath); + void api.saveRenderConfig(root, { renderer, profile, texture_profile }).then( + () => bench.projectRevision++, + (error) => { + bench.buildFailed = true; + bench.buildStatus = errorText(error); + }, + ); } const audioAssets = $derived((bench.project?.assets ?? []).filter((asset) => asset.kind === "audio")); @@ -95,9 +116,15 @@ ? intensities.reduce((sum, value) => sum + value, 0) / intensities.length : 0.45; return [ - Math.min(100, 18 + scene.tracks.length * 13 + scene.sections.length * 5), + Math.min( + 100, + 18 + scene.tracks.length * 13 + scene.sections.length * 5 + scene.textures.length * 7, + ), Math.min(100, Math.round(average * 100)), - Math.min(100, 16 + scene.harmony.length * 7 + scene.tracks.length * 9), + Math.min( + 100, + 16 + scene.harmony.length * 7 + scene.tracks.length * 9 + scene.textures.length * 6, + ), Math.min(100, Math.max(12, Math.round(((scene.tempo ?? 90) - 45) * 0.85 + average * 22))), ]; }); @@ -147,6 +174,9 @@ } const needsProfile = $derived(renderer === "sfizz" && !profilePath); + const needsTextureProfile = $derived( + (inspection?.scene?.textures.length ?? 0) > 0 && !textureProfilePath, + ); async function pickProfile() { const picked = await open({ @@ -161,8 +191,28 @@ } } + async function pickTextureProfile() { + const picked = await open({ + multiple: false, + directory: false, + defaultPath: bench.project?.root, + filters: [{ name: "Texture profile", extensions: ["yaml", "yml"] }], + }); + if (typeof picked === "string") { + textureProfilePath = picked; + persistRenderConfig(); + } + } + async function render() { - if (!bench.project || !bench.selectedScene || bench.building || needsProfile) return; + if ( + !bench.project || + !bench.selectedScene || + bench.building || + needsProfile || + needsTextureProfile + ) + return; bench.building = true; bench.buildFailed = false; bench.buildStatus = "starting…"; @@ -171,6 +221,7 @@ // Absolute path: the scorekit subprocess does not run from the project root. params.profile = profilePath; } + if (textureProfilePath) params.texture_profile = textureProfilePath; try { await api.runBuild(bench.project.root, bench.selectedScene, params, format, onBuildEvent); } catch (error) { @@ -277,6 +328,7 @@ {inspection.scene.tracks.length}{t("preview.tracks")} {inspection.scene.sections.length}{t("preview.sections")} {inspection.scene.harmony.length}{t("preview.harmony")} + {inspection.scene.textures.length}{t("preview.textures")} {#if inspection.last_diff}
@@ -294,6 +346,18 @@ })}

{/if} {/if} + {#if inspection.texture_profile} + {#if !inspection.texture_profile.profile} +

{t("panel.textureProfileMissing")}

+ {:else if inspection.texture_profile.error} +

{t("panel.textureProfileUnusable", { error: inspection.texture_profile.error })}

+ {:else if inspection.texture_profile.missing.length} +

{t("panel.textureProfileUnmapped", { + profile: inspection.texture_profile.profile_name ?? inspection.texture_profile.profile, + sources: inspection.texture_profile.missing.join(", "), + })}

+ {/if} + {/if} {#if inspection.validation.error}

{errorText(inspection.validation.error)}

{/if} {/if} @@ -314,21 +378,42 @@ {#if renderer === "sfizz"} -
- {#if profilePath} - {profilePath.split("/").at(-1)} + + {/if} +
+ + {#if needsProfile} +

{t("panel.profileHint")}

+ {/if} + {/if} +
+ {t("panel.textureProfile")} +
+ - {#if profilePath} - + {#if textureProfilePath} + {/if}
- {#if needsProfile} -

{t("panel.profileHint")}

- {/if} +
+ {#if needsTextureProfile} +

{t("panel.textureProfileHint")}

{/if} {#if renderer === "sfizz" && inspection?.render_profile} {#if inspection.render_profile.error} @@ -340,7 +425,19 @@ })}

{/if} {/if} - {#if bench.building}
{/if} @@ -480,7 +577,7 @@ .dial strong { z-index: 1; grid-area: 1 / 1; color: var(--fg); font: 14px var(--mono); font-weight: 400; } .dial small { color: var(--fg-muted); font-size: 10px; } - .stats { display: grid; grid-template-columns: repeat(3, 1fr); } + .stats { display: grid; grid-template-columns: repeat(4, 1fr); } .stats span { display: grid; gap: 3px; justify-items: center; padding: 11px 6px 10px; border-right: 1px solid var(--line); } .stats span:last-child { border-right: 0; } .stats b { color: var(--fg); font: 17px var(--mono); font-weight: 450; } @@ -500,7 +597,9 @@ .params select:focus, .params input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } .params .check { display: flex; align-items: center; gap: 5px; padding-top: 11px; } .params input[type="checkbox"] { accent-color: var(--accent); } - .profile-row { display: flex; gap: 6px; margin: -2px 10px 8px; } + .profile-field { display: grid; gap: 4px; margin: -2px 10px 8px; } + .profile-label { color: var(--fg-label); font-size: var(--ui-label-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; } + .profile-row { display: flex; gap: 6px; } .profile-pick { display: flex; flex: 1; align-items: center; min-width: 0; height: 28px; padding: 0 10px; color: var(--fg); background: var(--panel-glass); border: 1px solid var(--accent-line); border-radius: 6px; font: 11px var(--mono); cursor: pointer; transition: border-color .15s ease, background .15s ease; } .profile-pick:hover:not(:disabled) { border-color: var(--accent-line-strong); background: var(--accent-soft); } .profile-pick strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } diff --git a/src/lib/components/ScenePreview.svelte b/src/lib/components/ScenePreview.svelte index bfd3179..9f1da68 100644 --- a/src/lib/components/ScenePreview.svelte +++ b/src/lib/components/ScenePreview.svelte @@ -53,6 +53,7 @@ {#if scene.bars != null}{scene.bars} bars{/if} {#if scene.loop_enabled}loop{/if} {#if scene.has_performance}performance{/if} + {#if scene.textures.length}{scene.textures.length} {t("preview.textures")}{/if} {inspection.validation.status} @@ -128,6 +129,42 @@ {/if} + + {#if scene.textures.length} +
+

{t("preview.textures")} {scene.textures.length}

+ + + + + + + + + + + + {#each scene.textures as texture, index} + + + + + + + + {/each} + +
#{t("preview.source")}{t("preview.mode")}{t("preview.schedule")}{t("preview.gain")}
{index + 1}{texture.source ?? "—"}{texture.mode ?? "—"} + {#if texture.mode === "loop"} + {t("preview.beat")} {texture.start_beat ?? 0} + {:else if texture.at.length} + {texture.at.join(", ")} + {:else} + — + {/if} + {(texture.gain ?? 1).toFixed(2)}
+
+ {/if} {/if} diff --git a/src/lib/i18n.svelte.ts b/src/lib/i18n.svelte.ts index 02c865a..467bdcd 100644 --- a/src/lib/i18n.svelte.ts +++ b/src/lib/i18n.svelte.ts @@ -153,6 +153,11 @@ const en = { "panel.profileDialog": "Choose sfizz profile YAML", "panel.profileUnmapped": "Profile {profile} has no mapping for: {instruments}. The sfizz build will fail.", "panel.profileUnusable": "Render profile problem: {error}", + "panel.textureProfile": "Texture profile", + "panel.textureProfileHint": "This scene uses textures. Choose a profile that maps source names to audio files.", + "panel.textureProfileMissing": "This scene uses textures but no texture profile is configured. The build will fail.", + "panel.textureProfileUnmapped": "Texture profile {profile} has no mapping for: {sources}. The build will fail.", + "panel.textureProfileUnusable": "Texture profile problem: {error}", "panel.renderBtn": "Render", "panel.rendering": "Rendering…", "panel.currentOutputs": "Current outputs", @@ -193,6 +198,12 @@ const en = { "preview.intensity": "Intensity", "preview.articulation": "Articulation", "preview.noTracks": "No tracks declared.", + "preview.textures": "Textures", + "preview.source": "Source", + "preview.mode": "Mode", + "preview.schedule": "Schedule", + "preview.gain": "Gain", + "preview.beat": "beat", // Player "player.visual": "Visual", @@ -387,6 +398,11 @@ const zh: Record = { "panel.profileUnmapped": "Profile {profile} 缺少这些乐器的映射:{instruments},sfizz 构建会失败。", "panel.profileUnusable": "渲染 profile 有问题:{error}", "panel.profileDialog": "选择 sfizz profile YAML", + "panel.textureProfile": "声音纹理 profile", + "panel.textureProfileHint": "当前场景使用了声音纹理,请选择一个将 source 名称映射到音频文件的 profile。", + "panel.textureProfileMissing": "当前场景使用了声音纹理,但尚未配置纹理 profile,构建将失败。", + "panel.textureProfileUnmapped": "纹理 profile {profile} 缺少这些 source 映射:{sources},构建将失败。", + "panel.textureProfileUnusable": "声音纹理 profile 有问题:{error}", "panel.renderBtn": "渲染", "panel.rendering": "渲染中…", "panel.currentOutputs": "当前产物", @@ -425,6 +441,12 @@ const zh: Record = { "preview.intensity": "强度", "preview.articulation": "奏法", "preview.noTracks": "没有声明音轨。", + "preview.textures": "声音纹理", + "preview.source": "声音源", + "preview.mode": "模式", + "preview.schedule": "触发位置", + "preview.gain": "增益", + "preview.beat": "拍", "player.visual": "视觉", "player.auto": "自动", From 6d891faddf0953c86774face87d3963dc3871266 Mon Sep 17 00:00:00 2001 From: jettwang Date: Wed, 22 Jul 2026 17:42:51 +0800 Subject: [PATCH 2/2] perf(spectrum): smooth visual switching (0.3.2) Retain initialized WebGL scenes across style changes, preload lazy modules during idle time, use English picker labels, and soften Voyage engine particles. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src-tauri/tauri.conf.json | 2 +- src/lib/components/SpectrumView.svelte | 173 +++++++++++++++++-------- src/lib/spectrum/index.test.ts | 12 ++ src/lib/spectrum/index.ts | 6 +- src/lib/spectrum/runtime.test.ts | 31 ++++- src/lib/spectrum/runtime.ts | 35 +++++ src/lib/spectrum/three/voyage.ts | 9 +- src/lib/spectrum/voyage.test.ts | 2 + src/lib/spectrum/voyage.ts | 1 + 9 files changed, 214 insertions(+), 57 deletions(-) create mode 100644 src/lib/spectrum/index.test.ts diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c83f0fa..3290d48 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "scorebench", - "version": "0.3.1", + "version": "0.3.2", "identifier": "com.talkincode.scorebench", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/lib/components/SpectrumView.svelte b/src/lib/components/SpectrumView.svelte index 251ca70..89c6d97 100644 --- a/src/lib/components/SpectrumView.svelte +++ b/src/lib/components/SpectrumView.svelte @@ -1,11 +1,14 @@
- {#key glKey} - - {/key} + {#each threeStyles as threeStyle (threeStyle.id)} + + {/each}