From a1b40083680c668a7a15a62d55cab3b03df0e985 Mon Sep 17 00:00:00 2001 From: Dave Ronan Date: Wed, 22 Apr 2026 12:26:33 +0100 Subject: [PATCH 1/4] Add typed response models, fix retrieve_enhanced_track key name bug - Fix retrived_track_tasks_results -> revivedTrackTaskResults (camelCase) in enhance_controller.py, which silently broke the primary extraction path - Add 6 typed response dataclasses: EnhancedTrackResult, PreviewMixResult, FinalMixResult, PreviewMasterResult, FinalMasterResult, AnalysisResult - Update all 7 retrieval methods to return typed models instead of raw dicts - Rewrite inaccurate docstrings to reflect actual server response shapes - Update unit tests for new return types and corrected key names - Bump version to 1.3.2 Made-with: Cursor --- CHANGELOG.md | 25 ++ pyproject.toml | 2 +- roex_python/__init__.py | 2 +- .../controllers/analysis_controller.py | 127 +++------ roex_python/controllers/enhance_controller.py | 76 ++---- .../controllers/mastering_controller.py | 126 ++++----- roex_python/controllers/mix_controller.py | 243 +++++------------- roex_python/models/__init__.py | 14 +- roex_python/models/analysis.py | 21 +- roex_python/models/enhance.py | 21 +- roex_python/models/mastering.py | 20 +- roex_python/models/mixing.py | 28 +- setup.py | 2 +- .../test_analysis_controller.py | 110 +++----- .../test_enhance_controller.py | 59 ++--- .../test_mastering_controller.py | 42 ++- .../test_controllers/test_mix_controller.py | 55 ++-- 17 files changed, 392 insertions(+), 581 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 547129f..52008f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.2] - 2026-04-21 + +### Fixed +- Fixed `retrieve_enhanced_track` using wrong response key `revived_track_tasks_results` (snake_case) — now correctly reads `revivedTrackTaskResults` (camelCase) from the server response +- Rewrote inaccurate docstrings across all retrieval methods to reflect actual server response shapes + +### Added +- Typed response dataclasses for all retrieval endpoints, replacing raw `dict` returns: + - `EnhancedTrackResult` — fields: `download_url_preview_revived`, `download_url_revived`, `stems`, `preview_start_time` + - `PreviewMixResult` — fields: `download_url_preview_mixed`, `stems`, `mix_output_settings`, `status` + - `FinalMixResult` — fields: `download_url_mixed`, `stems`, `mix_output_settings` + - `PreviewMasterResult` — fields: `download_url_mastered_preview`, `preview_start_time` + - `FinalMasterResult` — fields: `download_url_mastered` + - `AnalysisResult` — fields: `payload`, `error`, `info`, `completion_time` +- All new response types are exported from `roex_python.models` + +### Changed +- `retrieve_enhanced_track` now returns `EnhancedTrackResult` instead of `dict` +- `retrieve_preview_mix` now returns `PreviewMixResult` instead of `Dict` +- `retrieve_final_mix` and `retrieve_final_mix_advanced` now return `FinalMixResult` instead of `Dict` +- `retrieve_preview_master` now returns `PreviewMasterResult` instead of `Dict` +- `retrieve_final_master` now returns `FinalMasterResult` instead of `Dict` +- `analyze_mix` now returns `AnalysisResult` instead of `Dict` + ## [1.3.1] - 2026-04-21 ### Fixed @@ -103,6 +127,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Asynchronous task polling - Secure file upload/download +[1.3.2]: https://github.com/roexaudio/roex-python/compare/v1.3.1...v1.3.2 [1.3.1]: https://github.com/roexaudio/roex-python/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/roexaudio/roex-python/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/roexaudio/roex-python/compare/v1.2.0...v1.2.1 diff --git a/pyproject.toml b/pyproject.toml index 73f4c23..508fef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "roex_python" -version = "1.3.1" +version = "1.3.2" description = "Pip package for the RoEx Tonn API" readme = "README.md" authors = [ diff --git a/roex_python/__init__.py b/roex_python/__init__.py index 4f9d1d3..d6ce489 100644 --- a/roex_python/__init__.py +++ b/roex_python/__init__.py @@ -5,7 +5,7 @@ for audio mixing, mastering, analysis, and enhancement. """ -__version__ = "1.3.1" +__version__ = "1.3.2" __author__ = "RoEx Audio" __email__ = "support@roexaudio.com" __license__ = "MIT" diff --git a/roex_python/controllers/analysis_controller.py b/roex_python/controllers/analysis_controller.py index 00f5e78..dff8061 100644 --- a/roex_python/controllers/analysis_controller.py +++ b/roex_python/controllers/analysis_controller.py @@ -7,7 +7,7 @@ import requests import logging -from roex_python.models.analysis import MixAnalysisRequest, AnalysisMusicalStyle +from roex_python.models.analysis import AnalysisMusicalStyle, AnalysisResult, MixAnalysisRequest from roex_python.providers.api_provider import ApiProvider # Initialize logger for this module @@ -30,47 +30,33 @@ def __init__(self, api_provider: ApiProvider): self.api_provider = api_provider logger.info("AnalysisController initialized.") - def analyze_mix(self, request: MixAnalysisRequest) -> Dict[str, Any]: + def analyze_mix(self, request: MixAnalysisRequest) -> AnalysisResult: """ Analyze a single mix or master track to retrieve detailed metrics. - This method sends the track URL and analysis parameters to the API - and returns the analysis results synchronously. + Sends the track URL and analysis parameters to ``/mixanalysis`` and + returns the results synchronously. Args: - request (MixAnalysisRequest): An object containing the track URL - (`audio_file_location`), the musical style for reference - (`musical_style`), and whether the track is mastered (`is_master`). - The URL must point to an accessible WAV or FLAC file. + request (MixAnalysisRequest): The track URL, musical style reference, + and ``is_master`` flag. Returns: - Dict[str, Any]: A dictionary containing the analysis results. - The structure typically includes detailed metrics under a 'payload' key, - covering loudness, dynamics, stereo field, phase, tonal balance, etc. - Check the official RoEx API documentation for the full structure. + AnalysisResult: A typed result containing: + - ``payload`` (Optional[Dict[str, Any]]): Diagnosis metrics including + ``integrated_loudness_lufs``, ``peak_loudness_dbfs``, + ``tonal_profile``, ``clipping``, ``stereo_field``, ``phase_issues``, + ``bit_depth``, ``sample_rate``, etc. + - ``error`` (bool): Whether the analysis encountered an error. + - ``info`` (str): Additional information from the API. + - ``completion_time`` (str): When the analysis finished. Raises: - requests.exceptions.RequestException: If the API request fails due to network - issues or invalid endpoint. - Exception: If the API returns an error response (e.g., 4xx, 5xx status codes) - indicating issues like invalid input, file access problems, or - server errors. + Exception: If the API returns an error response. Example: - >>> from roex_python.models import MixAnalysisRequest, AnalysisMusicalStyle - >>> # Assume 'client' is an initialized RoExClient - >>> # Assume 'track_url' is a URL obtained after uploading a local file - >>> analysis_request = MixAnalysisRequest( - ... audio_file_location=track_url, - ... musical_style=AnalysisMusicalStyle.ROCK_PUNK, - ... is_master=False - ... ) - >>> try: - >>> analysis_results = client.analysis.analyze_mix(analysis_request) - >>> print(f"Analysis Loudness (LUFS): {analysis_results.get('payload', {}).get('integrated_loudness_lufs')}") - >>> # Explore other metrics in analysis_results['payload'] - >>> except Exception as e: - >>> print(f"Error analyzing mix: {e}") + >>> result = client.analysis.analyze_mix(request) + >>> print(result.payload.get("integrated_loudness_lufs")) """ logger.info(f"Analyzing mix with parameters: {request}") payload = { @@ -84,11 +70,14 @@ def analyze_mix(self, request: MixAnalysisRequest) -> Dict[str, Any]: try: logger.debug(f"Sending analysis request to API: {payload}") response = self.api_provider.post("/mixanalysis", payload) - if "mixDiagnosisResults" in response: - logger.info("Analysis results received successfully.") - return response["mixDiagnosisResults"] - logger.info("Analysis results received without expected format.") - return response + raw = response.get("mixDiagnosisResults", response) + logger.info("Analysis results received successfully.") + return AnalysisResult( + payload=raw.get("payload"), + error=raw.get("error", False), + info=raw.get("info", ""), + completion_time=raw.get("completion_time", ""), + ) except requests.HTTPError as e: logger.error(f"Failed to analyze mix: {str(e)}") raise Exception(f"Failed to analyze mix: {str(e)}") @@ -101,43 +90,23 @@ def compare_mixes(self, mix_a_url: str, mix_b_url: str, """ Analyze two mixes and provide a comparison of their key metrics. - This method internally calls `analyze_mix` for both provided track URLs - and then computes differences between key metrics. + Calls ``analyze_mix`` for both URLs and computes per-metric differences. Args: - mix_a_url (str): URL of the first mix (must be accessible WAV/FLAC). - mix_b_url (str): URL of the second mix (must be accessible WAV/FLAC). - musical_style (AnalysisMusicalStyle): The musical style reference for analysis. - is_master (bool, optional): Whether the tracks should be analyzed as - mastered tracks. Defaults to False. + mix_a_url (str): URL of the first mix (accessible WAV/FLAC). + mix_b_url (str): URL of the second mix (accessible WAV/FLAC). + musical_style (AnalysisMusicalStyle): Musical style reference for analysis. + is_master (bool): Whether to analyze as mastered tracks. Defaults to False. Returns: - Dict[str, Any]: A dictionary containing 'mix_a' results, 'mix_b' results, - and calculated 'differences' between key metrics (loudness, etc.). + Dict[str, Any]: ``{"mix_a": {...}, "mix_b": {...}, "differences": {...}}``. Raises: - requests.exceptions.RequestException: If either underlying `analyze_mix` call fails - due to network issues. - Exception: If either underlying `analyze_mix` call returns an API error, - or if there's an issue during metric extraction/comparison. + Exception: If either underlying ``analyze_mix`` call fails. Example: - >>> from roex_python.models import AnalysisMusicalStyle - >>> # Assume 'client' is an initialized RoExClient - >>> # Assume 'track_url_a', 'track_url_b' are URLs for two mixes - >>> try: - >>> comparison = client.analysis.compare_mixes( - ... mix_a_url=track_url_a, - ... mix_b_url=track_url_b, - ... musical_style=AnalysisMusicalStyle.POP, - ... is_master=True - ... ) - >>> print(f"Mix A LUFS: {comparison['mix_a'].get('integrated_loudness_lufs')}") - >>> print(f"Mix B LUFS: {comparison['mix_b'].get('integrated_loudness_lufs')}") - >>> print(f"LUFS Difference: {comparison['differences'].get('integrated_loudness_lufs')}") - >>> # Explore other comparison metrics - >>> except Exception as e: - >>> print(f"Error comparing mixes: {e}") + >>> comparison = client.analysis.compare_mixes(url_a, url_b, AnalysisMusicalStyle.POP) + >>> print(comparison["differences"]["integrated_loudness_lufs"]) """ logger.info(f"Comparing mixes: {mix_a_url} and {mix_b_url} with musical style: {musical_style}") request_a = MixAnalysisRequest( @@ -155,7 +124,6 @@ def compare_mixes(self, mix_a_url: str, mix_b_url: str, results_a = self.analyze_mix(request_a) results_b = self.analyze_mix(request_b) - # Extract key metrics for comparison comparison = { "mix_a": self._extract_metrics(results_a), "mix_b": self._extract_metrics(results_b), @@ -165,18 +133,10 @@ def compare_mixes(self, mix_a_url: str, mix_b_url: str, logger.info("Comparison results generated successfully.") return comparison - def _extract_metrics(self, diagnosis: Dict[str, Any]) -> Dict[str, Any]: - """ - Extract key metrics from diagnosis results - - Args: - diagnosis: Mix diagnosis results - - Returns: - Dictionary of extracted metrics - """ + def _extract_metrics(self, diagnosis: AnalysisResult) -> Dict[str, Any]: + """Extract key metrics from an AnalysisResult.""" logger.debug(f"Extracting metrics from diagnosis results: {diagnosis}") - payload = diagnosis.get("payload", {}) + payload = diagnosis.payload or {} # Extract production metrics production_keys = [ @@ -193,17 +153,8 @@ def _extract_metrics(self, diagnosis: Dict[str, Any]) -> Dict[str, Any]: logger.info("Metrics extracted successfully.") return metrics - def _compare_metrics(self, results_a: Dict[str, Any], results_b: Dict[str, Any]) -> Dict[str, Any]: - """ - Compare metrics between two analysis results - - Args: - results_a: First mix diagnosis results - results_b: Second mix diagnosis results - - Returns: - Dictionary of differences - """ + def _compare_metrics(self, results_a: AnalysisResult, results_b: AnalysisResult) -> Dict[str, Any]: + """Compare metrics between two AnalysisResult objects.""" logger.info("Comparing metrics between two analysis results.") metrics_a = self._extract_metrics(results_a) metrics_b = self._extract_metrics(results_b) diff --git a/roex_python/controllers/enhance_controller.py b/roex_python/controllers/enhance_controller.py index 9e0cea5..7abc8df 100644 --- a/roex_python/controllers/enhance_controller.py +++ b/roex_python/controllers/enhance_controller.py @@ -9,7 +9,7 @@ import requests -from roex_python.models.enhance import MixEnhanceRequest, MixEnhanceResponse +from roex_python.models.enhance import EnhancedTrackResult, MixEnhanceRequest, MixEnhanceResponse from roex_python.providers.api_provider import ApiProvider # Initialize logger for this module @@ -172,56 +172,38 @@ def create_mix_enhance(self, request: MixEnhanceRequest) -> MixEnhanceResponse: logger.exception(f"Unexpected error creating mix enhance: {e}") raise - def retrieve_enhanced_track(self, task_id: str, poll_interval: int = 5, timeout: int = 600) -> dict: + def retrieve_enhanced_track(self, task_id: str, poll_interval: int = 5, timeout: int = 600) -> EnhancedTrackResult: """ Retrieve the results of a mix enhancement task (preview or full). - This method polls the RoEx API's task status endpoint using the provided - `task_id`. It waits for the task to complete or until the `timeout` is reached. + Polls the ``/retrieveenhancedtrack`` endpoint until results are ready + or the *timeout* is reached. Args: task_id (str): The unique ID of the enhancement task (obtained from - `create_mix_enhance_preview` or `create_mix_enhance`). + ``create_mix_enhance_preview`` or ``create_mix_enhance``). poll_interval (int): Seconds to wait between status checks. Defaults to 5. timeout (int): Maximum seconds to wait for task completion. Defaults to 600 (10 minutes). Returns: - dict: A dictionary containing the task results. The structure includes: - - `status` (str): Final status ('completed', 'failed', etc.). - - `results` (dict): Contains details upon completion: - - `preview_audio_file_location` (Optional[str]): URL for preview audio. - - `enhanced_audio_file_location` (Optional[str]): URL for full enhanced audio. - - `stems` (Optional[dict]): If `stem_processing` was True in the request, - this dictionary contains URLs for individual stems (e.g., `vocals`, `bass`, `drums`, `other`). - - `processing_time` (float): Time taken for processing. - - `error_message` (Optional[str]): Error details if the task failed. - - Other task metadata. + EnhancedTrackResult: A typed result containing: + - ``download_url_preview_revived`` (Optional[str]): Signed URL for the MP3 preview. + - ``download_url_revived`` (Optional[str]): Signed URL for the full WAV file. + - ``stems`` (Optional[Dict[str, str]]): URLs keyed by stem name + (``vocal``, ``bass``, ``drums``, ``other``) when stem processing was requested. + - ``preview_start_time`` (Optional[float]): Offset in seconds where + the preview clip begins in the original track. Raises: - TimeoutError: If the task does not complete within the specified `timeout`. - requests.exceptions.RequestException: Network or API endpoint errors during polling. - Exception: If the API returns an error status code (4xx, 5xx) during polling - or if the task itself failed. + Exception: If the task does not complete within the specified *timeout*, + or the API returns an error during polling. Example: - >>> # Assume 'client' is an initialized RoExClient and 'task_id' was obtained previously - >>> try: - >>> results = client.enhance.retrieve_enhanced_track(task_id) - >>> if results.get('status') == 'completed': - >>> enhanced_url = results.get('results', {}).get('enhanced_audio_file_location') - >>> print(f"Enhancement complete! Audio URL: {enhanced_url}") - >>> stems = results.get('results', {}).get('stems') - >>> if stems: - >>> print(f"Stems generated:") - >>> for stem_name, stem_url in stems.items(): - >>> print(f" - {stem_name}: {stem_url}") - >>> else: - >>> error_msg = results.get('results', {}).get('error_message', 'Unknown error') - >>> print(f"Task failed or timed out. Status: {results.get('status')}. Error: {error_msg}") - >>> except TimeoutError: - >>> print("Task timed out.") - >>> except Exception as e: - >>> print(f"An error occurred while retrieving results: {e}") + >>> result = client.enhance.retrieve_enhanced_track(task_id) + >>> print(result.download_url_preview_revived) + >>> if result.stems: + ... for name, url in result.stems.items(): + ... print(f"{name}: {url}") """ logger.info(f"Attempting to retrieve results for task ID: {task_id}") payload = { @@ -230,32 +212,26 @@ def retrieve_enhanced_track(self, task_id: str, poll_interval: int = 5, timeout: } } - # Poll for results for attempt in range(timeout // poll_interval): try: logger.debug(f"Polling attempt {attempt + 1}/{timeout // poll_interval}...") response = self.api_provider.post("/retrieveenhancedtrack", payload) if not response.get("error", False): - results = response.get("revived_track_tasks_results", {}) + results = response.get("revivedTrackTaskResults", {}) if results: logger.info(f"Enhanced track retrieved successfully for task ID: {task_id}") - return results - - # Some API versions might return a different format - for key in response: - if isinstance(response[key], dict) and ( - "download_url_revived" in response[key] or - "download_url_preview_revived" in response[key] - ): - logger.info(f"Enhanced track retrieved successfully for task ID: {task_id}") - return response[key] + return EnhancedTrackResult( + download_url_preview_revived=results.get("download_url_preview_revived"), + download_url_revived=results.get("download_url_revived"), + stems=results.get("stems"), + preview_start_time=results.get("preview_start_time"), + ) except requests.HTTPError as e: logger.error(f"Error during polling: {str(e)}") except Exception as e: logger.exception(f"Unexpected error during polling for task ID: {task_id}: {e}") - # Wait before next attempt time.sleep(poll_interval) logger.error(f"Enhanced track was not available after polling for task ID: {task_id}.") diff --git a/roex_python/controllers/mastering_controller.py b/roex_python/controllers/mastering_controller.py index 178fcea..de044a7 100644 --- a/roex_python/controllers/mastering_controller.py +++ b/roex_python/controllers/mastering_controller.py @@ -10,9 +10,11 @@ import requests from roex_python.models.mastering import ( + AlbumMasteringRequest, + FinalMasterResult, MasteringRequest, MasteringTaskResponse, - AlbumMasteringRequest + PreviewMasterResult ) from roex_python.providers.api_provider import ApiProvider @@ -107,45 +109,30 @@ def create_mastering_preview(self, request: MasteringRequest) -> MasteringTaskRe raise def retrieve_preview_master(self, task_id: str, max_attempts: int = 30, - poll_interval: int = 5) -> Dict[str, Any]: + poll_interval: int = 5) -> PreviewMasterResult: """ Retrieve the results of a mastering preview task, polling until complete. - Checks the status of a mastering task initiated by `create_mastering_preview`. - Polls the API periodically until the task completes or the maximum number - of attempts is reached. + Polls the ``/retrievepreviewmaster`` endpoint until + ``previewMasterTaskResults`` is present or *max_attempts* is exhausted. Args: - task_id (str): The `mastering_task_id` obtained from the - `create_mastering_preview` response. - max_attempts (int, optional): Maximum number of polling attempts before - timing out. Defaults to 30. - poll_interval (int, optional): Seconds to wait between polling attempts. - Defaults to 5. + task_id (str): The ``mastering_task_id`` from ``create_mastering_preview``. + max_attempts (int): Maximum polling iterations. Defaults to 30. + poll_interval (int): Seconds between polls. Defaults to 5. Returns: - Dict[str, Any]: A dictionary containing the results of the preview master. - Key fields typically include: - - 'status': Final status (e.g., 'MASTERING_TASK_PREVIEW_COMPLETED'). - - 'download_url_mastered_preview': URL to download the preview audio. - Check the official RoEx API documentation for the exact structure. + PreviewMasterResult: A typed result containing: + - ``download_url_mastered_preview`` (Optional[str]): Signed URL for the mastered preview. + - ``preview_start_time`` (Optional[float]): Offset in seconds where the + preview clip starts in the original track. Raises: - requests.exceptions.RequestException: If an API request fails during polling. - Exception: If the task does not complete successfully within `max_attempts`, - if the API returns an error during polling, or for other - API errors (4xx/5xx). + Exception: If the task does not complete within *max_attempts* polls. Example: - >>> # Assume 'client' is an initialized RoExClient - >>> # Assume 'task_id' was obtained from create_mastering_preview - >>> try: - >>> master_results = client.mastering.retrieve_preview_master(task_id) - >>> print(f"Mastering Preview Status: {master_results.get('status')}") - >>> print(f"Preview Download URL: {master_results.get('download_url_mastered_preview')}") - >>> # Further process the results (e.g., download the file) - >>> except Exception as e: - >>> print(f"Error retrieving mastering preview: {e}") + >>> result = client.mastering.retrieve_preview_master(task_id) + >>> print(result.download_url_mastered_preview) """ logger.info(f"Retrieving preview master for task ID: {task_id}") payload = { @@ -154,85 +141,65 @@ def retrieve_preview_master(self, task_id: str, max_attempts: int = 30, } } - # Try initial request + def _parse(raw: Dict[str, Any]) -> PreviewMasterResult: + return PreviewMasterResult( + download_url_mastered_preview=raw.get("download_url_mastered_preview"), + preview_start_time=raw.get("preview_start_time"), + ) + try: response = self.api_provider.post("/retrievepreviewmaster", payload) if "previewMasterTaskResults" in response: logger.info(f"Preview master ready for task ID: {task_id}") - return response["previewMasterTaskResults"] + return _parse(response["previewMasterTaskResults"]) except requests.HTTPError: - # Initial request failed, let's try polling logger.warning(f"Initial request failed for task ID: {task_id}. Starting polling...") pass - # Poll for results for attempt in range(max_attempts): try: logger.debug(f"Polling attempt {attempt + 1}/{max_attempts} for task ID: {task_id}") response = self.api_provider.post("/retrievepreviewmaster", payload) - # If we get a 200 response with results if "previewMasterTaskResults" in response: logger.info(f"Preview master ready for task ID: {task_id}") - return response["previewMasterTaskResults"] + return _parse(response["previewMasterTaskResults"]) - # Check for specific status codes status_code = response.get("status", 0) if status_code == 202: logger.info(f"Task still processing for task ID: {task_id}...") - elif status_code == 200: - # If we get a 200 but no results, try to parse the response differently - if isinstance(response, dict): - for key, value in response.items(): - if isinstance(value, dict) and "download_url_mastered_preview" in value: - logger.info(f"Preview master ready for task ID: {task_id}") - return value except requests.HTTPError as e: logger.error(f"Error during polling for task ID: {task_id}: {e}") except Exception as e: logger.exception(f"Unexpected error during polling for task ID: {task_id}: {e}") - # Wait before next attempt time.sleep(poll_interval) logger.error(f"Timeout waiting for preview master for task ID: {task_id} after {max_attempts} attempts.") raise Exception(f"Preview master task {task_id} did not complete after polling for {max_attempts * poll_interval} seconds.") - def retrieve_final_master(self, task_id: str) -> Dict[str, Any]: + def retrieve_final_master(self, task_id: str) -> FinalMasterResult: """ Retrieve the final mastered audio file. - This method fetches the final output of a completed mastering task. - It's typically called after `create_mastering_preview` and potentially - `retrieve_preview_master` confirm the task is done, although polling - is not built into this specific retrieval method. + Fetches the result from ``/retrievefinalmaster``. Call after + ``create_mastering_preview`` and ``retrieve_preview_master`` have + confirmed the task is done. Args: - task_id (str): The `mastering_task_id` obtained from the - `create_mastering_preview` response. + task_id (str): The ``mastering_task_id`` from ``create_mastering_preview``. Returns: - Dict[str, Any]: A dictionary containing the results of the final master. - Key fields typically include: - - 'status': Final status (e.g., 'MASTERING_TASK_FINAL_COMPLETED'). - - 'download_url_mastered_final': URL to download the final audio. - Check the official RoEx API documentation for the exact structure. + FinalMasterResult: A typed result containing: + - ``download_url_mastered`` (Optional[str]): Signed URL for the final + mastered audio file. Raises: - requests.exceptions.RequestException: If the API request fails. - Exception: If the API returns an error response (e.g., 4xx, 5xx status codes), - indicating issues like task not found, task not complete, or server errors. + Exception: If the API returns an error response. Example: - >>> # Assume 'client' is an initialized RoExClient - >>> # Assume 'task_id' was obtained from create_mastering_preview and preview is complete - >>> try: - >>> final_master_results = client.mastering.retrieve_final_master(task_id) - >>> print(f"Final Master Status: {final_master_results.get('status')}") - >>> print(f"Final Download URL: {final_master_results.get('download_url_mastered_final')}") - >>> # Further process the results - >>> except Exception as e: - >>> print(f"Error retrieving final master: {e}") + >>> result = client.mastering.retrieve_final_master(task_id) + >>> print(result.download_url_mastered) """ logger.info(f"Retrieving final master for task ID: {task_id}") payload = { @@ -244,23 +211,20 @@ def retrieve_final_master(self, task_id: str) -> Dict[str, Any]: try: response = self.api_provider.post("/retrievefinalmaster", payload) - # Handle different response formats if "finalMasterTaskResults" in response: - # If it's a structured response + raw = response["finalMasterTaskResults"] logger.info(f"Final master ready for task ID: {task_id}") - return response["finalMasterTaskResults"] + return FinalMasterResult( + download_url_mastered=raw.get("download_url_mastered"), + ) elif isinstance(response, dict) and "download_url_mastered" in response: - # If the URL is directly in the response - logger.info(f"Final master ready for task ID: {task_id}") - return response["download_url_mastered"] - elif isinstance(response, str) and (response.startswith("http://") or response.startswith("https://")): - # If the response is just the URL as a string logger.info(f"Final master ready for task ID: {task_id}") - return response + return FinalMasterResult( + download_url_mastered=response.get("download_url_mastered"), + ) - # Default fallback - logger.warning(f"Unknown response format for task ID: {task_id}. Returning raw response.") - return response + logger.warning(f"Unknown response format for task ID: {task_id}. Returning empty result.") + return FinalMasterResult() except requests.HTTPError as e: logger.exception(f"HTTP error retrieving final master for task ID: {task_id}: {e}") raise Exception(f"Failed to retrieve final master: {e}") @@ -303,7 +267,7 @@ def process_album(self, album_request: AlbumMasteringRequest, output_dir: str = # Get final master try: - final_url = self.retrieve_final_master(task_id)['download_url_mastered'] + final_url = self.retrieve_final_master(task_id).download_url_mastered results[idx] = final_url # Download the file diff --git a/roex_python/controllers/mix_controller.py b/roex_python/controllers/mix_controller.py index a5b9037..3aab530 100644 --- a/roex_python/controllers/mix_controller.py +++ b/roex_python/controllers/mix_controller.py @@ -9,10 +9,12 @@ import requests from roex_python.models.mixing import ( - MultitrackMixRequest, - MultitrackTaskResponse, FinalMixRequest, FinalMixRequestAdvanced, + FinalMixResult, + MultitrackMixRequest, + MultitrackTaskResponse, + PreviewMixResult, TrackData, TrackGainData, TrackEffectsData @@ -106,53 +108,32 @@ def create_mix_preview(self, request: MultitrackMixRequest) -> MultitrackTaskRes raise def retrieve_preview_mix(self, task_id: str, retrieve_fx_settings: bool = False, - max_attempts: int = 30, poll_interval: int = 5) -> Dict[str, Any]: + max_attempts: int = 30, poll_interval: int = 5) -> PreviewMixResult: """ Retrieve the results of a multitrack mix preview task, polling until complete. - This method checks the status of a mix preview task initiated by - `create_mix_preview`. It polls the API endpoint periodically until the task - status indicates completion (or failure) or the maximum number of attempts - is reached. + Polls the ``/retrievepreviewmix`` endpoint until the task reaches + ``MIX_TASK_PREVIEW_COMPLETED`` or *max_attempts* is exhausted. Args: - task_id (str): The `multitrack_task_id` obtained from the - `create_mix_preview` response. - retrieve_fx_settings (bool, optional): Whether to retrieve detailed FX settings - applied during the mix. Note: This might incur additional charges - depending on the API plan. Defaults to False. - max_attempts (int, optional): The maximum number of times to poll the API - before timing out. Defaults to 30. - poll_interval (int, optional): The number of seconds to wait between - polling attempts. Defaults to 5. + task_id (str): The ``multitrack_task_id`` from ``create_mix_preview``. + retrieve_fx_settings (bool): Request detailed FX settings. Defaults to False. + max_attempts (int): Maximum polling iterations. Defaults to 30. + poll_interval (int): Seconds between polls. Defaults to 5. Returns: - Dict[str, Any]: A dictionary containing the results of the preview mix task. - The structure typically includes: - - 'status': The final status of the task (e.g., 'MIX_TASK_PREVIEW_COMPLETED'). - - 'previewMixDownloadUrl': URL to download the preview mix audio file. - - 'stemsDownloadUrl': URL to download stems (if requested). - - 'settings': Applied settings (gain, pan, etc.). - - 'fxSettings': Detailed FX settings (if requested and available). - Check the official RoEx API documentation for the exact structure. + PreviewMixResult: A typed result containing: + - ``download_url_preview_mixed`` (Optional[str]): Signed URL for the preview mix. + - ``stems`` (Optional[Dict[str, str]]): Per-stem download URLs (if requested). + - ``mix_output_settings`` (Optional[Dict]): Gain, pan, and other applied settings. + - ``status`` (Optional[str]): Task status string. Raises: - requests.exceptions.RequestException: If an API request fails during polling. - Exception: If the task does not complete successfully within the - `max_attempts` or if the API returns an error during polling. - Also raised for other API errors (4xx/5xx). + Exception: If the task does not complete within *max_attempts* polls. Example: - >>> # Assume 'client' is an initialized RoExClient - >>> # Assume 'task_id' was obtained from create_mix_preview - >>> try: - >>> preview_results = client.mix.retrieve_preview_mix(task_id) - >>> print(f"Preview Status: {preview_results.get('status')}") - >>> print(f"Preview Download URL: {preview_results.get('previewMixDownloadUrl')}") - >>> # Further process the results (e.g., download the file) - >>> except Exception as e: - >>> print(f"Error retrieving mix preview: {e}") - + >>> result = client.mix.retrieve_preview_mix(task_id) + >>> print(result.download_url_preview_mixed) """ logger.info(f"Retrieving preview mix for task ID: {task_id}") payload = { @@ -162,133 +143,74 @@ def retrieve_preview_mix(self, task_id: str, retrieve_fx_settings: bool = False, } } - # Initial request + def _parse_preview_result(raw: Dict[str, Any]) -> PreviewMixResult: + return PreviewMixResult( + download_url_preview_mixed=raw.get("download_url_preview_mixed"), + stems=raw.get("stems"), + mix_output_settings=raw.get("mix_output_settings"), + status=raw.get("status"), + ) + try: response = self.api_provider.post("/retrievepreviewmix", payload) - # Check if the mix is already complete if "previewMixTaskResults" in response and response.get("status") == "MIX_TASK_PREVIEW_COMPLETED": logger.info(f"Preview mix for task {task_id} is ready.") - return response["previewMixTaskResults"] + return _parse_preview_result(response["previewMixTaskResults"]) - # If status code is 200 but no results, it might still be processing if "status" in response and response.get("status") != "MIX_TASK_PREVIEW_COMPLETED": logger.info(f"Mix preview is pending. Starting polling...") else: - # If the response doesn't indicate it's processing, return it as is - return response + return _parse_preview_result(response) except requests.HTTPError: - # Initial request failed, let's try polling logger.error("Initial request failed. Starting polling...") pass - # Poll for results for attempt in range(max_attempts): try: logger.debug(f"Polling attempt {attempt + 1}/{max_attempts}...") response = self.api_provider.post("/retrievepreviewmix", payload) - # Check if the mix is complete if "previewMixTaskResults" in response: results = response["previewMixTaskResults"] status = results.get("status", "") if status == "MIX_TASK_PREVIEW_COMPLETED": logger.info(f"Preview mix for task {task_id} is ready.") - return results + return _parse_preview_result(results) - # Check if it's still processing if "status" in response: logger.info(f"Current status: {response.get('status')}") except requests.HTTPError as e: logger.error(f"Error during polling: {str(e)}") - # Wait before next attempt time.sleep(poll_interval) logger.error(f"Polling timed out for preview mix task {task_id} after {max_attempts} attempts.") raise Exception(f"Preview mix task {task_id} did not complete after polling for {max_attempts * poll_interval} seconds.") - def retrieve_final_mix_advanced(self, request: FinalMixRequestAdvanced) -> Dict[str, Any]: + def retrieve_final_mix_advanced(self, request: FinalMixRequestAdvanced) -> FinalMixResult: """ Retrieve the final multitrack mix with advanced audio effects (EQ, compression, panning). - This method generates the final mix output with comprehensive audio processing - capabilities including parametric EQ, dynamic compression, and stereo panning. - It typically follows a `create_mix_preview` and `retrieve_preview_mix` sequence. + Posts to ``/retrievefinalmix`` with per-track EQ, compression, and panning + settings. Typically follows ``create_mix_preview`` / ``retrieve_preview_mix``. Args: - request (FinalMixRequestAdvanced): An object containing: - - multitrack_task_id (str): The task ID from the original preview. - - track_data (List[TrackEffectsData]): A list of advanced track settings - including gain, EQ, compression, and panning for each track. - - return_stems (bool, optional): Whether to return individual track stems - along with the final mix. - - create_master (bool, optional): Whether to create a mastered version of - the final mix. - - desired_loudness (DesiredLoudness, optional): Target loudness level - (LOW, MEDIUM, HIGH). Only applicable when not creating stems. - - sample_rate (str, optional): Sample rate for output ("44100" or "48000"). - - webhook_url (str, optional): URL for completion notifications. + request (FinalMixRequestAdvanced): Advanced final mix parameters including + per-track EQ, compression, panning, gain, and global options. Returns: - Dict[str, Any]: A dictionary containing the results of the final mix task. - The structure typically includes: - - 'status': The status of the final mix generation. - - 'download_url_mixed': URL to download the final mix audio file. - - 'stems': URLs to download stems (if requested). - - 'mix_output_settings': Applied settings. - Check the official RoEx API documentation for the exact structure. + FinalMixResult: A typed result containing: + - ``download_url_mixed`` (Optional[str]): Signed URL for the final mix. + - ``stems`` (Optional[Dict[str, str]]): Per-stem download URLs (if requested). + - ``mix_output_settings`` (Optional[Dict]): Applied settings. Raises: - requests.exceptions.RequestException: If the API request fails. - Exception: If the API returns an error response (e.g., 4xx, 5xx status codes). - ValueError: If audio effects parameters are out of valid ranges. + Exception: If the API returns an error response. Example: - >>> from roex_python.models import ( - ... FinalMixRequestAdvanced, TrackEffectsData, - ... EQSettings, EQBandSettings, CompressionSettings, PanningSettings - ... ) - >>> # Assume 'client' is an initialized RoExClient - >>> # Assume 'task_id' was obtained from create_mix_preview - >>> - >>> # Define advanced track effects - >>> bass_track = TrackEffectsData( - ... track_url="https://example.com/bass.wav", - ... gain_db=2.0, - ... eq_settings=EQSettings.preset_bass_boost(), - ... compression_settings=CompressionSettings.preset_bass(), - ... panning_settings=PanningSettings.center() - ... ) - >>> - >>> vocal_track = TrackEffectsData( - ... track_url="https://example.com/vocals.wav", - ... gain_db=-0.5, - ... eq_settings=EQSettings.preset_vocal_clarity(), - ... compression_settings=CompressionSettings.preset_vocal() - ... ) - >>> - >>> final_mix_request = FinalMixRequestAdvanced( - ... multitrack_task_id=task_id, - ... track_data=[bass_track, vocal_track], - ... return_stems=True, - ... create_master=False - ... ) - >>> - >>> try: - >>> final_mix_results = client.mix.retrieve_final_mix_advanced(final_mix_request) - >>> print(f"Final Mix Status: {final_mix_results.get('status')}") - >>> print(f"Final Mix URL: {final_mix_results.get('download_url_mixed')}") - >>> except Exception as e: - >>> print(f"Error retrieving final mix: {e}") - + >>> result = client.mix.retrieve_final_mix_advanced(request) + >>> print(result.download_url_mixed) """ logger.info(f"Retrieving advanced final mix for task ID: {request.multitrack_task_id}") logger.debug(f"Advanced final mix request data: {request}") @@ -297,77 +219,43 @@ def retrieve_final_mix_advanced(self, request: FinalMixRequestAdvanced) -> Dict[ try: response = self.api_provider.post("/retrievefinalmix", payload) logger.info("Advanced final mix retrieved successfully.") - if "applyAudioEffectsResults" in response: - return response["applyAudioEffectsResults"] - return response + raw = response.get("applyAudioEffectsResults", response) + return FinalMixResult( + download_url_mixed=raw.get("download_url_mixed"), + stems=raw.get("stems"), + mix_output_settings=raw.get("mix_output_settings"), + ) except requests.HTTPError as e: - # Log specific HTTP errors error_detail = f"{e.response.status_code} - {e.response.text}" if hasattr(e, 'response') and e.response else str(e) logger.error(f"HTTP error retrieving advanced final mix: {error_detail}") raise Exception(f"Failed to retrieve advanced final mix: {error_detail}") except Exception as e: - # Catch other potential exceptions logger.exception(f"Unexpected error retrieving advanced final mix: {e}") raise - def retrieve_final_mix(self, request: FinalMixRequest) -> Dict[str, Any]: + def retrieve_final_mix(self, request: FinalMixRequest) -> FinalMixResult: """ Retrieve the final multitrack mix, potentially with gain adjustments. - This method is used to generate the final mix output. It typically follows - a `create_mix_preview` and `retrieve_preview_mix` sequence, allowing users - to apply gain adjustments based on the preview before generating the final - audio file(s). + Posts to ``/retrievefinalmix`` with optional per-track gain changes applied + on top of the original preview. Args: - request (FinalMixRequest): An object containing: - - multitrack_task_id (str): The task ID from the original preview. - - gain_adjustments (List[TrackGainData], optional): A list of gain - adjustments to apply to specific tracks before final mixing. - - return_stems (bool, optional): Whether to return individual track stems - along with the final mix. Defaults according to original preview request if omitted. + request (FinalMixRequest): Final mix parameters including the preview + task ID, optional gain adjustments, and stem / sample-rate options. Returns: - Dict[str, Any]: A dictionary containing the results of the final mix task. - The structure typically includes: - - 'status': The status of the final mix generation (e.g., 'FINAL_MIX_COMPLETE'). - - 'finalMixDownloadUrl': URL to download the final mix audio file. - - 'stemsDownloadUrl': URL to download stems (if requested). - Check the official RoEx API documentation for the exact structure. + FinalMixResult: A typed result containing: + - ``download_url_mixed`` (Optional[str]): Signed URL for the final mix. + - ``stems`` (Optional[Dict[str, str]]): Per-stem download URLs (if requested). + - ``mix_output_settings`` (Optional[Dict]): Applied settings. Raises: - requests.exceptions.RequestException: If the API request fails. - Exception: If the API returns an error response (e.g., 4xx, 5xx status codes) - indicating issues like invalid input, task not found, or server errors. + Exception: If the API returns an error response. Example: - >>> from roex_python.models import FinalMixRequest, TrackGainData, InstrumentGroup - >>> # Assume 'client' is an initialized RoExClient - >>> # Assume 'task_id' was obtained from create_mix_preview - >>> - >>> # Optional: Define gain adjustments based on preview analysis - >>> adjustments = [ - ... TrackGainData(instrument_group=InstrumentGroup.BASS_GROUP, gain_db=1.5), - ... TrackGainData(instrument_group=InstrumentGroup.VOCAL_GROUP, gain_db=-0.5) - ... ] - >>> - >>> final_mix_request = FinalMixRequest( - ... multitrack_task_id=task_id, - ... gain_adjustments=adjustments, # Can be None or empty list if no adjustments - ... return_stems=True # Optional: Override stem generation - ... ) - >>> - >>> try: - >>> final_mix_results = client.mix.retrieve_final_mix(final_mix_request) - >>> print(f"Final Mix Status: {final_mix_results.get('status')}") - >>> print(f"Final Mix URL: {final_mix_results.get('finalMixDownloadUrl')}") - >>> # Further process the results - >>> except Exception as e: - >>> print(f"Error retrieving final mix: {e}") - + >>> result = client.mix.retrieve_final_mix(request) + >>> print(result.download_url_mixed) """ logger.info(f"Retrieving final mix for task ID: {request.multitrack_task_id}") logger.debug(f"Final mix request data: {request}") @@ -376,16 +264,17 @@ def retrieve_final_mix(self, request: FinalMixRequest) -> Dict[str, Any]: try: response = self.api_provider.post("/retrievefinalmix", payload) logger.info("Final mix retrieved successfully.") - if "applyAudioEffectsResults" in response: - return response["applyAudioEffectsResults"] - return response + raw = response.get("applyAudioEffectsResults", response) + return FinalMixResult( + download_url_mixed=raw.get("download_url_mixed"), + stems=raw.get("stems"), + mix_output_settings=raw.get("mix_output_settings"), + ) except requests.HTTPError as e: - # Log specific HTTP errors error_detail = f"{e.response.status_code} - {e.response.text}" if hasattr(e, 'response') and e.response else str(e) logger.error(f"HTTP error retrieving final mix: {error_detail}") raise Exception(f"Failed to retrieve final mix: {error_detail}") except Exception as e: - # Catch other potential exceptions logger.exception(f"Unexpected error retrieving final mix: {e}") raise diff --git a/roex_python/models/__init__.py b/roex_python/models/__init__.py index 928afd8..f071f26 100644 --- a/roex_python/models/__init__.py +++ b/roex_python/models/__init__.py @@ -17,8 +17,10 @@ from roex_python.models.mixing import ( FinalMixRequest, FinalMixRequestAdvanced, + FinalMixResult, MultitrackMixRequest, MultitrackTaskResponse, + PreviewMixResult, TrackData, TrackGainData, TrackEffectsData, @@ -31,18 +33,22 @@ # Import mastering models from roex_python.models.mastering import ( AlbumMasteringRequest, + FinalMasterResult, MasteringRequest, - MasteringTaskResponse + MasteringTaskResponse, + PreviewMasterResult ) # Import analysis models from roex_python.models.analysis import ( AnalysisMusicalStyle, + AnalysisResult, MixAnalysisRequest ) # Import enhance models from roex_python.models.enhance import ( + EnhancedTrackResult, EnhanceMusicalStyle, MixEnhanceRequest, MixEnhanceResponse @@ -75,8 +81,10 @@ # Mixing models "FinalMixRequest", "FinalMixRequestAdvanced", + "FinalMixResult", "MultitrackMixRequest", "MultitrackTaskResponse", + "PreviewMixResult", "TrackData", "TrackGainData", "TrackEffectsData", @@ -87,14 +95,18 @@ # Mastering models "AlbumMasteringRequest", + "FinalMasterResult", "MasteringRequest", "MasteringTaskResponse", + "PreviewMasterResult", # Analysis models "AnalysisMusicalStyle", + "AnalysisResult", "MixAnalysisRequest", # Enhance models + "EnhancedTrackResult", "EnhanceMusicalStyle", "MixEnhanceRequest", "MixEnhanceResponse", diff --git a/roex_python/models/analysis.py b/roex_python/models/analysis.py index c48b30f..5bd6f83 100644 --- a/roex_python/models/analysis.py +++ b/roex_python/models/analysis.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from enum import Enum +from typing import Any, Dict, Optional class AnalysisMusicalStyle(Enum): @@ -61,4 +62,22 @@ class MixAnalysisRequest: musical_style: AnalysisMusicalStyle """AnalysisMusicalStyle: The musical style of the track, used as a reference for the analysis (e.g., ROCK, POP, ELECTRONIC).""" is_master: bool - """bool: Indicates whether the provided audio file is a mastered track (True) or a mix (False).""" \ No newline at end of file + """bool: Indicates whether the provided audio file is a mastered track (True) or a mix (False).""" + + +@dataclass +class AnalysisResult: + """Result of a mix/master analysis from the ``/mixanalysis`` endpoint. + + The ``payload`` dict contains the detailed diagnosis metrics such as + ``integrated_loudness_lufs``, ``peak_loudness_dbfs``, ``tonal_profile``, + ``clipping``, ``stereo_field``, etc. + """ + payload: Optional[Dict[str, Any]] = None + """Optional[Dict[str, Any]]: The diagnosis metrics dictionary.""" + error: bool = False + """bool: Whether the analysis encountered an error.""" + info: str = "" + """str: Additional information from the API.""" + completion_time: str = "" + """str: Timestamp when the analysis completed.""" \ No newline at end of file diff --git a/roex_python/models/enhance.py b/roex_python/models/enhance.py index f91c082..43c0f12 100644 --- a/roex_python/models/enhance.py +++ b/roex_python/models/enhance.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional +from typing import Dict, Optional from roex_python.models.common import LoudnessPreference @@ -57,4 +57,21 @@ class MixEnhanceResponse: """Response model for mix enhancement task creation""" mixrevive_task_id: str error: bool - message: str \ No newline at end of file + message: str + + +@dataclass +class EnhancedTrackResult: + """Result of a completed mix enhancement (preview or full). + + Returned by ``EnhanceController.retrieve_enhanced_track`` after polling + the ``/retrieveenhancedtrack`` endpoint. + """ + download_url_preview_revived: Optional[str] = None + """Optional[str]: Signed URL for the MP3 preview of the enhanced track.""" + download_url_revived: Optional[str] = None + """Optional[str]: Signed URL for the full WAV enhanced track.""" + stems: Optional[Dict[str, str]] = None + """Optional[Dict[str, str]]: URLs keyed by stem name (``vocal``, ``bass``, ``drums``, ``other``).""" + preview_start_time: Optional[float] = None + """Optional[float]: Offset in seconds where the preview clip starts in the original track.""" \ No newline at end of file diff --git a/roex_python/models/mastering.py b/roex_python/models/mastering.py index a65281a..a7af7a5 100644 --- a/roex_python/models/mastering.py +++ b/roex_python/models/mastering.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import List, Optional + + from roex_python.models.common import DesiredLoudness, MusicalStyle @@ -50,4 +52,20 @@ class AlbumMasteringRequest: This model might be reserved for future use or internal API structures. """ tracks: List[MasteringRequest] - """List[MasteringRequest]: A list of individual MasteringRequest objects, one for each track to be mastered.""" \ No newline at end of file + """List[MasteringRequest]: A list of individual MasteringRequest objects, one for each track to be mastered.""" + + +@dataclass +class PreviewMasterResult: + """Result of a completed mastering preview from the ``/retrievepreviewmaster`` endpoint.""" + download_url_mastered_preview: Optional[str] = None + """Optional[str]: Signed URL for the mastered preview audio file.""" + preview_start_time: Optional[float] = None + """Optional[float]: Offset in seconds where the preview clip starts in the original track.""" + + +@dataclass +class FinalMasterResult: + """Result of a completed final master from the ``/retrievefinalmaster`` endpoint.""" + download_url_mastered: Optional[str] = None + """Optional[str]: Signed URL for the final mastered audio file.""" \ No newline at end of file diff --git a/roex_python/models/mixing.py b/roex_python/models/mixing.py index fa0f45d..214b174 100644 --- a/roex_python/models/mixing.py +++ b/roex_python/models/mixing.py @@ -3,7 +3,7 @@ """ from dataclasses import dataclass, field -from typing import List, Optional +from typing import Any, Dict, List, Optional from roex_python.models.common import ( DesiredLoudness, @@ -407,4 +407,28 @@ class FinalMixRequestAdvanced: sample_rate: str = "44100" """str: The desired sample rate for the output. "44100" for 44.1kHz (16-bit) or "48000" for 48kHz (24-bit). Defaults to "44100".""" webhook_url: Optional[str] = None - """Optional[str]: A URL to which a notification will be sent upon task completion.""" \ No newline at end of file + """Optional[str]: A URL to which a notification will be sent upon task completion.""" + + +@dataclass +class PreviewMixResult: + """Result of a completed mix preview from the ``/retrievepreviewmix`` endpoint.""" + download_url_preview_mixed: Optional[str] = None + """Optional[str]: Signed URL for the preview mix audio file.""" + stems: Optional[Dict[str, str]] = None + """Optional[Dict[str, str]]: URLs keyed by stem name, if stems were requested.""" + mix_output_settings: Optional[Dict[str, Any]] = None + """Optional[Dict[str, Any]]: The mixing settings applied (gain, pan, etc.).""" + status: Optional[str] = None + """Optional[str]: Task status (e.g. ``MIX_TASK_PREVIEW_COMPLETED``).""" + + +@dataclass +class FinalMixResult: + """Result of a completed final mix from the ``/retrievefinalmix`` endpoint.""" + download_url_mixed: Optional[str] = None + """Optional[str]: Signed URL for the final mix audio file.""" + stems: Optional[Dict[str, str]] = None + """Optional[Dict[str, str]]: URLs keyed by stem name, if stems were requested.""" + mix_output_settings: Optional[Dict[str, Any]] = None + """Optional[Dict[str, Any]]: The mixing settings applied.""" \ No newline at end of file diff --git a/setup.py b/setup.py index e938224..916dd22 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="roex_python", - version="1.3.1", + version="1.3.2", author="RoEx Audio", author_email="support@roexaudio.com", description="Pip package for the RoEx Tonn API", diff --git a/tests/unit/test_controllers/test_analysis_controller.py b/tests/unit/test_controllers/test_analysis_controller.py index 2b1217b..1209781 100644 --- a/tests/unit/test_controllers/test_analysis_controller.py +++ b/tests/unit/test_controllers/test_analysis_controller.py @@ -6,7 +6,7 @@ from unittest.mock import Mock import requests from roex_python.controllers.analysis_controller import AnalysisController -from roex_python.models import MixAnalysisRequest, AnalysisMusicalStyle +from roex_python.models import MixAnalysisRequest, AnalysisMusicalStyle, AnalysisResult @pytest.mark.unit @@ -48,13 +48,12 @@ def test_successful_analysis(self, mock_api_provider): is_master=False ) - # Execute result = controller.analyze_mix(request) - # Assert - assert "payload" in result - assert result["payload"]["integrated_loudness_lufs"] == -14.5 - assert result["payload"]["clipping"] == "NO" + assert isinstance(result, AnalysisResult) + assert result.payload["integrated_loudness_lufs"] == -14.5 + assert result.payload["clipping"] == "NO" + assert result.error is False # Verify correct payload was sent call_args = mock_api_provider.post.call_args @@ -93,7 +92,6 @@ def test_master_analysis(self, mock_api_provider): def test_response_without_expected_format(self, mock_api_provider): """Test handling of response without expected format""" - # Setup mock_api_provider.post.return_value = { "some_other_key": "value" } @@ -106,11 +104,10 @@ def test_response_without_expected_format(self, mock_api_provider): is_master=False ) - # Execute result = controller.analyze_mix(request) - # Assert - should return raw response - assert result == {"some_other_key": "value"} + assert isinstance(result, AnalysisResult) + assert result.payload is None def test_http_error_handling(self, mock_api_provider): """Test error handling when API returns HTTP error""" @@ -236,8 +233,8 @@ def test_extract_all_metrics(self, mock_api_provider): """Test extraction of all available metrics""" controller = AnalysisController(mock_api_provider) - diagnosis = { - "payload": { + diagnosis = AnalysisResult( + payload={ "bit_depth": 24, "clipping": "NO", "integrated_loudness_lufs": -14.5, @@ -250,12 +247,10 @@ def test_extract_all_metrics(self, mock_api_provider): "high_frequency": "BRIGHT" } } - } + ) - # Execute metrics = controller._extract_metrics(diagnosis) - # Assert assert metrics["bit_depth"] == 24 assert metrics["clipping"] == "NO" assert metrics["integrated_loudness_lufs"] == -14.5 @@ -267,17 +262,14 @@ def test_extract_with_missing_fields(self, mock_api_provider): """Test extraction when some fields are missing""" controller = AnalysisController(mock_api_provider) - diagnosis = { - "payload": { + diagnosis = AnalysisResult( + payload={ "integrated_loudness_lufs": -14.5, - # Missing many fields } - } + ) - # Execute metrics = controller._extract_metrics(diagnosis) - # Assert - missing fields should be "N/A" assert metrics["integrated_loudness_lufs"] == -14.5 assert metrics["clipping"] == "N/A" assert metrics["bit_depth"] == "N/A" @@ -286,12 +278,10 @@ def test_extract_with_empty_payload(self, mock_api_provider): """Test extraction with empty payload""" controller = AnalysisController(mock_api_provider) - diagnosis = {} + diagnosis = AnalysisResult() - # Execute metrics = controller._extract_metrics(diagnosis) - # Assert - all fields should be "N/A" assert all(value == "N/A" or value == {} for value in metrics.values()) @@ -303,28 +293,22 @@ def test_numeric_comparison(self, mock_api_provider): """Test comparison of numeric values""" controller = AnalysisController(mock_api_provider) - results_a = { - "payload": { - "integrated_loudness_lufs": -14.0, - "peak_loudness_dbfs": -1.0, - "bit_depth": 24, - "sample_rate": 44100 - } - } + results_a = AnalysisResult(payload={ + "integrated_loudness_lufs": -14.0, + "peak_loudness_dbfs": -1.0, + "bit_depth": 24, + "sample_rate": 44100 + }) - results_b = { - "payload": { - "integrated_loudness_lufs": -10.0, - "peak_loudness_dbfs": -0.5, - "bit_depth": 16, - "sample_rate": 48000 - } - } + results_b = AnalysisResult(payload={ + "integrated_loudness_lufs": -10.0, + "peak_loudness_dbfs": -0.5, + "bit_depth": 16, + "sample_rate": 48000 + }) - # Execute differences = controller._compare_metrics(results_a, results_b) - # Assert assert differences["integrated_loudness_lufs"]["difference"] == 4.0 assert differences["peak_loudness_dbfs"]["difference"] == 0.5 assert differences["bit_depth"]["difference"] == 8.0 @@ -334,24 +318,11 @@ def test_categorical_comparison(self, mock_api_provider): """Test comparison of categorical values""" controller = AnalysisController(mock_api_provider) - results_a = { - "payload": { - "clipping": "NO", - "stereo_field": "GOOD" - } - } + results_a = AnalysisResult(payload={"clipping": "NO", "stereo_field": "GOOD"}) + results_b = AnalysisResult(payload={"clipping": "YES", "stereo_field": "GOOD"}) - results_b = { - "payload": { - "clipping": "YES", - "stereo_field": "GOOD" - } - } - - # Execute differences = controller._compare_metrics(results_a, results_b) - # Assert assert differences["clipping"]["status"] == "DIFFERENT" assert differences["stereo_field"]["status"] == "SAME" @@ -359,27 +330,14 @@ def test_tonal_profile_comparison(self, mock_api_provider): """Test comparison of tonal profiles""" controller = AnalysisController(mock_api_provider) - results_a = { - "payload": { - "tonal_profile": { - "bass_frequency": "GOOD", - "high_frequency": "BRIGHT" - } - } - } - - results_b = { - "payload": { - "tonal_profile": { - "bass_frequency": "WEAK", - "high_frequency": "BRIGHT" - } - } - } + results_a = AnalysisResult(payload={ + "tonal_profile": {"bass_frequency": "GOOD", "high_frequency": "BRIGHT"} + }) + results_b = AnalysisResult(payload={ + "tonal_profile": {"bass_frequency": "WEAK", "high_frequency": "BRIGHT"} + }) - # Execute differences = controller._compare_metrics(results_a, results_b) - # Assert assert differences["tonal_profile"]["bass_frequency"]["status"] == "DIFFERENT" assert differences["tonal_profile"]["high_frequency"]["status"] == "SAME" diff --git a/tests/unit/test_controllers/test_enhance_controller.py b/tests/unit/test_controllers/test_enhance_controller.py index 6818406..37b8f95 100644 --- a/tests/unit/test_controllers/test_enhance_controller.py +++ b/tests/unit/test_controllers/test_enhance_controller.py @@ -7,7 +7,8 @@ import requests from roex_python.controllers.enhance_controller import EnhanceController from roex_python.models import ( - MixEnhanceRequest, MixEnhanceResponse, MusicalStyle, LoudnessPreference + MixEnhanceRequest, MixEnhanceResponse, EnhancedTrackResult, + MusicalStyle, LoudnessPreference ) @@ -164,74 +165,53 @@ class TestRetrieveEnhancedTrack: @patch('roex_python.controllers.enhance_controller.time.sleep') def test_successful_retrieval(self, mock_sleep, mock_api_provider): """Test successful enhanced track retrieval""" - # Setup mock_api_provider.post.return_value = { "error": False, - "revived_track_tasks_results": { - "download_url_revived": "https://example.com/enhanced.wav" + "revivedTrackTaskResults": { + "download_url_revived": "https://example.com/enhanced.wav", + "download_url_preview_revived": "https://example.com/preview.mp3", + "stems": {"vocal": "https://example.com/vocal.wav"}, + "preview_start_time": 30.0 } } controller = EnhanceController(mock_api_provider) - - # Execute result = controller.retrieve_enhanced_track("enhance_task_123") - # Assert - assert result["download_url_revived"] == "https://example.com/enhanced.wav" + assert isinstance(result, EnhancedTrackResult) + assert result.download_url_revived == "https://example.com/enhanced.wav" + assert result.download_url_preview_revived == "https://example.com/preview.mp3" + assert result.stems == {"vocal": "https://example.com/vocal.wav"} + assert result.preview_start_time == 30.0 @patch('roex_python.controllers.enhance_controller.time.sleep') def test_polling_until_ready(self, mock_sleep, mock_api_provider): """Test polling until enhanced track is ready""" - # Setup - first calls return error, last returns result mock_api_provider.post.side_effect = [ {"error": True}, {"error": True}, { "error": False, - "revived_track_tasks_results": { + "revivedTrackTaskResults": { "download_url_revived": "https://example.com/enhanced.wav" } } ] controller = EnhanceController(mock_api_provider) - - # Execute result = controller.retrieve_enhanced_track("enhance_task_123", poll_interval=1) - # Assert - assert result["download_url_revived"] == "https://example.com/enhanced.wav" + assert isinstance(result, EnhancedTrackResult) + assert result.download_url_revived == "https://example.com/enhanced.wav" assert mock_api_provider.post.call_count == 3 - @patch('roex_python.controllers.enhance_controller.time.sleep') - def test_alternate_response_format(self, mock_sleep, mock_api_provider): - """Test handling of alternate response format""" - # Setup - response in different format - mock_api_provider.post.return_value = { - "error": False, - "track_results": { - "download_url_preview_revived": "https://example.com/preview_enhanced.wav" - } - } - - controller = EnhanceController(mock_api_provider) - - # Execute - result = controller.retrieve_enhanced_track("enhance_task_123") - - # Assert - should find the download URL in nested structure - assert "download_url_preview_revived" in result - @patch('roex_python.controllers.enhance_controller.time.sleep') def test_polling_timeout(self, mock_sleep, mock_api_provider): """Test polling timeout after max attempts""" - # Setup - always return error mock_api_provider.post.return_value = {"error": True} controller = EnhanceController(mock_api_provider) - # timeout=3, poll_interval=1 -> 3 attempts with pytest.raises(Exception, match="did not complete after polling"): controller.retrieve_enhanced_track("enhance_task_123", timeout=3, poll_interval=1) @@ -240,24 +220,21 @@ def test_polling_timeout(self, mock_sleep, mock_api_provider): @patch('roex_python.controllers.enhance_controller.time.sleep') def test_http_error_continues_polling(self, mock_sleep, mock_api_provider): """Test that HTTP errors don't stop polling""" - # Setup - first call errors, second succeeds mock_api_provider.post.side_effect = [ requests.HTTPError("Temporary error"), { "error": False, - "revived_track_tasks_results": { + "revivedTrackTaskResults": { "download_url_revived": "https://example.com/enhanced.wav" } } ] controller = EnhanceController(mock_api_provider) - - # timeout=10, poll_interval=1 -> up to 10 attempts result = controller.retrieve_enhanced_track("enhance_task_123", timeout=10, poll_interval=1) - # Assert - should eventually succeed despite initial error - assert result["download_url_revived"] == "https://example.com/enhanced.wav" + assert isinstance(result, EnhancedTrackResult) + assert result.download_url_revived == "https://example.com/enhanced.wav" @pytest.mark.unit diff --git a/tests/unit/test_controllers/test_mastering_controller.py b/tests/unit/test_controllers/test_mastering_controller.py index 8e80243..bc08088 100644 --- a/tests/unit/test_controllers/test_mastering_controller.py +++ b/tests/unit/test_controllers/test_mastering_controller.py @@ -9,7 +9,7 @@ from roex_python.controllers.mastering_controller import MasteringController from roex_python.models import ( MasteringRequest, MusicalStyle, DesiredLoudness, - MasteringTaskResponse + MasteringTaskResponse, PreviewMasterResult, FinalMasterResult ) @@ -102,27 +102,23 @@ class TestRetrievePreviewMaster: def test_immediate_success(self, mock_api_provider): """Test when preview is immediately available""" - # Setup mock_api_provider.post.return_value = { "previewMasterTaskResults": { "download_url_mastered_preview": "https://example.com/preview.wav", - "status": "completed" + "preview_start_time": 15.0 } } controller = MasteringController(mock_api_provider) - - # Execute result = controller.retrieve_preview_master("task_123") - # Assert - assert result["download_url_mastered_preview"] == "https://example.com/preview.wav" - assert result["status"] == "completed" + assert isinstance(result, PreviewMasterResult) + assert result.download_url_mastered_preview == "https://example.com/preview.wav" + assert result.preview_start_time == 15.0 @patch('roex_python.controllers.mastering_controller.time.sleep') def test_polling_until_ready(self, mock_sleep, mock_api_provider): """Test polling until preview is ready""" - # Setup - first call returns pending, second returns result mock_api_provider.post.side_effect = [ requests.HTTPError("Not ready"), {"status": 202}, @@ -134,12 +130,10 @@ def test_polling_until_ready(self, mock_sleep, mock_api_provider): ] controller = MasteringController(mock_api_provider) - - # Execute result = controller.retrieve_preview_master("task_123", poll_interval=1) - # Assert - assert result["download_url_mastered_preview"] == "https://example.com/preview.wav" + assert isinstance(result, PreviewMasterResult) + assert result.download_url_mastered_preview == "https://example.com/preview.wav" assert mock_api_provider.post.call_count == 3 @patch('roex_python.controllers.mastering_controller.time.sleep') @@ -169,7 +163,6 @@ class TestRetrieveFinalMaster: def test_structured_response(self, mock_api_provider): """Test with structured response format""" - # Setup mock_api_provider.post.return_value = { "finalMasterTaskResults": { "download_url_mastered": "https://example.com/final.wav" @@ -177,25 +170,22 @@ def test_structured_response(self, mock_api_provider): } controller = MasteringController(mock_api_provider) - - # Execute result = controller.retrieve_final_master("task_123") - # Assert - assert result["download_url_mastered"] == "https://example.com/final.wav" + assert isinstance(result, FinalMasterResult) + assert result.download_url_mastered == "https://example.com/final.wav" - def test_direct_url_response(self, mock_api_provider): - """Test when response is direct URL""" - # Setup - mock_api_provider.post.return_value = "https://example.com/final.wav" + def test_direct_url_in_response(self, mock_api_provider): + """Test when download_url_mastered is directly in response dict""" + mock_api_provider.post.return_value = { + "download_url_mastered": "https://example.com/final.wav" + } controller = MasteringController(mock_api_provider) - - # Execute result = controller.retrieve_final_master("task_123") - # Assert - assert result == "https://example.com/final.wav" + assert isinstance(result, FinalMasterResult) + assert result.download_url_mastered == "https://example.com/final.wav" def test_http_error(self, mock_api_provider): """Test error handling""" diff --git a/tests/unit/test_controllers/test_mix_controller.py b/tests/unit/test_controllers/test_mix_controller.py index 8f0e23c..f18f7e9 100644 --- a/tests/unit/test_controllers/test_mix_controller.py +++ b/tests/unit/test_controllers/test_mix_controller.py @@ -9,8 +9,8 @@ from roex_python.models import ( MultitrackMixRequest, TrackData, InstrumentGroup, PresenceSetting, PanPreference, ReverbPreference, - MusicalStyle, FinalMixRequest, TrackGainData, - MultitrackTaskResponse, FinalMixRequestAdvanced, + MusicalStyle, FinalMixRequest, FinalMixResult, TrackGainData, + MultitrackTaskResponse, PreviewMixResult, FinalMixRequestAdvanced, TrackEffectsData, EQSettings, EQBandSettings, CompressionSettings, PanningSettings, DesiredLoudness ) @@ -161,46 +161,42 @@ class TestRetrievePreviewMix: def test_immediate_success(self, mock_api_provider): """Test when preview is immediately available""" - # Setup mock_api_provider.post.return_value = { "previewMixTaskResults": { - "download_url_preview_mix": "https://example.com/preview.wav", - "status": "MIX_TASK_PREVIEW_COMPLETED" + "download_url_preview_mixed": "https://example.com/preview.wav", + "status": "MIX_TASK_PREVIEW_COMPLETED", + "stems": {"vocal": "https://example.com/vocal.wav"}, + "mix_output_settings": {"gain": 1.0} }, "status": "MIX_TASK_PREVIEW_COMPLETED" } controller = MixController(mock_api_provider) - - # Execute result = controller.retrieve_preview_mix("mix_task_123") - # Assert - assert result["download_url_preview_mix"] == "https://example.com/preview.wav" - assert result["status"] == "MIX_TASK_PREVIEW_COMPLETED" + assert isinstance(result, PreviewMixResult) + assert result.download_url_preview_mixed == "https://example.com/preview.wav" + assert result.status == "MIX_TASK_PREVIEW_COMPLETED" @patch('roex_python.controllers.mix_controller.time.sleep') def test_polling_until_ready(self, mock_sleep, mock_api_provider): """Test polling until preview is ready""" - # Setup - first calls return pending, last returns result mock_api_provider.post.side_effect = [ requests.HTTPError("Not ready"), {"status": "PROCESSING"}, { "previewMixTaskResults": { - "download_url_preview_mix": "https://example.com/preview.wav", + "download_url_preview_mixed": "https://example.com/preview.wav", "status": "MIX_TASK_PREVIEW_COMPLETED" } } ] controller = MixController(mock_api_provider) - - # Execute result = controller.retrieve_preview_mix("mix_task_123", poll_interval=1) - # Assert - assert result["download_url_preview_mix"] == "https://example.com/preview.wav" + assert isinstance(result, PreviewMixResult) + assert result.download_url_preview_mixed == "https://example.com/preview.wav" assert mock_api_provider.post.call_count == 3 @patch('roex_python.controllers.mix_controller.time.sleep') @@ -225,25 +221,21 @@ def test_polling_timeout(self, mock_sleep, mock_api_provider): def test_with_fx_settings(self, mock_api_provider): """Test retrieving preview with FX settings""" - # Setup mock_api_provider.post.return_value = { "previewMixTaskResults": { - "download_url_preview_mix": "https://example.com/preview.wav", - "fx_settings": {"compression": "moderate"}, + "download_url_preview_mixed": "https://example.com/preview.wav", + "mix_output_settings": {"compression": "moderate"}, "status": "MIX_TASK_PREVIEW_COMPLETED" }, "status": "MIX_TASK_PREVIEW_COMPLETED" } controller = MixController(mock_api_provider) - - # Execute result = controller.retrieve_preview_mix("mix_task_123", retrieve_fx_settings=True) - # Assert - assert "fx_settings" in result + assert isinstance(result, PreviewMixResult) + assert result.mix_output_settings == {"compression": "moderate"} - # Verify retrieveFXSettings was passed payload = mock_api_provider.post.call_args[0][1] assert payload["multitrackData"]["retrieveFXSettings"] is True @@ -254,10 +246,11 @@ class TestRetrieveFinalMix: def test_successful_retrieval(self, mock_api_provider): """Test successful final mix retrieval""" - # Setup mock_api_provider.post.return_value = { "applyAudioEffectsResults": { - "download_url_final_mix": "https://example.com/final.wav" + "download_url_mixed": "https://example.com/final.wav", + "stems": {"vocal": "https://example.com/vocal.wav"}, + "mix_output_settings": {"gain": 2.5} } } @@ -275,11 +268,10 @@ def test_successful_retrieval(self, mock_api_provider): track_data=track_data ) - # Execute result = controller.retrieve_final_mix(request) - # Assert - assert result["download_url_final_mix"] == "https://example.com/final.wav" + assert isinstance(result, FinalMixResult) + assert result.download_url_mixed == "https://example.com/final.wav" def test_with_multiple_tracks_and_gains(self, mock_api_provider): """Test final mix with multiple tracks and gain adjustments""" @@ -703,11 +695,10 @@ def test_successful_advanced_final_mix(self, mock_api_provider): webhook_url="https://example.com/webhook" ) - # Execute result = controller.retrieve_final_mix_advanced(request) - # Assert - assert result["download_url_mixed"] == "https://example.com/final_advanced.wav" + assert isinstance(result, FinalMixResult) + assert result.download_url_mixed == "https://example.com/final_advanced.wav" # Verify payload structure payload = mock_api_provider.post.call_args[0][1] From d483e105a1c12dd45ec7c13e5cebc526c5c50ddf Mon Sep 17 00:00:00 2001 From: Dave Ronan Date: Wed, 22 Apr 2026 12:29:56 +0100 Subject: [PATCH 2/4] Fix examples to use typed response attributes instead of dict access All example scripts still used .get() / [] on return values that are now dataclass instances (EnhancedTrackResult, PreviewMasterResult, etc). Updated to use attribute access (.download_url_revived, .payload, etc). Made-with: Cursor --- examples/analysis_example.py | 14 +++++++------- examples/enhance_example.py | 8 ++++---- examples/mastering_example.py | 9 ++++----- examples/mix_example.py | 4 ++-- roex_python/models/mastering.py | 1 - 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/examples/analysis_example.py b/examples/analysis_example.py index f2f2a73..8dfc17e 100644 --- a/examples/analysis_example.py +++ b/examples/analysis_example.py @@ -65,13 +65,13 @@ def print_analysis_results(results): """Pretty print the analysis results from the mix analysis endpoint. Args: - results: Dictionary containing the analysis results from the API + results: AnalysisResult from the API """ - if not results or "payload" not in results: + if not results or not results.payload: logger.error("No payload data found in analysis results.") return - payload = results["payload"] + payload = results.payload print("\n==== Mix Analysis Results ====") print(f"Integrated Loudness: {payload.get('integrated_loudness_lufs', 'N/A')} LUFS") @@ -205,15 +205,15 @@ def analysis_workflow(input_file: str, compare_file: str = None): ) try: analysis_results = client.analysis.analyze_mix(analysis_request) - if not analysis_results or analysis_results.get("error"): # Basic error check - logger.error(f"Error analyzing mix: {analysis_results.get('message', 'Unknown API error')}") - # Continue to comparison if possible, but don't save analysis results + if not analysis_results or analysis_results.error: + logger.error(f"Error analyzing mix: {analysis_results.info if analysis_results else 'Unknown API error'}") else: print_analysis_results(analysis_results) analysis_output_path = output_dir / f"analysis_{file_path_a.stem}.json" try: + from dataclasses import asdict with open(analysis_output_path, "w") as f: - json.dump(analysis_results, f, indent=2) + json.dump(asdict(analysis_results), f, indent=2) logger.info(f"\nAnalysis results saved to {analysis_output_path}") except IOError as e: logger.error(f"Error saving analysis results: {e}") diff --git a/examples/enhance_example.py b/examples/enhance_example.py index 2b25a4b..da8f740 100644 --- a/examples/enhance_example.py +++ b/examples/enhance_example.py @@ -132,7 +132,7 @@ def enhance_workflow(input_file: str = None): logger.error(f"Error retrieving preview: {e}") return - preview_url = preview_results.get("download_url_preview_revived") + preview_url = preview_results.download_url_preview_revived if not preview_url: logger.error("No preview URL received") return @@ -148,7 +148,7 @@ def enhance_workflow(input_file: str = None): logger.error("Failed to download preview") return - stems = preview_results.get('stems', {}) + stems = preview_results.stems or {} if stems: logger.info("\nDownloading preview stems...") for stem_name, stem_url in stems.items(): @@ -177,7 +177,7 @@ def enhance_workflow(input_file: str = None): logger.error(f"Error retrieving full enhancement: {e}") return None - final_url = full_results.get('download_url_revived') + final_url = full_results.download_url_revived if not final_url: logger.error("No final enhancement URL received") return @@ -191,7 +191,7 @@ def enhance_workflow(input_file: str = None): return logger.info(f"Downloaded full enhancement to {full_path}") - stems = full_results.get('stems', {}) + stems = full_results.stems or {} if stems: logger.info("\nDownloading final stems...") for stem_name, stem_url in stems.items(): diff --git a/examples/mastering_example.py b/examples/mastering_example.py index 7baeb39..93dc0e8 100644 --- a/examples/mastering_example.py +++ b/examples/mastering_example.py @@ -110,7 +110,7 @@ def master_single_track(client: RoExClient, file_path: Path, output_dir: Path): logger.info("Retrieving preview master (this may take some time)...") preview_results = client.mastering.retrieve_preview_master(mastering_task_id) - preview_url = preview_results.get('download_url_mastered_preview') + preview_url = preview_results.download_url_mastered_preview if preview_url: logger.info("Preview master ready.") # Optional: Download preview @@ -130,11 +130,10 @@ def master_single_track(client: RoExClient, file_path: Path, output_dir: Path): try: logger.info("Retrieving final master URL (this may take some time)...") # This retrieves the download URL for the final mastered file - final_url_response = client.mastering.retrieve_final_master(mastering_task_id) - final_url = final_url_response.get('download_url_mastered') + final_result = client.mastering.retrieve_final_master(mastering_task_id) + final_url = final_result.download_url_mastered if not final_url: - error_msg = final_url_response.get('message', 'No URL found') - logger.error(f"Could not retrieve final master URL. Error: {error_msg}") + logger.error("Could not retrieve final master URL.") return False logger.info("Final master URL ready.") except Exception as e: diff --git a/examples/mix_example.py b/examples/mix_example.py index c54e118..d1f3593 100644 --- a/examples/mix_example.py +++ b/examples/mix_example.py @@ -247,8 +247,8 @@ def add_track_config(track_name: str, path_str: Optional[str], instrument: Instr logger.error(f"Error retrieving mix preview: {e}") return - preview_download_url = preview_results.get('download_url_preview_mixed') - fx_settings = preview_results.get('mix_output_settings') # Capture FX settings + preview_download_url = preview_results.download_url_preview_mixed + fx_settings = preview_results.mix_output_settings if preview_download_url: logger.info(f"Preview mix ready: {preview_download_url}") diff --git a/roex_python/models/mastering.py b/roex_python/models/mastering.py index a7af7a5..b5ac02c 100644 --- a/roex_python/models/mastering.py +++ b/roex_python/models/mastering.py @@ -6,7 +6,6 @@ from typing import List, Optional - from roex_python.models.common import DesiredLoudness, MusicalStyle From d9e20acc86c17d8779e85f8beac8a58a8d7c8d13 Mon Sep 17 00:00:00 2001 From: Dave Ronan Date: Wed, 22 Apr 2026 12:31:18 +0100 Subject: [PATCH 3/4] Fix remaining dict access in examples, integration tests, and smoke test - advanced_mix_example.py: use .download_url_preview_mixed / .download_url_mixed - test_mastering_integration.py: use .download_url_mastered_preview attribute - test_analysis_integration.py: use .payload attribute instead of dict .get() - smoke_test.py: bump version assertion to 1.3.2, use .payload on AnalysisResult Made-with: Cursor --- examples/advanced_mix_example.py | 4 ++-- tests/integration/test_analysis_integration.py | 12 +++++------- tests/integration/test_mastering_integration.py | 10 +++++----- tests/smoke_test.py | 7 ++++--- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/examples/advanced_mix_example.py b/examples/advanced_mix_example.py index 626206f..53a6dd3 100644 --- a/examples/advanced_mix_example.py +++ b/examples/advanced_mix_example.py @@ -185,7 +185,7 @@ def advanced_mix_workflow(bass_path: str, vocals_path: str, drums_path: str = No logger.info("? Mix preview ready!") # Download preview - preview_url = preview_results.get('download_url_preview_mixed') + preview_url = preview_results.download_url_preview_mixed if preview_url: preview_file = output_path / "mix_preview.wav" if client.api_provider.download_file(preview_url, str(preview_file)): @@ -273,7 +273,7 @@ def advanced_mix_workflow(bass_path: str, vocals_path: str, drums_path: str = No logger.info("? Advanced final mix complete!") # Download final mix - final_url = final_mix_results.get('download_url_mixed') or final_mix_results.get('download_url_final_mix') + final_url = final_mix_results.download_url_mixed if final_url: final_file = output_path / "mix_final_advanced.wav" if client.api_provider.download_file(final_url, str(final_file)): diff --git a/tests/integration/test_analysis_integration.py b/tests/integration/test_analysis_integration.py index a114ea3..b0f1ab5 100644 --- a/tests/integration/test_analysis_integration.py +++ b/tests/integration/test_analysis_integration.py @@ -31,14 +31,12 @@ def test_analyze_mix(self, requires_api_key, integration_audio_file): results = client.analysis.analyze_mix(request) - # Verify results contain expected metrics - assert "payload" in results or isinstance(results, dict) - print(f"Analysis results keys: {results.keys()}") + assert results is not None + assert results.payload is not None + print(f"Analysis payload keys: {list(results.payload.keys())}") - # Check for common analysis fields - payload = results.get("payload", results) - if "integrated_loudness_lufs" in payload: - print(f"Integrated loudness: {payload['integrated_loudness_lufs']} LUFS") + if "integrated_loudness_lufs" in results.payload: + print(f"Integrated loudness: {results.payload['integrated_loudness_lufs']} LUFS") def test_compare_two_mixes(self, requires_api_key, integration_audio_file): """Test comparing two mixes""" diff --git a/tests/integration/test_mastering_integration.py b/tests/integration/test_mastering_integration.py index eabc62d..50d53e4 100644 --- a/tests/integration/test_mastering_integration.py +++ b/tests/integration/test_mastering_integration.py @@ -52,13 +52,13 @@ def test_complete_mastering_workflow(self, requires_api_key, integration_audio_f max_attempts=30, poll_interval=5 ) - assert "download_url_mastered_preview" in preview - print(f"Preview ready: {preview.get('download_url_mastered_preview')}") + assert preview.download_url_mastered_preview is not None + print(f"Preview ready: {preview.download_url_mastered_preview}") # Retrieve final master - final_url = client.mastering.retrieve_final_master(task.mastering_task_id) - assert final_url is not None - print(f"Final master URL: {final_url}") + final_result = client.mastering.retrieve_final_master(task.mastering_task_id) + assert final_result.download_url_mastered is not None + print(f"Final master URL: {final_result.download_url_mastered}") def test_mastering_different_styles(self, requires_api_key, integration_audio_file): """Test mastering with different musical styles""" diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 16d6416..08de900 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -1,5 +1,5 @@ """ -Smoke test for roex-python SDK v1.3.1 +Smoke test for roex-python SDK v1.3.2 Validates that models, enums, and payloads are accepted by the live API. Run: ROEX_API_KEY= python -m tests.smoke_test """ @@ -28,7 +28,7 @@ def main(): ) from roex_python.utils import upload_file import roex_python - assert roex_python.__version__ == "1.3.1" + assert roex_python.__version__ == "1.3.2" print(f"OK (v{roex_python.__version__})") except Exception as e: print(f"FAIL: {e}") @@ -106,7 +106,8 @@ def main(): ) results = client.analysis.analyze_mix(request) assert results is not None - print(f"OK (keys: {list(results.keys()) if isinstance(results, dict) else 'non-dict'})") + assert results.payload is not None + print(f"OK (payload keys: {list(results.payload.keys())})") except Exception as e: print(f"FAIL: {e}") failures.append(("Analysis", e)) From aee61d05f401f0f53c53417b32f757aad2777d18 Mon Sep 17 00:00:00 2001 From: Dave Ronan Date: Wed, 22 Apr 2026 13:01:56 +0100 Subject: [PATCH 4/4] Fix enhance polling returning early on 202 with null URLs The API returns revivedTrackTaskResults with null download URLs in 202 (still processing) responses. The polling loop treated any truthy dict as completion, causing retrieve_enhanced_track to return an EnhancedTrackResult with all None fields. Now checks that at least one download URL is populated before considering the task complete. Made-with: Cursor --- roex_python/controllers/enhance_controller.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/roex_python/controllers/enhance_controller.py b/roex_python/controllers/enhance_controller.py index 7abc8df..b93b1a6 100644 --- a/roex_python/controllers/enhance_controller.py +++ b/roex_python/controllers/enhance_controller.py @@ -219,7 +219,11 @@ def retrieve_enhanced_track(self, task_id: str, poll_interval: int = 5, timeout: if not response.get("error", False): results = response.get("revivedTrackTaskResults", {}) - if results: + has_url = ( + results.get("download_url_preview_revived") + or results.get("download_url_revived") + ) + if results and has_url: logger.info(f"Enhanced track retrieved successfully for task ID: {task_id}") return EnhancedTrackResult( download_url_preview_revived=results.get("download_url_preview_revived"),