Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 79 additions & 31 deletions src/conductor_main/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
LoopGenerationEngine,
ProviderCredentials,
)
from conductor_core.music import get_loop_prompt, get_model_info
from conductor_core.music import ENHARMONIC_NOTE_NAMES, get_loop_prompt, get_model_info
from conductor_core.playback import (
add_soundfont_search_dir,
get_default_soundfont,
Expand All @@ -44,13 +44,32 @@
DEFAULT_MODEL = "gemini-3.1-flash-lite"
CONDUCTOR_APP_DIRNAME = "main"
MAX_HISTORY_GENERATIONS = 20
KEY_CHOICES = (
"C",
"C#/Db",
"D",
"D#/Eb",
"E",
"F",
"F#/Gb",
"G",
"G#/Ab",
"A",
"A#/Bb",
"B",
)
SHARP_KEY_ALIASES = {
"C#/Db": "C#",
"D#/Eb": "D#",
"F#/Gb": "F#",
"G#/Ab": "G#",
"A#/Bb": "A#",
}
CORE_KEY_TO_UI = {
key: KEY_CHOICES[pitch_class]
for pitch_class, keys in enumerate(ENHARMONIC_NOTE_NAMES)
for key in keys
}
APP_CSS = """
.center-title { text-align: center; font-size: 3em; }
.app-header {
Expand All @@ -64,15 +83,49 @@
z-index: 1;
}
.history-sidebar {
background: #1a1a1a;
border-left: 1px solid #333;
background: var(--background-fill-primary);
border-left: 1px solid var(--border-color-primary);
color: var(--body-text-color);
height: 100%;
overflow-y: auto;
}
.history-item {
background: var(--block-background-fill);
border: 1px solid var(--border-color-primary);
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
}
.history-item:hover {
border-color: #666 !important;
border-color: var(--border-color-accent) !important;
cursor: pointer;
}
.history-item-title {
color: var(--body-text-color);
font-weight: bold;
margin-bottom: 4px;
}
.history-item-prompt {
color: var(--block-label-text-color);
font-size: 0.85em;
margin-bottom: 6px;
}
.history-item-metadata {
color: var(--block-label-text-color);
display: flex;
font-size: 0.8em;
justify-content: space-between;
}
.history-item-cost {
color: var(--block-label-text-color);
font-size: 0.75em;
margin-top: 4px;
}
.history-empty {
color: var(--block-label-text-color);
padding: 20px;
text-align: center;
}
"""


Expand Down Expand Up @@ -123,6 +176,14 @@ def normalize_key_for_core(key):
return SHARP_KEY_ALIASES.get(key, key)


def normalize_key_for_ui(key):
"""Coerce a supported Core key spelling to its 12-note UI choice."""
if not isinstance(key, str):
return None

return CORE_KEY_TO_UI.get(normalize_key_for_core(key))


def get_generation(gen_id):
return HISTORY_STORE.get_generation(gen_id)

Expand Down Expand Up @@ -223,6 +284,12 @@ def get_history_control_updates(gen):
provider = gen.provider
model = gen.model
warnings = []
ui_key = normalize_key_for_ui(gen.key)
if ui_key is None:
key_update = gr.update()
warnings.append(f"Unavailable key: {gen.key!r}.")
else:
key_update = gr.update(value=ui_key)

provider_choices = list(known_models)
provider_is_available = provider in known_models
Expand Down Expand Up @@ -273,7 +340,7 @@ def get_history_control_updates(gen):
effort_options.append(effort_value)

return HistoryControlUpdates(
key=gr.update(value=gen.key),
key=key_update,
scale=gr.update(value=gen.scale),
description=gr.update(value=gen.prompt),
provider=gr.update(choices=provider_choices, value=provider),
Expand Down Expand Up @@ -818,7 +885,7 @@ def render_history_html():

if not history:
return """
<div style="padding: 20px; text-align: center; color: #888;">
<div class="history-empty">
<p>No generations yet.</p>
<p style="font-size: 0.9em;">Your generated loops will appear here.</p>
</div>
Expand All @@ -839,24 +906,18 @@ def render_history_html():
reasoning_suffix = f" ({reasoning})" if reasoning else ""

html_parts.append(f"""
<div class="history-item" data-id="{generation_id}" style="
background: #2a2a2a;
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
border: 1px solid #444;
">
<div style="font-weight: bold; color: #fff; margin-bottom: 4px;">
<div class="history-item" data-id="{generation_id}">
<div class="history-item-title">
{key} {scale}
</div>
<div style="font-size: 0.85em; color: #aaa; margin-bottom: 6px;">
<div class="history-item-prompt">
"{prompt_preview}"
</div>
<div style="font-size: 0.8em; color: #888; display: flex; justify-content: space-between;">
<div class="history-item-metadata">
<span>{model}{reasoning_suffix}</span>
<span>{timestamp_str}</span>
</div>
<div style="font-size: 0.75em; color: #666; margin-top: 4px;">
<div class="history-item-cost">
Cost: {cost_str}
</div>
</div>
Expand Down Expand Up @@ -1109,20 +1170,7 @@ def create_demo(playback_status=None):
with gr.Column():
gr.Markdown("## Loop Parameters")
key_input = gr.Dropdown(
choices=[
"C",
"C#/Db",
"D",
"D#/Eb",
"E",
"F",
"F#/Gb",
"G",
"G#/Ab",
"A",
"A#/Bb",
"B",
],
choices=KEY_CHOICES,
label="Key",
value="C",
)
Expand Down
68 changes: 66 additions & 2 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ def test_normalize_key_for_core_defaults_combined_black_keys_to_sharps():
} == expected_keys


def test_normalize_key_for_ui_coerces_core_enharmonics_to_twelve_choices():
expected_keys = {
"C#": "C#/Db",
"Db": "C#/Db",
"D#": "D#/Eb",
"F#": "F#/Gb",
"G#": "G#/Ab",
"A#": "A#/Bb",
"C##": "D",
"Gbb": "F",
"B#": "C",
"C": "C",
}

assert {
key: app.normalize_key_for_ui(key) for key in expected_keys
} == expected_keys
assert app.normalize_key_for_ui("not-a-key") is None


def test_run_loop_passes_ui_configuration_to_core(monkeypatch, tmp_path):
captured = {}
midi_path = tmp_path / "loop.mid"
Expand Down Expand Up @@ -277,6 +297,42 @@ def test_history_controls_restore_known_effort_model_exactly(monkeypatch):
assert updates.warnings == ()


def test_history_controls_warn_and_preserve_key_for_invalid_saved_value(monkeypatch):
monkeypatch.setattr(
app,
"get_model_info",
lambda: {
"models": {
"OpenAI": {
"known-model": {
"extended_thinking": False,
"effort_options": [],
}
}
}
},
)
monkeypatch.setattr(app.gr, "update", lambda **kwargs: kwargs)

updates = app.get_history_control_updates(
SimpleNamespace(
key="not-a-key",
scale="Major",
prompt="restored prompt",
provider="OpenAI",
model="known-model",
temperature=0.5,
use_thinking=False,
effort="low",
)
)

assert updates.key == {}
assert updates.scale == {"value": "Major"}
assert updates.description == {"value": "restored prompt"}
assert updates.warnings == ("Unavailable key: 'not-a-key'.",)


def test_history_controls_restore_known_toggle_model_exactly(monkeypatch):
monkeypatch.setattr(
app,
Expand Down Expand Up @@ -510,7 +566,7 @@ def test_load_history_item_warns_when_saved_soundfont_is_missing(monkeypatch, tm
audio_path=str(audio_path),
soundfont="missing.sf2",
id=gen_id,
key="D",
key="C#",
scale="minor",
prompt="saved prompt",
provider="Google",
Expand Down Expand Up @@ -556,7 +612,7 @@ def test_load_history_item_warns_when_saved_soundfont_is_missing(monkeypatch, tm
assert saved_soundfont == "missing.sf2"
assert current_audio_path == str(audio_path)
assert rerender_update["interactive"] is True
assert key_update["value"] == "D"
assert key_update["value"] == "C#/Db"
assert scale_update["value"] == "minor"
assert description_update["value"] == "saved prompt"
assert provider_update["value"] == "Google"
Expand Down Expand Up @@ -740,6 +796,14 @@ def test_render_history_html_displays_zero_cost(monkeypatch):
assert "Cost: N/A" not in html


def test_render_history_html_uses_theme_aware_classes(monkeypatch):
monkeypatch.setattr(app, "load_history", list)

rendered_history = app.render_history_html()

assert 'class="history-empty"' in rendered_history


def test_render_history_html_displays_missing_cost_as_na(monkeypatch):
monkeypatch.setattr(
app,
Expand Down
Loading