diff --git a/apps/backend/server.py b/apps/backend/server.py index 664872a2..30d788b4 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -46,7 +46,7 @@ should_start_in_process_workers, ) from utils.cleanup import cleanup_artifacts -<<<<<<< HEAD +import upload_limits from server_upload import ( # noqa: F401 — re-exported for test backward compat LEGACY_ENDPOINT_SUNSET, ERROR_PHASE_LOCAL_DSP, @@ -139,10 +139,6 @@ _validate_phase2_catalog_entry, _validate_phase2_semantics, ) -======= -import upload_limits -import uuid ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd app = FastAPI(title="Sonic Analyzer Local API") @@ -2566,7 +2562,7 @@ async def analyze_audio( ): upload_size = _uploaded_file_size_bytes(track) if upload_size is not None and upload_size > upload_limits.MAX_UPLOAD_SIZE_BYTES: - return _legacy_upload_too_large_file_response(str(uuid.uuid4())) + return _legacy_upload_too_large_file_response(str(uuid4())) request_id = str(uuid4()) logger.warning("Legacy compatibility endpoint hit: /api/analyze request_id=%s", request_id) diff --git a/apps/backend/tests/test_audio_fixture.py b/apps/backend/tests/test_audio_fixture.py new file mode 100644 index 00000000..15e713cc --- /dev/null +++ b/apps/backend/tests/test_audio_fixture.py @@ -0,0 +1,331 @@ +import json +import subprocess +import sys +import tempfile +import unittest +import wave +from pathlib import Path + +import numpy as np + + +EXPECTED_TOP_LEVEL_KEYS = { + "bpm", "bpmConfidence", "bpmPercival", "bpmAgreement", + "bpmDoubletime", "bpmSource", "bpmRawOriginal", + "key", "keyConfidence", "keyProfile", "tuningFrequency", "tuningCents", + "timeSignature", "timeSignatureSource", + "timeSignatureConfidence", "durationSeconds", "sampleRate", + "lufsIntegrated", "lufsRange", "lufsMomentaryMax", "lufsShortTermMax", + "truePeak", "plr", "crestFactor", + "dynamicSpread", "dynamicCharacter", "textureCharacter", "stereoDetail", + "monoCompatible", "spectralBalance", + "spectralDetail", "rhythmDetail", "melodyDetail", "transcriptionDetail", + "pitchDetail", "grooveDetail", "beatsLoudness", "rhythmTimeline", + "sidechainDetail", "acidDetail", "reverbDetail", + "vocalDetail", "supersawDetail", "bassDetail", "kickDetail", + "genreDetail", "effectsDetail", "synthesisCharacter", + "danceability", "structure", "arrangementDetail", + "segmentLoudness", "segmentSpectral", "segmentStereo", "segmentKey", + "chordDetail", "perceptual", "essentiaFeatures", +} + +EXPECTED_SPECTRAL_BANDS = { + "subBass", "lowBass", "lowMids", "mids", + "upperMids", "highs", "brilliance", +} + + +def _write_smoke_fixture( + path: Path, + sample_rate: int = 44_100, + duration_seconds: float = 8.0, +) -> None: + """Composite fixture: A-minor triad + 120 BPM clicks + gentle fade.""" + total_samples = int(sample_rate * duration_seconds) + time_axis = np.arange(total_samples, dtype=np.float32) / sample_rate + + harmonic = ( + 0.30 * np.sin(2 * np.pi * 220.00 * time_axis) + + 0.20 * np.sin(2 * np.pi * 261.63 * time_axis) + + 0.20 * np.sin(2 * np.pi * 329.63 * time_axis) + + 0.10 * np.sin(2 * np.pi * 440.00 * time_axis) + ).astype(np.float32) + + bpm = 120.0 + beat_interval = int(round(sample_rate * 60.0 / bpm)) + click_samples = max(8, int(round(sample_rate * 10.0 / 1000.0))) + click_shape = np.hanning(click_samples).astype(np.float32) + clicks = np.zeros(total_samples, dtype=np.float32) + for start in range(0, total_samples, beat_interval): + stop = min(total_samples, start + click_samples) + clicks[start:stop] += 0.7 * click_shape[: stop - start] + + envelope = np.linspace(1.0, 0.85, total_samples, dtype=np.float32) + signal = (harmonic + clicks) * envelope + + stereo = np.stack([signal, signal], axis=1) + pcm = np.clip(stereo, -1.0, 1.0) + pcm = (pcm * 32767.0).astype(np.int16) + + with wave.open(str(path), "wb") as wav_file: + wav_file.setnchannels(2) + wav_file.setsampwidth(2) + wav_file.setframerate(sample_rate) + wav_file.writeframes(pcm.tobytes()) + + +def _run_analyze(analyze_path: Path, fixture_path: Path) -> tuple[str, str]: + try: + completed = subprocess.run( + [sys.executable, str(analyze_path), str(fixture_path), "--yes"], + cwd=analyze_path.parent, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as error: + raise AssertionError( + f"analyze.py failed.\nstdout:\n{error.stdout[:800]}\n" + f"stderr:\n{error.stderr[:800]}" + ) from error + return completed.stdout, completed.stderr + + +class AudioFixtureSmokeTest(unittest.TestCase): + """End-to-end smoke test: deterministic composite fixture -> full analyze.py -> pinned value assertions.""" + + SAMPLE_RATE = 44_100 + FIXTURE_DURATION = 8.0 + + EXPECTED_BPM = 120.0 + EXPECTED_BPM_TOLERANCE = 1.0 + EXPECTED_LUFS = -8.9 + EXPECTED_LUFS_TOLERANCE = 0.5 + EXPECTED_LUFS_RANGE = 0.8 + EXPECTED_LUFS_RANGE_TOLERANCE = 0.5 + EXPECTED_TRUE_PEAK = 1.0 + EXPECTED_TRUE_PEAK_TOLERANCE = 0.1 + EXPECTED_PLR = 9.9 + EXPECTED_PLR_TOLERANCE = 0.7 + EXPECTED_CREST_FACTOR = 11.0 + EXPECTED_CREST_FACTOR_TOLERANCE = 0.5 + + EXPECTED_SPECTRAL = { + "subBass": -21.5, + "lowBass": -8.1, + "lowMids": -9.6, + "mids": -46.4, + "upperMids": -67.2, + "highs": -77.1, + "brilliance": -78.4, + } + SPECTRAL_TOLERANCE = 2.0 + + @classmethod + def setUpClass(cls) -> None: + cls.repo_root = Path(__file__).resolve().parent.parent + cls.analyze_path = cls.repo_root / "analyze.py" + cls.temp_dir = tempfile.TemporaryDirectory(prefix="sonic_analyzer_smoke_") + cls.fixture_path = Path(cls.temp_dir.name) / "smoke_fixture.wav" + _write_smoke_fixture(cls.fixture_path, cls.SAMPLE_RATE, cls.FIXTURE_DURATION) + + stdout, stderr = _run_analyze(cls.analyze_path, cls.fixture_path) + try: + cls.result = json.loads(stdout) + except json.JSONDecodeError as error: + raise AssertionError( + "analyze.py did not emit valid JSON for the smoke fixture.\n" + f"stdout:\n{stdout[:800]}\nstderr:\n{stderr[:800]}" + ) from error + + @classmethod + def tearDownClass(cls) -> None: + cls.temp_dir.cleanup() + + # -- Tier 1: Exact-match fields -- + + def test_sample_rate_exact(self) -> None: + self.assertEqual(self.result["sampleRate"], self.SAMPLE_RATE) + + def test_time_signature_exact(self) -> None: + self.assertEqual(self.result["timeSignature"], "4/4") + self.assertEqual(self.result["timeSignatureSource"], "assumed_four_four") + self.assertEqual(self.result["timeSignatureConfidence"], 0.0) + + def test_mono_compatible_exact(self) -> None: + self.assertTrue(self.result["monoCompatible"]) + + def test_bpm_agreement_exact(self) -> None: + self.assertTrue(self.result["bpmAgreement"]) + self.assertFalse(self.result["bpmDoubletime"]) + self.assertEqual(self.result["bpmSource"], "rhythm_extractor_confirmed") + + # -- Tier 2: Tight-tolerance numeric fields -- + + def test_duration(self) -> None: + self.assertAlmostEqual( + self.result["durationSeconds"], + self.FIXTURE_DURATION, + delta=0.15, + ) + + def test_bpm(self) -> None: + self.assertAlmostEqual( + self.result["bpm"], + self.EXPECTED_BPM, + delta=self.EXPECTED_BPM_TOLERANCE, + ) + + def test_lufs_integrated(self) -> None: + self.assertAlmostEqual( + self.result["lufsIntegrated"], + self.EXPECTED_LUFS, + delta=self.EXPECTED_LUFS_TOLERANCE, + ) + + def test_lufs_range(self) -> None: + self.assertAlmostEqual( + self.result["lufsRange"], + self.EXPECTED_LUFS_RANGE, + delta=self.EXPECTED_LUFS_RANGE_TOLERANCE, + ) + + def test_true_peak(self) -> None: + self.assertAlmostEqual( + self.result["truePeak"], + self.EXPECTED_TRUE_PEAK, + delta=self.EXPECTED_TRUE_PEAK_TOLERANCE, + ) + + def test_plr(self) -> None: + self.assertAlmostEqual( + self.result["plr"], + self.EXPECTED_PLR, + delta=self.EXPECTED_PLR_TOLERANCE, + ) + + def test_crest_factor(self) -> None: + self.assertAlmostEqual( + self.result["crestFactor"], + self.EXPECTED_CREST_FACTOR, + delta=self.EXPECTED_CREST_FACTOR_TOLERANCE, + ) + + def test_stereo_detail(self) -> None: + stereo = self.result["stereoDetail"] + self.assertIsInstance(stereo, dict) + self.assertAlmostEqual(stereo["stereoWidth"], 0.0, delta=0.01) + self.assertAlmostEqual(stereo["stereoCorrelation"], 1.0, delta=0.01) + + # -- Tier 3: Key detection -- + + def test_key_detection(self) -> None: + self.assertIn(self.result["key"], {"A Minor", "C Major"}) + self.assertIsInstance(self.result["keyConfidence"], (int, float)) + self.assertGreater(self.result["keyConfidence"], 0.3) + + # -- Tier 4: Spectral balance bands -- + + def test_spectral_balance_bands(self) -> None: + spectral = self.result["spectralBalance"] + self.assertIsInstance(spectral, dict) + self.assertEqual(set(spectral.keys()), EXPECTED_SPECTRAL_BANDS) + + for band, expected_value in self.EXPECTED_SPECTRAL.items(): + with self.subTest(band=band): + self.assertAlmostEqual( + spectral[band], + expected_value, + delta=self.SPECTRAL_TOLERANCE, + msg=f"spectralBalance.{band} drifted from pinned value", + ) + + # -- Tier 5: Detail objects -- + + def test_rhythm_detail(self) -> None: + rhythm = self.result["rhythmDetail"] + self.assertIsInstance(rhythm, dict) + beat_grid = rhythm.get("beatGrid") + self.assertIsInstance(beat_grid, list) + self.assertGreater(len(beat_grid), 10) + beat_positions = rhythm.get("beatPositions") + self.assertIsInstance(beat_positions, list) + self.assertEqual(len(beat_positions), len(beat_grid)) + self.assertTrue(all(p in {1, 2, 3, 4} for p in beat_positions)) + downbeats = rhythm.get("downbeats") + self.assertIsInstance(downbeats, list) + self.assertGreater(len(downbeats), 0) + + def test_groove_detail(self) -> None: + groove = self.result["grooveDetail"] + self.assertIsInstance(groove, dict) + + def test_beats_loudness(self) -> None: + beats = self.result["beatsLoudness"] + self.assertIsInstance(beats, dict) + + def test_structure(self) -> None: + structure = self.result["structure"] + self.assertIsInstance(structure, dict) + segments = structure.get("segments") + self.assertIsInstance(segments, list) + self.assertGreater(len(segments), 0) + + def test_danceability(self) -> None: + dance = self.result["danceability"] + self.assertIsInstance(dance, dict) + self.assertIn("danceability", dance) + self.assertIsInstance(dance["danceability"], (int, float)) + self.assertGreaterEqual(dance["danceability"], 0.0) + self.assertLessEqual(dance["danceability"], 3.0) + + def test_dynamic_character(self) -> None: + dc = self.result["dynamicCharacter"] + self.assertIsInstance(dc, dict) + + def test_texture_character(self) -> None: + tc = self.result["textureCharacter"] + self.assertIsInstance(tc, dict) + + def test_acid_detail_negative(self) -> None: + acid = self.result["acidDetail"] + self.assertIsInstance(acid, dict) + self.assertFalse(acid["isAcid"]) + + def test_reverb_detail_negative(self) -> None: + reverb = self.result["reverbDetail"] + self.assertIsInstance(reverb, dict) + self.assertFalse(reverb["isWet"]) + + def test_remaining_detail_fields_populated(self) -> None: + for field in ("bassDetail", "kickDetail", "genreDetail", "effectsDetail", + "synthesisCharacter", "perceptual", "essentiaFeatures"): + with self.subTest(field=field): + self.assertIsNotNone( + self.result[field], + f"{field} should be populated in full mode", + ) + + def test_chord_detail_present(self) -> None: + self.assertIn("chordDetail", self.result) + + # -- Tier 6: Expected nulls -- + + def test_transcription_detail_null(self) -> None: + self.assertIsNone(self.result["transcriptionDetail"]) + + def test_pitch_detail_null(self) -> None: + self.assertIsNone(self.result["pitchDetail"]) + + # -- Tier 7: Schema completeness -- + + def test_output_contains_all_expected_keys(self) -> None: + self.assertEqual( + set(self.result.keys()), + EXPECTED_TOP_LEVEL_KEYS, + "Output schema diverged from expected top-level keys.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/ui/src/services/analysisRunsClient.ts b/apps/ui/src/services/analysisRunsClient.ts index 7d29545b..186f2380 100644 --- a/apps/ui/src/services/analysisRunsClient.ts +++ b/apps/ui/src/services/analysisRunsClient.ts @@ -19,14 +19,9 @@ import { PitchNoteTranslationAttemptSummary, PitchNoteTranslationStageSnapshot, } from '../types'; -<<<<<<< HEAD -import { parsePhase1Result } from './backendPhase1Client'; -======= import { appConfig, buildConfiguredRequestInit } from '../config'; import { BackendClientError, createUserCancelledError, parsePhase1Result } from './backendPhase1Client'; ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd import { requestBackendEstimate } from './backendPhase1Client'; -import { fetchJson } from './httpClient'; interface AnalysisRunsClientOptions { apiBaseUrl: string; @@ -267,8 +262,6 @@ export function projectStemSummaryFromRun(snapshot: AnalysisRunSnapshot): StemSu return profile.result as StemSummaryResult | null; } -<<<<<<< HEAD -======= async function fetchJson(url: string, init: RequestInit): Promise { let response: Response; try { @@ -321,7 +314,6 @@ async function fetchJson(url: string, init: RequestInit): Promise { return payload; } ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd function parseAnalysisRunSnapshot(value: unknown): AnalysisRunSnapshot { const root = expectRecord(value, 'analysis run'); const stages = expectRecord(root.stages, 'analysis run stages'); diff --git a/apps/ui/src/services/spectralArtifactsClient.ts b/apps/ui/src/services/spectralArtifactsClient.ts index 66fc2b79..c37badc0 100644 --- a/apps/ui/src/services/spectralArtifactsClient.ts +++ b/apps/ui/src/services/spectralArtifactsClient.ts @@ -4,11 +4,7 @@ import type { SpectralArtifactRef, SpectralTimeSeriesData, } from '../types'; -<<<<<<< HEAD -import { fetchJson } from './httpClient'; -======= import { buildConfiguredRequestInit } from '../config'; ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd export function buildArtifactUrl( apiBaseUrl: string, @@ -52,15 +48,11 @@ export async function fetchSpectralTimeSeries( options?: { signal?: AbortSignal }, ): Promise { const url = buildArtifactUrl(apiBaseUrl, runId, artifactId); -<<<<<<< HEAD - return fetchJson(url, { signal: options?.signal }) as Promise; -======= const response = await fetch(url, buildConfiguredRequestInit({ signal: options?.signal })); if (!response.ok) { throw new Error(`Failed to fetch spectral time series: ${response.status}`); } return response.json() as Promise; ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd } export type SpectralEnhancementKind = 'cqt' | 'hpss' | 'onset' | 'chroma_interactive'; @@ -72,9 +64,6 @@ export async function generateSpectralEnhancement( options?: { signal?: AbortSignal }, ): Promise<{ artifacts: SpectralArtifactRef[] }> { const url = `${apiBaseUrl}/api/analysis-runs/${encodeURIComponent(runId)}/spectral-enhancements/${encodeURIComponent(kind)}`; -<<<<<<< HEAD - return fetchJson(url, { method: 'POST', signal: options?.signal }) as Promise<{ artifacts: SpectralArtifactRef[] }>; -======= const response = await fetch( url, buildConfiguredRequestInit({ method: 'POST', signal: options?.signal }), @@ -84,7 +73,6 @@ export async function generateSpectralEnhancement( throw new Error(body?.error?.message ?? `Enhancement generation failed: ${response.status}`); } return response.json(); ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd } export async function fetchOnsetStrengthData( @@ -94,15 +82,11 @@ export async function fetchOnsetStrengthData( options?: { signal?: AbortSignal }, ): Promise { const url = buildArtifactUrl(apiBaseUrl, runId, artifactId); -<<<<<<< HEAD - return fetchJson(url, { signal: options?.signal }) as Promise; -======= const response = await fetch(url, buildConfiguredRequestInit({ signal: options?.signal })); if (!response.ok) { throw new Error(`Failed to fetch onset strength data: ${response.status}`); } return response.json() as Promise; ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd } export async function fetchChromaInteractiveData( @@ -112,13 +96,9 @@ export async function fetchChromaInteractiveData( options?: { signal?: AbortSignal }, ): Promise { const url = buildArtifactUrl(apiBaseUrl, runId, artifactId); -<<<<<<< HEAD - return fetchJson(url, { signal: options?.signal }) as Promise; -======= const response = await fetch(url, buildConfiguredRequestInit({ signal: options?.signal })); if (!response.ok) { throw new Error(`Failed to fetch interactive chroma data: ${response.status}`); } return response.json() as Promise; ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd } diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index 155cc106..a3d3b4a0 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -1,853 +1 @@ -<<<<<<< HEAD export * from './types/index'; -======= -export interface MelodyNote { - midi: number; - onset: number; - duration: number; -} - -export interface MelodyPitchRange { - min: number | null; - max: number | null; -} - -export interface MelodyDetail { - noteCount: number; - notes: MelodyNote[]; - dominantNotes: number[]; - pitchRange: MelodyPitchRange; - pitchConfidence: number; - midiFile: string | null; - sourceSeparated: boolean; - vibratoPresent: boolean; - vibratoExtent: number; - vibratoRate: number; - vibratoConfidence: number; -} - -export interface TranscriptionNote { - pitchMidi: number; - pitchName: string; - onsetSeconds: number; - durationSeconds: number; - confidence: number; - stemSource: "bass" | "other" | "full_mix"; -} - -export interface TranscriptionDetail { - transcriptionMethod: string; - noteCount: number; - averageConfidence: number; - stemSeparationUsed: boolean; - fullMixFallback: boolean; - stemsTranscribed: string[]; - dominantPitches: Array<{ - pitchMidi: number; - pitchName: string; - count: number; - }>; - pitchRange: { - minMidi: number | null; - maxMidi: number | null; - minName: string | null; - maxName: string | null; - }; - notes: TranscriptionNote[]; -} - -export interface DanceabilityResult { - danceability: number; - dfa: number; -} - -export interface StereoDetail { - stereoWidth: number | null; - stereoCorrelation: number | null; - subBassCorrelation?: number | null; - subBassMono?: boolean | null; -} - -export interface SpectralDetail { - spectralCentroidMean?: number | null; - spectralRolloffMean?: number | null; - spectralBandwidthMean?: number | null; - spectralFlatnessMean?: number | null; - mfcc?: number[] | null; - chroma?: number[] | null; - barkBands?: number[] | null; - erbBands?: number[] | null; - spectralContrast?: number[] | null; - spectralValley?: number[] | null; -} - -export interface PhraseGrid { - phrases4Bar: number[]; - phrases8Bar: number[]; - phrases16Bar: number[]; - totalBars: number; - totalPhrases8Bar: number; -} - -export interface RhythmDetail { - onsetRate: number; - beatGrid: number[]; - downbeats: number[]; - beatPositions: number[]; - grooveAmount: number; - tempoStability?: number | null; - phraseGrid?: PhraseGrid | null; -} - -export interface GrooveDetail { - kickSwing: number; - hihatSwing: number; - kickAccent: number[]; - hihatAccent: number[]; -} - -export interface SidechainDetail { - pumpingStrength: number; - pumpingRegularity: number; - pumpingRate: string | null; - pumpingConfidence: number; - envelopeShape?: number[] | null; -} - -export interface EffectsDetail { - gatingDetected?: boolean | null; - gatingRate?: number | null; - gatingRegularity?: number | null; - gatingEventCount?: number | null; -} - -export interface SynthesisCharacter { - inharmonicity?: number | null; - oddToEvenRatio?: number | null; - analogLike?: boolean | null; -} - -export interface StructureData { - segments?: unknown[] | null; - segmentCount?: number | null; - sections?: number | null; -} - -export interface ArrangementDetail { - noveltyCurve?: number[] | null; - noveltyPeaks?: number[] | null; - noveltyMean?: number | null; - noveltyStdDev?: number | null; - sectionCount?: number | null; -} - -export interface SegmentLoudnessEntry { - segmentIndex?: number; - start?: number; - end?: number; - lufs?: number | null; - lra?: number | null; - value?: number | null; -} - -export interface SegmentSpectralEntry { - segmentIndex: number; - barkBands?: number[] | null; - spectralCentroid?: number | null; - spectralRolloff?: number | null; - stereoWidth?: number | null; - stereoCorrelation?: number | null; -} - -export interface SegmentStereoEntry { - segmentIndex: number; - stereoWidth?: number | null; - stereoCorrelation?: number | null; -} - -export interface SegmentKeyEntry { - segmentIndex: number; - key: string | null; - keyConfidence?: number | null; -} - -export interface ChordDetail { - chordSequence?: string[] | null; - chordStrength?: number | null; - progression?: string[] | null; - dominantChords?: string[] | null; -} - -export interface PerceptualDetail { - sharpness: number; - roughness: number; -} - -export interface EssentiaFeatures { - zeroCrossingRate?: number | null; - hfc?: number | null; - spectralComplexity?: number | null; - dissonance?: number | null; -} - -export interface DynamicCharacter { - dynamicComplexity: number; - loudnessDb: number; - loudnessVariation?: number | null; - spectralFlatness: number; - logAttackTime: number; - attackTimeStdDev: number; -} - -export interface TextureCharacter { - textureScore: number; - lowBandFlatness: number; - midBandFlatness: number; - highBandFlatness: number; - inharmonicity?: number | null; -} - -export interface BeatsLoudness { - kickDominantRatio: number; - midDominantRatio: number; - highDominantRatio: number; - patternBeatsPerBar: number; - lowBandAccentPattern: number[]; - midBandAccentPattern: number[]; - highBandAccentPattern: number[]; - overallAccentPattern: number[]; - accentPattern: number[]; - meanBeatLoudness: number; - beatLoudnessVariation: number; - beatCount: number; -} - -export interface RhythmTimelineWindow { - bars: number; - startBar: number; - endBar: number; - lowBandSteps: number[]; - midBandSteps: number[]; - highBandSteps: number[]; - overallSteps: number[]; -} - -export interface RhythmTimeline { - beatsPerBar: number; - stepsPerBeat: number; - availableBars: number; - selectionMethod: "representative_dsp_window"; - windows: RhythmTimelineWindow[]; -} - -export interface PitchStemResult { - medianPitchHz: number | null; - pitchRangeLowHz: number | null; - pitchRangeHighHz: number | null; - meanPeriodicity: number; - voicedFramePercent: number; - hopLength: number; - sampleRate: number; - model: string; -} - -export interface PitchDetail { - method: string; - stems: Record; -} - -export interface AcidDetail { - isAcid: boolean; - confidence: number; - resonanceLevel: number; - centroidOscillationHz: number; - bassRhythmDensity: number; -} - -export interface ReverbDetail { - rt60: number | null; - isWet: boolean; - tailEnergyRatio: number | null; - measured: boolean; -} - -export interface VocalDetail { - hasVocals: boolean; - confidence: number; - vocalEnergyRatio: number; - formantStrength: number; - mfccLikelihood: number; -} - -export interface SupersawDetail { - isSupersaw: boolean; - confidence: number; - voiceCount: number; - avgDetuneCents: number; - spectralComplexity: number; -} - -export interface BassDetail { - averageDecayMs: number; - type: "punchy" | "medium" | "rolling" | "sustained"; - transientRatio: number; - fundamentalHz: number | null; - transientCount: number; - swingPercent: number; - grooveType: string; -} - -export interface KickDetail { - isDistorted: boolean; - thd: number; - harmonicRatio: number; - fundamentalHz: number | null; - kickCount: number; -} - -export interface GenreDetail { - genre: string; - confidence: number; - secondaryGenre: string | null; - genreFamily: "house" | "techno" | "dnb" | "ambient" | "trance" | "dubstep" | "breaks" | "other"; - topScores: Array<{ genre: string; score: number }>; -} - -export interface Phase1Result { - bpm: number; - bpmConfidence: number; - bpmPercival?: number | null; - bpmAgreement?: boolean | null; - bpmDoubletime?: boolean | null; - bpmSource?: string | null; - bpmRawOriginal?: number | null; - key: string | null; - keyConfidence: number; - keyProfile?: string | null; - tuningFrequency?: number | null; - tuningCents?: number | null; - timeSignature: string; - timeSignatureSource?: string | null; - timeSignatureConfidence?: number | null; - durationSeconds: number; - sampleRate?: number | null; - lufsIntegrated: number; - lufsRange?: number | null; - lufsMomentaryMax?: number | null; - lufsShortTermMax?: number | null; - truePeak: number; - plr?: number | null; - crestFactor?: number | null; - dynamicSpread?: number | null; - dynamicCharacter?: DynamicCharacter | null; - textureCharacter?: TextureCharacter | null; - stereoWidth: number; - stereoCorrelation: number; - stereoDetail?: StereoDetail | null; - monoCompatible?: boolean | null; - spectralBalance: { - subBass: number; - lowBass: number; - lowMids: number; - mids: number; - upperMids: number; - highs: number; - brilliance: number; - }; - spectralDetail?: SpectralDetail | null; - rhythmDetail?: RhythmDetail | null; - melodyDetail?: MelodyDetail; - transcriptionDetail?: TranscriptionDetail | null; - pitchDetail?: PitchDetail | null; - grooveDetail?: GrooveDetail | null; - beatsLoudness?: BeatsLoudness | null; - rhythmTimeline?: RhythmTimeline | null; - sidechainDetail?: SidechainDetail | null; - effectsDetail?: EffectsDetail | null; - synthesisCharacter?: SynthesisCharacter | null; - danceability?: DanceabilityResult | null; - structure?: StructureData | null; - arrangementDetail?: ArrangementDetail | null; - segmentLoudness?: SegmentLoudnessEntry[] | null; - segmentSpectral?: SegmentSpectralEntry[] | null; - segmentStereo?: SegmentStereoEntry[] | null; - segmentKey?: SegmentKeyEntry[] | null; - chordDetail?: ChordDetail | null; - perceptual?: PerceptualDetail | null; - essentiaFeatures?: EssentiaFeatures | null; - acidDetail?: AcidDetail | null; - reverbDetail?: ReverbDetail | null; - vocalDetail?: VocalDetail | null; - supersawDetail?: SupersawDetail | null; - bassDetail?: BassDetail | null; - kickDetail?: KickDetail | null; - genreDetail?: GenreDetail | null; -} - -export type MeasurementResult = Omit; - -export type RecommendationCategory = - | "SYNTHESIS" - | "DYNAMICS" - | "EQ" - | "EFFECTS" - | "STEREO" - | "MASTERING" - | "MIDI" - | "ROUTING"; - -export type DeviceFamily = "NATIVE" | "MAX_FOR_LIVE"; - -export type WorkflowStage = - | "PROJECT_SETUP" - | "SOUND_DESIGN" - | "ARRANGEMENT" - | "MIX" - | "MASTER"; - -export type WarpMode = - | "Beats" - | "Tones" - | "Texture" - | "Re-Pitch" - | "Complex" - | "Complex Pro"; - -export interface Phase2Grounding { - phase1Fields: string[]; - segmentIndexes?: number[]; -} - -export interface Phase2ProjectSetup { - tempoBpm: number; - timeSignature: string; - sampleRate: number; - bitDepth: number; - headroomTarget: string; - sessionGoal: string; -} - -export interface Phase2TrackLayoutItem { - order: number; - name: string; - type: string; - purpose: string; - grounding: Phase2Grounding; -} - -export interface RoutingBlueprintReturn { - name: string; - purpose: string; - sendSources: string[]; - deviceFocus: string; - levelGuidance: string; -} - -export interface RoutingBlueprint { - sidechainSource?: string | null; - sidechainTargets: string[]; - returns: RoutingBlueprintReturn[]; - notes: string[]; -} - -export interface WarpGuideTarget { - warpMode: WarpMode; - settings?: string; - reason: string; -} - -export interface Phase2WarpGuide { - fullTrack: WarpGuideTarget; - drums: WarpGuideTarget; - bass: WarpGuideTarget; - melodic: WarpGuideTarget; - vocals?: WarpGuideTarget; - rationale: string; -} - -export interface SecretSauceWorkflowStep { - step: number; - trackContext: string; - device: string; - parameter: string; - value: string; - instruction: string; - measurementJustification: string; -} - -export interface AbletonRecommendation { - device: string; - deviceFamily?: DeviceFamily; - trackContext?: string; - workflowStage?: WorkflowStage; - category: RecommendationCategory; - parameter: string; - value: string; - reason: string; - advancedTip?: string; -} - -export interface AudioObservationElement { - element: string; - description: string; -} - -export interface AudioObservations { - soundDesignFingerprint: string; - elementCharacter: AudioObservationElement[]; - productionSignatures: string[]; - mixContext: string; -} - -export interface Phase2Result { - trackCharacter: string; - projectSetup?: Phase2ProjectSetup; - trackLayout?: Phase2TrackLayoutItem[]; - routingBlueprint?: RoutingBlueprint; - warpGuide?: Phase2WarpGuide; - audioObservations?: AudioObservations; - detectedCharacteristics: { - name: string; - confidence: "HIGH" | "MED" | "LOW"; - explanation: string; - }[]; - arrangementOverview: { - summary: string; - segments: Array<{ - index: number; - startTime: number; - endTime: number; - lufs?: number; - description: string; - spectralNote?: string; - sceneName?: string; - abletonAction?: string; - automationFocus?: string; - }>; - noveltyNotes?: string; - }; - sonicElements: { - kick: string; - bass: string; - melodicArp: string; - grooveAndTiming: string; - effectsAndTexture: string; - widthAndStereo?: string; - harmonicContent?: string; - }; - mixAndMasterChain: Array<{ - order: number; - device: string; - deviceFamily?: DeviceFamily; - trackContext?: string; - workflowStage?: WorkflowStage; - parameter: string; - value: string; - reason: string; - }>; - secretSauce: { - title: string; - icon?: string; - explanation: string; - implementationSteps: string[]; - workflowSteps?: SecretSauceWorkflowStep[]; - }; - confidenceNotes: { - field: string; - value: string; - reason: string; - }[]; - abletonRecommendations: AbletonRecommendation[]; -} - -export interface StemSummaryBar { - barStart: number; - barEnd: number; - startTime: number; - endTime: number; - noteHypotheses: string[]; - scaleDegreeHypotheses: string[]; - rhythmicPattern: string; - uncertaintyLevel: "LOW" | "MED" | "HIGH"; - uncertaintyReason: string; -} - -export interface StemSummaryStem { - stem: 'bass' | 'other'; - label: string; - summary: string; - bars: StemSummaryBar[]; - globalPatterns: { - bassRole: string; - melodicRole: string; - pumpingOrModulation: string; - }; - uncertaintyFlags: string[]; -} - -export interface StemSummaryResult { - summary: string; - stems: StemSummaryStem[]; - uncertaintyFlags: string[]; -} - -export type InterpretationResult = Phase2Result | StemSummaryResult; - -export type InterpretationSchemaVersion = "interpretation.v1" | "interpretation.v2"; - -export interface InterpretationValidationWarning { - code?: string; - path?: string; - message: string; - originalValue?: string; - coercedValue?: string; - dropReason?: string; -} - -export interface BackendTimingDiagnostics { - totalMs: number; - analysisMs: number; - serverOverheadMs: number; - flagsUsed: string[]; - fileSizeBytes: number; - fileDurationSeconds: number | null; - msPerSecondOfAudio: number | null; -} - -export interface BackendDiagnostics { - backendDurationMs: number; - engineVersion?: string; - estimatedLowMs?: number; - estimatedHighMs?: number; - timeoutSeconds?: number; - stdoutSnippet?: string; - stderrSnippet?: string; - timings?: BackendTimingDiagnostics; -} - -export interface BackendAnalyzeResponse { - requestId: string; - // COMPAT: non-canonical, do not use in primary flow. - analysisRunId?: string; - phase1: Phase1Result; - diagnostics?: BackendDiagnostics; -} - -export type AnalysisStageStatus = - | 'queued' - | 'running' - | 'blocked' - | 'ready' - | 'completed' - | 'failed' - | 'interrupted' - | 'not_requested'; - -export interface AnalysisStageError { - code: string; - message: string; - retryable?: boolean; - phase?: string; -} - -export interface AnalysisRunArtifact { - artifactId: string; - filename: string; - mimeType: string; - sizeBytes: number; - contentSha256: string; -} - -export interface SpectralArtifactRef { - artifactId: string; - kind: - | 'spectrogram_mel' - | 'spectrogram_chroma' - | 'spectrogram_cqt' - | 'spectrogram_harmonic' - | 'spectrogram_percussive' - | 'spectrogram_onset'; - filename: string; - mimeType: string; - sizeBytes: number; -} - -export interface SpectralArtifacts { - spectrograms: SpectralArtifactRef[]; - timeSeries: SpectralArtifactRef | null; - onsetStrength: SpectralArtifactRef | null; - chromaInteractive: SpectralArtifactRef | null; -} - -export interface OnsetStrengthData { - timePoints: number[]; - onsetStrength: number[]; - sampleRate: number; - hopLength: number; - originalFrameCount: number; - downsampledTo: number; -} - -export interface ChromaInteractiveData { - timePoints: number[]; - pitchClasses: string[]; - chroma: number[][]; - sampleRate: number; - hopLength: number; - originalFrameCount: number; - downsampledTo: number; -} - -export interface SpectralTimeSeriesData { - timePoints: number[]; - spectralCentroid: number[]; - spectralRolloff: number[]; - spectralBandwidth: number[]; - spectralFlatness: number[]; - sampleRate: number; - hopLength: number; - originalFrameCount: number; - downsampledTo: number; -} - -export interface AnalysisRunRequestedStages { - analysisMode: 'full' | 'standard'; - pitchNoteMode: string; - pitchNoteBackend: string; - interpretationMode: string; - interpretationProfile: string; - interpretationModel: string | null; -} - -export interface MeasurementAvailabilityContext { - analysisMode?: 'full' | 'standard'; - hasRunContext: boolean; -} - -export interface MeasurementStageSnapshot { - status: AnalysisStageStatus; - authoritative: true; - result: MeasurementResult | null; - provenance: Record | null; - diagnostics: Record | null; - error: AnalysisStageError | null; -} - -export interface PitchNoteTranslationAttemptSummary { - attemptId: string; - backendId: string; - mode: string; - status: AnalysisStageStatus; -} - -export interface InterpretationAttemptSummary { - attemptId: string; - profileId: string; - modelName: string | null; - status: AnalysisStageStatus; -} - -export interface PitchNoteTranslationStageSnapshot { - status: AnalysisStageStatus; - authoritative: false; - preferredAttemptId: string | null; - attemptsSummary: PitchNoteTranslationAttemptSummary[]; - result: TranscriptionDetail | null; - provenance: Record | null; - diagnostics: Record | null; - error: AnalysisStageError | null; -} - -export interface InterpretationStageSnapshot { - status: AnalysisStageStatus; - authoritative: false; - preferredAttemptId: string | null; - attemptsSummary: InterpretationAttemptSummary[]; - result: InterpretationResult | null; - provenance: Record | null; - diagnostics: Record | null; - error: AnalysisStageError | null; - profiles?: Record | null; - diagnostics: Record | null; - error: AnalysisStageError | null; - }>; -} - -export interface AnalysisRunSnapshot { - runId: string; - requestedStages: AnalysisRunRequestedStages; - artifacts: { - sourceAudio: AnalysisRunArtifact; - stems?: AnalysisRunArtifact[]; - spectral?: SpectralArtifacts; - }; - stages: { - measurement: MeasurementStageSnapshot; - pitchNoteTranslation: PitchNoteTranslationStageSnapshot; - interpretation: InterpretationStageSnapshot; - }; -} - -export interface BackendEstimateStage { - key: string; - label: string; - lowMs: number; - highMs: number; -} - -export interface BackendAnalysisEstimate { - durationSeconds: number; - totalLowMs: number; - totalHighMs: number; - stages: BackendEstimateStage[]; -} - -export interface BackendEstimateResponse { - requestId: string; - estimate: BackendAnalysisEstimate; -} - -export interface BackendErrorPayload { - code: string; - message: string; - phase: string; - retryable: boolean; -} - -export interface BackendErrorResponse { - requestId: string; - error: BackendErrorPayload; - diagnostics?: BackendDiagnostics; -} - -export type DiagnosticLogStatus = "running" | "success" | "error" | "skipped"; - -export interface DiagnosticLogEntry { - model: string; - phase: string; - stageKey?: 'measurement' | 'pitchNoteTranslation' | 'interpretation' | 'system'; - promptLength: number; - responseLength: number; - durationMs: number; - audioMetadata: { - name: string; - size: number; - type: string; - }; - timestamp: string; - requestId?: string; - source?: "backend" | "gemini" | "system"; - status?: DiagnosticLogStatus; - message?: string; - errorCode?: string; - estimateLowMs?: number; - estimateHighMs?: number; - timings?: BackendTimingDiagnostics; - validationReport?: import('./services/phase2Validator').ValidationReport; -} ->>>>>>> c3e6e0bf8fc19444d5c6012beea8c299dc0ab1dd diff --git a/apps/ui/src/types/backend.ts b/apps/ui/src/types/backend.ts index d5180749..ef172b30 100644 --- a/apps/ui/src/types/backend.ts +++ b/apps/ui/src/types/backend.ts @@ -53,7 +53,7 @@ export interface AnalysisRunArtifact { mimeType: string; sizeBytes: number; contentSha256: string; - path: string; + path?: string; } export interface SpectralArtifactRef { diff --git a/apps/ui/tests/services/analysisRunsClient.test.ts b/apps/ui/tests/services/analysisRunsClient.test.ts index 16391e8c..9b463a8d 100644 --- a/apps/ui/tests/services/analysisRunsClient.test.ts +++ b/apps/ui/tests/services/analysisRunsClient.test.ts @@ -30,6 +30,7 @@ const baseRunSnapshot: AnalysisRunSnapshot = { mimeType: 'audio/mpeg', sizeBytes: 1024, contentSha256: 'abc123', + path: 'uploads/track.mp3', }, }, stages: { diff --git a/apps/ui/tests/smoke/error-states.spec.ts b/apps/ui/tests/smoke/error-states.spec.ts index 19664054..59a2fc57 100644 --- a/apps/ui/tests/smoke/error-states.spec.ts +++ b/apps/ui/tests/smoke/error-states.spec.ts @@ -78,6 +78,7 @@ function buildRunSnapshot(runId: string, overrides: RunSnapshotOverrides = {}) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/file-validation.spec.ts b/apps/ui/tests/smoke/file-validation.spec.ts index 37f5b00f..b842de63 100644 --- a/apps/ui/tests/smoke/file-validation.spec.ts +++ b/apps/ui/tests/smoke/file-validation.spec.ts @@ -71,6 +71,7 @@ function stubBackendRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -126,6 +127,7 @@ function stubBackendRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/phase2-degradation.spec.ts b/apps/ui/tests/smoke/phase2-degradation.spec.ts index d5b2b59a..aaf3c00c 100644 --- a/apps/ui/tests/smoke/phase2-degradation.spec.ts +++ b/apps/ui/tests/smoke/phase2-degradation.spec.ts @@ -76,6 +76,7 @@ function stubAnalyzeRoute(page: import('@playwright/test').Page, requestId = 're mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -143,6 +144,7 @@ function stubRunPoll( mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/responsive-layout.spec.ts b/apps/ui/tests/smoke/responsive-layout.spec.ts index c6bb1de8..dba2397f 100644 --- a/apps/ui/tests/smoke/responsive-layout.spec.ts +++ b/apps/ui/tests/smoke/responsive-layout.spec.ts @@ -92,6 +92,7 @@ function stubRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -147,6 +148,7 @@ function stubRoutes(page: import('@playwright/test').Page) { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/ui-details.spec.ts b/apps/ui/tests/smoke/ui-details.spec.ts index a84fa894..89ab8149 100644 --- a/apps/ui/tests/smoke/ui-details.spec.ts +++ b/apps/ui/tests/smoke/ui-details.spec.ts @@ -184,6 +184,7 @@ function stubRoutes( mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -239,6 +240,7 @@ function stubRoutes( mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -339,6 +341,7 @@ test('CPU indicator animates during analysis', async ({ page }) => { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -400,6 +403,7 @@ test('CPU indicator animates during analysis', async ({ page }) => { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/upload-estimate-phase1.spec.ts b/apps/ui/tests/smoke/upload-estimate-phase1.spec.ts index 33c08fb2..69579664 100644 --- a/apps/ui/tests/smoke/upload-estimate-phase1.spec.ts +++ b/apps/ui/tests/smoke/upload-estimate-phase1.spec.ts @@ -94,6 +94,7 @@ test('upload shows estimate and local DSP processing copy before phase1 complete mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -152,6 +153,7 @@ test('upload shows estimate and local DSP processing copy before phase1 complete mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts index b5bae750..27e3194c 100644 --- a/apps/ui/tests/smoke/upload-phase1-midi.spec.ts +++ b/apps/ui/tests/smoke/upload-phase1-midi.spec.ts @@ -158,6 +158,7 @@ test('phase1 dual-source session musician panel toggles between pitch-note and m mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -214,6 +215,7 @@ test('phase1 dual-source session musician panel toggles between pitch-note and m mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -465,6 +467,7 @@ test('missing melodyDetail shows MIDI unavailable state', async ({ page }) => { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -521,6 +524,7 @@ test('missing melodyDetail shows MIDI unavailable state', async ({ page }) => { mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { diff --git a/apps/ui/tests/smoke/upload-phase1.spec.ts b/apps/ui/tests/smoke/upload-phase1.spec.ts index b234fd16..2da543b0 100644 --- a/apps/ui/tests/smoke/upload-phase1.spec.ts +++ b/apps/ui/tests/smoke/upload-phase1.spec.ts @@ -73,6 +73,7 @@ test('upload + backend phase1 success renders analysis results', async ({ page } mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: { @@ -129,6 +130,7 @@ test('upload + backend phase1 success renders analysis results', async ({ page } mimeType: 'audio/wav', sizeBytes: 2048, contentSha256: 'abc123', + path: 'uploads/test.wav', }, }, stages: {