From 3271ba1aed41d49fdced0141ce0f3ea5ea8e5452 Mon Sep 17 00:00:00 2001 From: Luke Mainwaring Date: Sun, 5 Apr 2026 17:52:09 -0400 Subject: [PATCH 1/4] unpitched types and agent instructions for cleaner workflows --- backend/src/samplespace/agents/sample_agent.py | 2 +- .../src/samplespace/agents/tools/formatting.py | 12 +++++++++--- .../samplespace/agents/tools/transform_tools.py | 7 +++++++ backend/src/samplespace/schemas/sample_type.py | 14 ++++++++++++++ .../src/samplespace/services/candidate_search.py | 16 +++------------- backend/src/samplespace/services/kit_builder.py | 8 +++++--- frontend/components/elements/sample-card.tsx | 6 ++++++ 7 files changed, 45 insertions(+), 20 deletions(-) diff --git a/backend/src/samplespace/agents/sample_agent.py b/backend/src/samplespace/agents/sample_agent.py index 351ad80..a73fd5b 100644 --- a/backend/src/samplespace/agents/sample_agent.py +++ b/backend/src/samplespace/agents/sample_agent.py @@ -41,7 +41,7 @@ - **Pair feedback**: present_pair → user verdict → record_verdict. The system learns from verdicts over time — after enough feedback, use show_preferences to explain what it has learned. - **Rapid pairing**: When the user asks to "start a pairing session" or "evaluate pairs," use present_pair with anchor_type and candidate_type (omit sample_id for random anchors). When you receive a `[NEXT_PAIR]` message, call record_verdict for the previous pair, then immediately call present_pair again with the same types — keep it fast, minimal commentary. - **Upload flow**: User uploads a WAV → analyze_sample → find_similar_to_upload to find library matches. -- If the user references a sample by name rather than ID, search for it first. +- **Resolving sample references**: Users will refer to samples by ordinal position ("the 3rd one", "the first result"), by filename ("warm-pad.wav"), or by description ("that bass loop"). When they use an ordinal, resolve it from the most recent search or tool results in the conversation — each result includes a 1-based `index` field. When they use a filename or partial name, search for it first. ## Output Rules diff --git a/backend/src/samplespace/agents/tools/formatting.py b/backend/src/samplespace/agents/tools/formatting.py index 5561e3d..e352737 100644 --- a/backend/src/samplespace/agents/tools/formatting.py +++ b/backend/src/samplespace/agents/tools/formatting.py @@ -11,8 +11,8 @@ def format_sample_results( ) -> str: """Format a list of sample results as a playable sample-results code fence.""" samples: list[dict[str, object]] = [] - for s in results: - payload = sample_to_payload(s) + for i, s in enumerate(results, start=1): + payload = sample_to_payload(s, index=i) if annotations and s.id in annotations: payload["annotation"] = annotations[s.id] samples.append(payload) @@ -21,13 +21,19 @@ def format_sample_results( return f"{header}\n\n```sample-results\n{json_str}\n```" -def sample_to_payload(sample: SampleSchema, audio_url: str | None = None) -> dict[str, object]: +def sample_to_payload( + sample: SampleSchema, + audio_url: str | None = None, + index: int | None = None, +) -> dict[str, object]: """Build a JSON-serializable payload dict for a sample.""" payload: dict[str, object] = { "id": sample.id, "filename": sample.filename, "audio_url": audio_url or f"/api/samples/{sample.id}/audio", } + if index is not None: + payload["index"] = index if sample.sample_type: payload["type"] = sample.sample_type if sample.is_loop: diff --git a/backend/src/samplespace/agents/tools/transform_tools.py b/backend/src/samplespace/agents/tools/transform_tools.py index 87b95ec..29d6ea5 100644 --- a/backend/src/samplespace/agents/tools/transform_tools.py +++ b/backend/src/samplespace/agents/tools/transform_tools.py @@ -8,6 +8,7 @@ from samplespace.agents.deps import AgentDeps from samplespace.schemas.sample import SampleSchema +from samplespace.schemas.sample_type import UNPITCHED_TYPES from samplespace.services import audio_transform as audio_transform_service from samplespace.services import music_theory as music_theory_service from samplespace.services import sample as sample_service @@ -60,6 +61,12 @@ async def transform_single_sample( will_stretch = target_bpm is not None and sample.bpm is not None skipped: list[str] = [] + # Percussive/noise types: skip pitch-shifting (degrades quality), keep BPM stretching + if sample.sample_type and sample.sample_type.lower() in UNPITCHED_TYPES: + if will_pitch: + skipped.append("Pitch-shift skipped — percussive sample type.") + will_pitch = False + if target_key and not sample.key: skipped.append("Key transformation skipped — sample has no detected key.") if target_bpm and not sample.bpm: diff --git a/backend/src/samplespace/schemas/sample_type.py b/backend/src/samplespace/schemas/sample_type.py index 62d0fb3..045e5d6 100644 --- a/backend/src/samplespace/schemas/sample_type.py +++ b/backend/src/samplespace/schemas/sample_type.py @@ -28,6 +28,20 @@ class SampleType(StrEnum): SAMPLE_TYPES: list[str] = sorted(t.value for t in SampleType) +# Sample types where pitch-shifting is harmful or meaningless. +# Percussive/noise-based — even as loops, shifting their pitch +# degrades quality without musical benefit. BPM time-stretching still applies. +UNPITCHED_TYPES: set[str] = { + SampleType.KICK, + SampleType.SNARE, + SampleType.HIHAT, + SampleType.CLAP, + SampleType.CYMBAL, + SampleType.PERCUSSION, + SampleType.DRUM, + SampleType.FX, +} + # Keyword-to-type mapping for inferring sample type from file paths. # Keys are SampleType enum members; values are directory/segment keywords # that map to that type (checked against lowercased path segments). diff --git a/backend/src/samplespace/services/candidate_search.py b/backend/src/samplespace/services/candidate_search.py index 8e50a85..15970ac 100644 --- a/backend/src/samplespace/services/candidate_search.py +++ b/backend/src/samplespace/services/candidate_search.py @@ -6,20 +6,10 @@ """ from samplespace.schemas.sample import SampleSchema -from samplespace.schemas.sample_type import SampleType +from samplespace.schemas.sample_type import UNPITCHED_TYPES from samplespace.schemas.thread import SongContext from samplespace.services.music_theory import normalize_bpm, semitone_key_score -# Types that are typically one-shots (no meaningful key) -ONE_SHOT_TYPES: set[str] = { - SampleType.KICK, - SampleType.SNARE, - SampleType.HIHAT, - SampleType.CLAP, - SampleType.PERCUSSION, - SampleType.FX, -} - # Re-ranking weight profiles: (clap, bpm, key) _TONAL_WEIGHTS = (0.4, 0.25, 0.35) _PERCUSSIVE_WEIGHTS = (0.5, 0.5, 0.0) @@ -43,7 +33,7 @@ def build_clap_query( parts.append(f"{sample_type} sample") if song_context: - if sample_type.lower() not in ONE_SHOT_TYPES and song_context.key: + if sample_type.lower() not in UNPITCHED_TYPES and song_context.key: parts.append(song_context.key) if song_context.bpm: parts.append(f"{song_context.bpm} BPM") @@ -67,7 +57,7 @@ def rerank_candidates( has_bpm = song_context.bpm is not None has_key = song_context.key is not None - is_tonal = sample_type.lower() not in ONE_SHOT_TYPES + is_tonal = sample_type.lower() not in UNPITCHED_TYPES w_clap, w_bpm, w_key = _TONAL_WEIGHTS if is_tonal else _PERCUSSIVE_WEIGHTS diff --git a/backend/src/samplespace/services/kit_builder.py b/backend/src/samplespace/services/kit_builder.py index 35427e4..88225d8 100644 --- a/backend/src/samplespace/services/kit_builder.py +++ b/backend/src/samplespace/services/kit_builder.py @@ -14,7 +14,7 @@ from samplespace.models.sample import Sample from samplespace.schemas.kit import KitResult, KitSlot, PairwiseEntry from samplespace.schemas.sample import SampleSchema -from samplespace.schemas.sample_type import SAMPLE_TYPES, SampleType +from samplespace.schemas.sample_type import SAMPLE_TYPES, UNPITCHED_TYPES, SampleType from samplespace.schemas.thread import SongContext from samplespace.services import embedding as embedding_service from samplespace.services import music_theory as music_theory_service @@ -267,8 +267,10 @@ def _fast_compatibility(sample_a: SampleSchema, sample_b: SampleSchema) -> float pair_key = frozenset({sample_a.sample_type.lower(), sample_b.sample_type.lower()}) scores.append(TYPE_COMPLEMENTARITY.get(pair_key, DEFAULT_TYPE_SCORE)) - # Key compatibility (only for loops with keys) - if sample_a.is_loop and sample_b.is_loop and sample_a.key and sample_b.key: + # Key compatibility (only for pitched loops with keys) + a_pitched = sample_a.sample_type and sample_a.sample_type.lower() not in UNPITCHED_TYPES + b_pitched = sample_b.sample_type and sample_b.sample_type.lower() not in UNPITCHED_TYPES + if sample_a.is_loop and sample_b.is_loop and sample_a.key and sample_b.key and a_pitched and b_pitched: value, _ = music_theory_service.key_compatibility_score(sample_a.key, sample_b.key) scores.append(value) diff --git a/frontend/components/elements/sample-card.tsx b/frontend/components/elements/sample-card.tsx index d8e76fb..879c7fa 100644 --- a/frontend/components/elements/sample-card.tsx +++ b/frontend/components/elements/sample-card.tsx @@ -12,6 +12,7 @@ export interface SamplePayload { type?: string; key?: string; bpm?: number; + index?: number; } interface SampleCardProps { @@ -47,6 +48,11 @@ export function SampleCard({ }`} >
+ {sample.index != null && ( + + {sample.index} + + )} {onTogglePlay && (