diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 9563c3c..5516793 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -1,5 +1,5 @@ -# Build and release AceForge-Suno AU + VST3 (macOS) -name: Build and release (macOS) +# Build and release AceForge-Suno for macOS, Windows, and Linux +name: Build and release on: push: @@ -139,3 +139,125 @@ jobs: with: name: AceForgeSuno-macOS-plugins path: release-artefacts/ + + build-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure CMake + run: | + cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: | + cmake --build build --config Release + + - name: Locate plugin artefacts + id: artefacts + shell: bash + run: | + set -e + VST3=$(find build -name "AceForge-Suno.vst3" -type d 2>/dev/null | head -1) + [ -n "$VST3" ] || VST3=$(find build -name "*.vst3" -type d 2>/dev/null | head -1) + echo "vst3_path=$VST3" >> $GITHUB_OUTPUT + echo "Found VST3: $VST3" + if [ -z "$VST3" ]; then + echo "Plugin artefacts not found. Build tree:" + find build -type d -name "*.vst3" 2>/dev/null || true + exit 1 + fi + + - name: Create zip archive for release + shell: bash + run: | + mkdir -p release-artefacts + cp -R "${{ steps.artefacts.outputs.vst3_path }}" "release-artefacts/AceForge-Suno.vst3" + cd release-artefacts && 7z a -tzip "AceForgeSuno-Windows-VST3.zip" "AceForge-Suno.vst3" && cd .. + echo "zip_path=release-artefacts/AceForgeSuno-Windows-VST3.zip" >> $GITHUB_ENV + + - name: Upload release assets + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ (github.event_name == 'release' && github.event.release.tag_name) || inputs.release_tag }} + files: | + release-artefacts/AceForgeSuno-Windows-VST3.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artefacts (push / workflow_dispatch or no release) + if: github.event_name != 'release' || github.event.release == null + uses: actions/upload-artifact@v4 + with: + name: AceForgeSuno-Windows-plugins + path: release-artefacts/ + + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + cmake \ + libcurl4-openssl-dev \ + libasound2-dev \ + libfreetype6-dev \ + libx11-dev \ + libxcomposite-dev \ + libxcursor-dev \ + libxinerama-dev \ + libxrandr-dev \ + libgl1-mesa-dev + + - name: Configure CMake + run: | + cmake -B build -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: | + cmake --build build --config Release + + - name: Locate plugin artefacts + id: artefacts + run: | + set -e + VST3=$(find build -name "AceForge-Suno.vst3" -type d 2>/dev/null | head -1) + [ -n "$VST3" ] || VST3=$(find build -name "*.vst3" -type d 2>/dev/null | head -1) + echo "vst3_path=$VST3" >> $GITHUB_OUTPUT + echo "Found VST3: $VST3" + if [ -z "$VST3" ]; then + echo "Plugin artefacts not found. Build tree:" + find build -type d -name "*.vst3" 2>/dev/null || true + exit 1 + fi + + - name: Create tar.gz archive for release + run: | + mkdir -p release-artefacts + cp -R "${{ steps.artefacts.outputs.vst3_path }}" "release-artefacts/AceForge-Suno.vst3" + cd release-artefacts && tar -czf "AceForgeSuno-Linux-VST3.tar.gz" "AceForge-Suno.vst3" && cd .. + echo "archive_path=release-artefacts/AceForgeSuno-Linux-VST3.tar.gz" >> $GITHUB_ENV + + - name: Upload release assets + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ (github.event_name == 'release' && github.event.release.tag_name) || inputs.release_tag }} + files: | + release-artefacts/AceForgeSuno-Linux-VST3.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artefacts (push / workflow_dispatch or no release) + if: github.event_name != 'release' || github.event.release == null + uses: actions/upload-artifact@v4 + with: + name: AceForgeSuno-Linux-plugins + path: release-artefacts/ diff --git a/README.md b/README.md index 3b767ab..e81788c 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ Control the **Suno API** from your DAW. Generate music, create covers, and add v ## Platform Support -**Currently supported:** macOS (Apple Silicon & Intel) - -**Future support:** Windows and Linux are planned for future releases. +**Currently supported:** +- **macOS** — Apple Silicon (arm64) and Intel (x86_64) via AU and VST3 formats +- **Windows** — x64 via VST3 format +- **Linux** — x64 via VST3 format ## Features @@ -27,14 +28,17 @@ Control the **Suno API** from your DAW. Generate music, create covers, and add v ### Pre-built Releases (Recommended) Download the latest release from [GitHub Releases](https://github.com/audiohacking/suno-daw/releases): -- `.pkg` installer for easy installation -- `.zip` for manual installation +- **macOS:** `.pkg` installer or `.zip` for manual installation +- **Windows:** `.zip` containing VST3 plugin +- **Linux:** `.tar.gz` containing VST3 plugin After installation, rescan plugins in your DAW. ### Requirements -- macOS (Apple Silicon or Intel) +- **macOS:** macOS 10.13 or later (Apple Silicon or Intel) +- **Windows:** Windows 10 or later (x64) +- **Linux:** Modern Linux distribution with X11 support - A Suno API key from [Suno API](https://docs.sunoapi.org/) ## Quick Start @@ -54,11 +58,29 @@ After installation, rescan plugins in your DAW. For developers who want to build the plugin: +### macOS ```bash cmake -B build -G Xcode -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release ``` +### Windows +```bash +cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release +``` + +### Linux +```bash +# Install dependencies first +sudo apt-get install build-essential cmake libcurl4-openssl-dev libasound2-dev \ + libfreetype6-dev libx11-dev libxcomposite-dev libxcursor-dev libxinerama-dev \ + libxrandr-dev libgl1-mesa-dev + +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release +``` + Copy the built plugins to your system plugin folders and rescan in your DAW. For detailed build instructions and architecture documentation, see [DESIGN.md](DESIGN.md). diff --git a/SunoClient/SunoClientLinux.cpp b/SunoClient/SunoClientLinux.cpp new file mode 100644 index 0000000..52839d6 --- /dev/null +++ b/SunoClient/SunoClientLinux.cpp @@ -0,0 +1,389 @@ +/** + * SunoClient implementation for Linux using libcurl (synchronous). + * Auth: Bearer token. API base: https://api.sunoapi.org + */ +#if defined(__linux__) || defined(__unix__) + +#include "SunoClient.hpp" +#include +#include +#include +#include + +namespace suno { + +// Note: In production code, curl_global_init should be called once at application startup +// and curl_global_cleanup at shutdown. For this plugin, we initialize per-client for simplicity. +// If thread-safety issues arise, consider using a global initialization mechanism. + +static std::string escapeJsonString(const std::string& s) { + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + if (c == '"') out += "\\\""; + else if (c == '\\') out += "\\\\"; + else if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else if ((unsigned char)c >= 32) out += c; + } + return out; +} + +// Callback for curl to write response data +static size_t writeCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t totalSize = size * nmemb; + std::string* response = static_cast(userp); + response->append(static_cast(contents), totalSize); + return totalSize; +} + +// Callback for curl to write binary data +static size_t writeBinaryCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t totalSize = size * nmemb; + std::vector* response = static_cast*>(userp); + uint8_t* data = static_cast(contents); + response->insert(response->end(), data, data + totalSize); + return totalSize; +} + +SunoClient::SunoClient(std::string apiKey) : apiKey_(std::move(apiKey)) { + curl_global_init(CURL_GLOBAL_DEFAULT); +} + +SunoClient::~SunoClient() { + curl_global_cleanup(); +} + +std::string SunoClient::get(const std::string& path) { + lastError_.clear(); + if (apiKey_.empty()) { lastError_ = "No API key"; return {}; } + + std::string url = std::string(kBaseUrl) + (path.empty() || path[0] != '/' ? "/" : "") + path; + std::string response; + + CURL* curl = curl_easy_init(); + if (!curl) { + lastError_ = "Failed to initialize curl"; + return {}; + } + + struct curl_slist* headers = nullptr; + std::string authHeader = "Authorization: Bearer " + apiKey_; + headers = curl_slist_append(headers, authHeader.c_str()); + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + CURLcode res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + lastError_ = curl_easy_strerror(res); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return {}; + } + + long httpCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (httpCode >= 400) { + lastError_ = "HTTP " + std::to_string(httpCode) + (response.empty() ? "" : " " + response.substr(0, 200)); + return {}; + } + + return response; +} + +std::string SunoClient::post(const std::string& path, const std::string& jsonBody) { + lastError_.clear(); + if (apiKey_.empty()) { lastError_ = "No API key"; return {}; } + + std::string url = std::string(kBaseUrl) + (path.empty() || path[0] != '/' ? "/" : "") + path; + std::string response; + + CURL* curl = curl_easy_init(); + if (!curl) { + lastError_ = "Failed to initialize curl"; + return {}; + } + + struct curl_slist* headers = nullptr; + std::string authHeader = "Authorization: Bearer " + apiKey_; + headers = curl_slist_append(headers, authHeader.c_str()); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, jsonBody.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, jsonBody.size()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + CURLcode res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + lastError_ = curl_easy_strerror(res); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return {}; + } + + long httpCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (httpCode >= 400) { + lastError_ = "HTTP " + std::to_string(httpCode) + (response.empty() ? "" : " " + response.substr(0, 200)); + return {}; + } + + return response; +} + +bool SunoClient::checkCredits() { + std::string body = get("/api/v1/generate/credit"); + if (body.empty()) return false; + return body.find("\"code\":200") != std::string::npos || body.find("\"data\":") != std::string::npos; +} + +// Build JSON for generate (callBackUrl required by API; we poll so use placeholder) +static std::string buildGenerateJson(const GenerateParams& p, const std::string* uploadUrl = nullptr) { + std::ostringstream o; + o << "{\"customMode\":" << (p.customMode ? "true" : "false") + << ",\"instrumental\":" << (p.instrumental ? "true" : "false") + << ",\"model\":\"" << modelToString(p.model) << "\"" + << ",\"callBackUrl\":\"https://example.com/callback\""; + if (uploadUrl && !uploadUrl->empty()) + o << ",\"uploadUrl\":\"" << escapeJsonString(*uploadUrl) << "\""; + if (!p.prompt.empty()) o << ",\"prompt\":\"" << escapeJsonString(p.prompt) << "\""; + if (!p.style.empty()) o << ",\"style\":\"" << escapeJsonString(p.style) << "\""; + if (!p.title.empty()) o << ",\"title\":\"" << escapeJsonString(p.title) << "\""; + if (!p.personaId.empty()) o << ",\"personaId\":\"" << escapeJsonString(p.personaId) << "\""; + if (!p.negativeTags.empty()) o << ",\"negativeTags\":\"" << escapeJsonString(p.negativeTags) << "\""; + if (!p.vocalGender.empty()) o << ",\"vocalGender\":\"" << escapeJsonString(p.vocalGender) << "\""; + o << ",\"styleWeight\":" << p.styleWeight; + o << ",\"weirdnessConstraint\":" << p.weirdnessConstraint; + o << ",\"audioWeight\":" << p.audioWeight << "}"; + return o.str(); +} + +std::string SunoClient::startGenerate(const GenerateParams& params) { + std::string body = post("/api/v1/generate", buildGenerateJson(params)); + if (body.empty()) return {}; + // Parse data.taskId + size_t i = body.find("\"taskId\""); + if (i == std::string::npos) { lastError_ = "No taskId in response"; return {}; } + i = body.find(':', i); + if (i == std::string::npos) return {}; + i = body.find('"', i); + if (i == std::string::npos) return {}; + size_t j = body.find('"', i + 1); + if (j == std::string::npos) return {}; + return body.substr(i + 1, j - (i + 1)); +} + +std::string SunoClient::startUploadCover(const std::string& uploadUrl, const GenerateParams& params) { + std::string json = buildGenerateJson(params, &uploadUrl); + std::string body = post("/api/v1/generate/upload-cover", json); + if (body.empty()) return {}; + size_t i = body.find("\"taskId\""); + if (i == std::string::npos) { lastError_ = "No taskId in response"; return {}; } + i = body.find(':', i); if (i == std::string::npos) return {}; + i = body.find('"', i); if (i == std::string::npos) return {}; + size_t j = body.find('"', i + 1); if (j == std::string::npos) return {}; + return body.substr(i + 1, j - (i + 1)); +} + +std::string SunoClient::startAddVocals(const AddVocalsParams& params) { + std::ostringstream o; + o << "{\"uploadUrl\":\"" << escapeJsonString(params.uploadUrl) << "\"" + << ",\"prompt\":\"" << escapeJsonString(params.prompt) << "\"" + << ",\"title\":\"" << escapeJsonString(params.title) << "\"" + << ",\"negativeTags\":\"" << escapeJsonString(params.negativeTags) << "\"" + << ",\"style\":\"" << escapeJsonString(params.style) << "\"" + << ",\"callBackUrl\":\"https://example.com/callback\""; + if (!params.vocalGender.empty()) o << ",\"vocalGender\":\"" << escapeJsonString(params.vocalGender) << "\""; + o << ",\"styleWeight\":" << params.styleWeight + << ",\"weirdnessConstraint\":" << params.weirdnessConstraint + << ",\"audioWeight\":" << params.audioWeight + << ",\"model\":\"" << modelToString(params.model) << "\"}"; + std::string body = post("/api/v1/generate/add-vocals", o.str()); + if (body.empty()) return {}; + size_t i = body.find("\"taskId\""); + if (i == std::string::npos) { lastError_ = "No taskId in response"; return {}; } + i = body.find(':', i); if (i == std::string::npos) return {}; + i = body.find('"', i); if (i == std::string::npos) return {}; + size_t j = body.find('"', i + 1); if (j == std::string::npos) return {}; + return body.substr(i + 1, j - (i + 1)); +} + +// Parse GET record-info response for status and audioUrls +static void parseRecordInfo(const std::string& body, TaskStatus* out) { + if (!out) return; + auto pos = body.find("\"status\""); + if (pos != std::string::npos) { + pos = body.find('"', body.find(':', pos) + 1); + size_t end = body.find('"', pos + 1); + if (pos != std::string::npos && end != std::string::npos) + out->status = body.substr(pos + 1, end - (pos + 1)); + } + pos = body.find("\"errorMessage\""); + if (pos != std::string::npos) { + pos = body.find('"', body.find(':', pos) + 1); + size_t end = body.find('"', pos + 1); + if (pos != std::string::npos && end != std::string::npos) + out->errorMessage = body.substr(pos + 1, end - (pos + 1)); + } + pos = body.find("\"taskId\""); + if (pos != std::string::npos) { + pos = body.find('"', body.find(':', pos) + 1); + size_t end = body.find('"', pos + 1); + if (pos != std::string::npos && end != std::string::npos) + out->taskId = body.substr(pos + 1, end - (pos + 1)); + } + // sunoData array: each element has "audioUrl" + size_t idx = 0; + while ((idx = body.find("\"audioUrl\"", idx)) != std::string::npos) { + idx = body.find(':', idx); + if (idx == std::string::npos) break; + size_t q = body.find('"', idx); + if (q == std::string::npos) break; + size_t r = body.find('"', q + 1); + if (r != std::string::npos) + out->audioUrls.push_back(body.substr(q + 1, r - (q + 1))); + idx = r + 1; + } +} + +TaskStatus SunoClient::getTaskStatus(const std::string& taskId) { + TaskStatus out; + out.taskId = taskId; + lastError_.clear(); + if (apiKey_.empty()) { lastError_ = "No API key"; return out; } + std::string path = "/api/v1/generate/record-info?taskId=" + taskId; + std::string body = get(path); + if (body.empty()) return out; + parseRecordInfo(body, &out); + return out; +} + +std::string SunoClient::uploadAudio(const std::vector& audioWavOrMp3, const std::string& fileName) { + lastError_.clear(); + if (apiKey_.empty()) { lastError_ = "No API key"; return {}; } + + std::string name = fileName.empty() ? "audio.wav" : fileName; + std::string url = std::string(kUploadBaseUrl) + "/api/file-stream-upload"; + std::string response; + + CURL* curl = curl_easy_init(); + if (!curl) { + lastError_ = "Failed to initialize curl"; + return {}; + } + + struct curl_slist* headers = nullptr; + std::string authHeader = "Authorization: Bearer " + apiKey_; + headers = curl_slist_append(headers, authHeader.c_str()); + + // Create multipart form + curl_mime* mime = curl_mime_init(curl); + curl_mimepart* part = curl_mime_addpart(mime); + curl_mime_name(part, "file"); + curl_mime_filename(part, name.c_str()); + curl_mime_data(part, reinterpret_cast(audioWavOrMp3.data()), audioWavOrMp3.size()); + curl_mime_type(part, "application/octet-stream"); + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_MIMEPOST, mime); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + CURLcode res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + lastError_ = curl_easy_strerror(res); + curl_mime_free(mime); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return {}; + } + + long httpCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); + + curl_mime_free(mime); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (httpCode >= 400) { + lastError_ = "Upload HTTP " + std::to_string(httpCode); + return {}; + } + + // Parse data.fileUrl from response + size_t i = response.find("\"fileUrl\""); + if (i == std::string::npos) { i = response.find("\"downloadUrl\""); } + if (i == std::string::npos) { lastError_ = "No fileUrl in upload response"; return {}; } + i = response.find('"', response.find(':', i) + 1); + size_t j = response.find('"', i + 1); + if (j != std::string::npos) + return response.substr(i + 1, j - (i + 1)); + return {}; +} + +std::vector SunoClient::fetchAudio(const std::string& url) { + lastError_.clear(); + std::vector audioData; + + CURL* curl = curl_easy_init(); + if (!curl) { + lastError_ = "Failed to initialize curl"; + return {}; + } + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeBinaryCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &audioData); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + CURLcode res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + lastError_ = curl_easy_strerror(res); + curl_easy_cleanup(curl); + return {}; + } + + curl_easy_cleanup(curl); + return audioData; +} + +std::string SunoClient::postMultipart(const std::string& path, const std::string& filePartName, + const void* fileData, size_t fileSize, const std::string& fileName) { + // This is used by uploadAudio, implemented there directly + return {}; +} + +} // namespace suno + +#endif diff --git a/SunoClient/SunoClientWin.cpp b/SunoClient/SunoClientWin.cpp new file mode 100644 index 0000000..2fd3259 --- /dev/null +++ b/SunoClient/SunoClientWin.cpp @@ -0,0 +1,445 @@ +/** + * SunoClient implementation for Windows using WinHTTP (synchronous). + * Auth: Bearer token. API base: https://api.sunoapi.org + */ +#ifdef _WIN32 + +#include "SunoClient.hpp" +#include +#include +#include +#include + +namespace suno { + +static std::string escapeJsonString(const std::string& s) { + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + if (c == '"') out += "\\\""; + else if (c == '\\') out += "\\\\"; + else if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else if ((unsigned char)c >= 32) out += c; + } + return out; +} + +static std::wstring stringToWString(const std::string& str) { + if (str.empty()) return std::wstring(); + int size = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), (int)str.size(), nullptr, 0); + std::wstring wstr(size, 0); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), (int)str.size(), &wstr[0], size); + return wstr; +} + +static std::string wstringToString(const std::wstring& wstr) { + if (wstr.empty()) return std::string(); + int size = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), nullptr, 0, nullptr, nullptr); + std::string str(size, 0); + WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), &str[0], size, nullptr, nullptr); + return str; +} + +static std::string performRequest(const std::wstring& host, int port, const std::wstring& path, + const std::string& method, const std::string& body, + const std::string& bearerToken) { + HINTERNET hSession = WinHttpOpen(L"SunoClient/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) return {}; + + HINTERNET hConnect = WinHttpConnect(hSession, host.c_str(), port, 0); + if (!hConnect) { + WinHttpCloseHandle(hSession); + return {}; + } + + DWORD flags = (port == 443) ? WINHTTP_FLAG_SECURE : 0; + std::wstring wmethod = stringToWString(method); + HINTERNET hRequest = WinHttpOpenRequest(hConnect, wmethod.c_str(), path.c_str(), + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); + if (!hRequest) { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return {}; + } + + // Add Authorization header + if (!bearerToken.empty()) { + std::wstring authHeader = L"Authorization: Bearer " + stringToWString(bearerToken) + L"\r\n"; + WinHttpAddRequestHeaders(hRequest, authHeader.c_str(), (DWORD)authHeader.length(), + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); + } + + // Add Content-Type for POST + if (method == "POST") { + WinHttpAddRequestHeaders(hRequest, L"Content-Type: application/json\r\n", -1, + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); + } + + BOOL result = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + (LPVOID)(body.empty() ? nullptr : body.data()), + (DWORD)body.size(), (DWORD)body.size(), 0); + + std::string response; + if (result && WinHttpReceiveResponse(hRequest, nullptr)) { + DWORD bytesAvailable = 0; + while (WinHttpQueryDataAvailable(hRequest, &bytesAvailable) && bytesAvailable > 0) { + std::vector buffer(bytesAvailable + 1); + DWORD bytesRead = 0; + if (WinHttpReadData(hRequest, buffer.data(), bytesAvailable, &bytesRead)) { + response.append(buffer.data(), bytesRead); + } + } + } + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return response; +} + +SunoClient::SunoClient(std::string apiKey) : apiKey_(std::move(apiKey)) {} + +SunoClient::~SunoClient() = default; + +std::string SunoClient::get(const std::string& path) { + lastError_.clear(); + if (apiKey_.empty()) { lastError_ = "No API key"; return {}; } + + std::string fullPath = (path.empty() || path[0] != '/') ? "/" + path : path; + std::wstring wpath = stringToWString(fullPath); + + std::string response = performRequest(L"api.sunoapi.org", 443, wpath, "GET", "", apiKey_); + if (response.empty()) { + lastError_ = "Request failed"; + return {}; + } + + // Check for error status in response + if (response.find("\"code\":") != std::string::npos) { + size_t pos = response.find("\"code\":"); + if (pos != std::string::npos) { + pos += 7; // skip "code": + while (pos < response.size() && std::isspace(response[pos])) pos++; + if (pos < response.size() && response[pos] != '2') { + // Not a 2xx code + lastError_ = "HTTP error in response"; + } + } + } + + return response; +} + +std::string SunoClient::post(const std::string& path, const std::string& jsonBody) { + lastError_.clear(); + if (apiKey_.empty()) { lastError_ = "No API key"; return {}; } + + std::string fullPath = (path.empty() || path[0] != '/') ? "/" + path : path; + std::wstring wpath = stringToWString(fullPath); + + std::string response = performRequest(L"api.sunoapi.org", 443, wpath, "POST", jsonBody, apiKey_); + if (response.empty()) { + lastError_ = "Request failed"; + return {}; + } + + return response; +} + +bool SunoClient::checkCredits() { + std::string body = get("/api/v1/generate/credit"); + if (body.empty()) return false; + return body.find("\"code\":200") != std::string::npos || body.find("\"data\":") != std::string::npos; +} + +// Build JSON for generate (callBackUrl required by API; we poll so use placeholder) +static std::string buildGenerateJson(const GenerateParams& p, const std::string* uploadUrl = nullptr) { + std::ostringstream o; + o << "{\"customMode\":" << (p.customMode ? "true" : "false") + << ",\"instrumental\":" << (p.instrumental ? "true" : "false") + << ",\"model\":\"" << modelToString(p.model) << "\"" + << ",\"callBackUrl\":\"https://example.com/callback\""; + if (uploadUrl && !uploadUrl->empty()) + o << ",\"uploadUrl\":\"" << escapeJsonString(*uploadUrl) << "\""; + if (!p.prompt.empty()) o << ",\"prompt\":\"" << escapeJsonString(p.prompt) << "\""; + if (!p.style.empty()) o << ",\"style\":\"" << escapeJsonString(p.style) << "\""; + if (!p.title.empty()) o << ",\"title\":\"" << escapeJsonString(p.title) << "\""; + if (!p.personaId.empty()) o << ",\"personaId\":\"" << escapeJsonString(p.personaId) << "\""; + if (!p.negativeTags.empty()) o << ",\"negativeTags\":\"" << escapeJsonString(p.negativeTags) << "\""; + if (!p.vocalGender.empty()) o << ",\"vocalGender\":\"" << escapeJsonString(p.vocalGender) << "\""; + o << ",\"styleWeight\":" << p.styleWeight; + o << ",\"weirdnessConstraint\":" << p.weirdnessConstraint; + o << ",\"audioWeight\":" << p.audioWeight << "}"; + return o.str(); +} + +std::string SunoClient::startGenerate(const GenerateParams& params) { + std::string body = post("/api/v1/generate", buildGenerateJson(params)); + if (body.empty()) return {}; + // Parse data.taskId + size_t i = body.find("\"taskId\""); + if (i == std::string::npos) { lastError_ = "No taskId in response"; return {}; } + i = body.find(':', i); + if (i == std::string::npos) return {}; + i = body.find('"', i); + if (i == std::string::npos) return {}; + size_t j = body.find('"', i + 1); + if (j == std::string::npos) return {}; + return body.substr(i + 1, j - (i + 1)); +} + +std::string SunoClient::startUploadCover(const std::string& uploadUrl, const GenerateParams& params) { + std::string json = buildGenerateJson(params, &uploadUrl); + std::string body = post("/api/v1/generate/upload-cover", json); + if (body.empty()) return {}; + size_t i = body.find("\"taskId\""); + if (i == std::string::npos) { lastError_ = "No taskId in response"; return {}; } + i = body.find(':', i); if (i == std::string::npos) return {}; + i = body.find('"', i); if (i == std::string::npos) return {}; + size_t j = body.find('"', i + 1); if (j == std::string::npos) return {}; + return body.substr(i + 1, j - (i + 1)); +} + +std::string SunoClient::startAddVocals(const AddVocalsParams& params) { + std::ostringstream o; + o << "{\"uploadUrl\":\"" << escapeJsonString(params.uploadUrl) << "\"" + << ",\"prompt\":\"" << escapeJsonString(params.prompt) << "\"" + << ",\"title\":\"" << escapeJsonString(params.title) << "\"" + << ",\"negativeTags\":\"" << escapeJsonString(params.negativeTags) << "\"" + << ",\"style\":\"" << escapeJsonString(params.style) << "\"" + << ",\"callBackUrl\":\"https://example.com/callback\""; + if (!params.vocalGender.empty()) o << ",\"vocalGender\":\"" << escapeJsonString(params.vocalGender) << "\""; + o << ",\"styleWeight\":" << params.styleWeight + << ",\"weirdnessConstraint\":" << params.weirdnessConstraint + << ",\"audioWeight\":" << params.audioWeight + << ",\"model\":\"" << modelToString(params.model) << "\"}"; + std::string body = post("/api/v1/generate/add-vocals", o.str()); + if (body.empty()) return {}; + size_t i = body.find("\"taskId\""); + if (i == std::string::npos) { lastError_ = "No taskId in response"; return {}; } + i = body.find(':', i); if (i == std::string::npos) return {}; + i = body.find('"', i); if (i == std::string::npos) return {}; + size_t j = body.find('"', i + 1); if (j == std::string::npos) return {}; + return body.substr(i + 1, j - (i + 1)); +} + +// Parse GET record-info response for status and audioUrls +static void parseRecordInfo(const std::string& body, TaskStatus* out) { + if (!out) return; + auto pos = body.find("\"status\""); + if (pos != std::string::npos) { + pos = body.find('"', body.find(':', pos) + 1); + size_t end = body.find('"', pos + 1); + if (pos != std::string::npos && end != std::string::npos) + out->status = body.substr(pos + 1, end - (pos + 1)); + } + pos = body.find("\"errorMessage\""); + if (pos != std::string::npos) { + pos = body.find('"', body.find(':', pos) + 1); + size_t end = body.find('"', pos + 1); + if (pos != std::string::npos && end != std::string::npos) + out->errorMessage = body.substr(pos + 1, end - (pos + 1)); + } + pos = body.find("\"taskId\""); + if (pos != std::string::npos) { + pos = body.find('"', body.find(':', pos) + 1); + size_t end = body.find('"', pos + 1); + if (pos != std::string::npos && end != std::string::npos) + out->taskId = body.substr(pos + 1, end - (pos + 1)); + } + // sunoData array: each element has "audioUrl" + size_t idx = 0; + while ((idx = body.find("\"audioUrl\"", idx)) != std::string::npos) { + idx = body.find(':', idx); + if (idx == std::string::npos) break; + size_t q = body.find('"', idx); + if (q == std::string::npos) break; + size_t r = body.find('"', q + 1); + if (r != std::string::npos) + out->audioUrls.push_back(body.substr(q + 1, r - (q + 1))); + idx = r + 1; + } +} + +TaskStatus SunoClient::getTaskStatus(const std::string& taskId) { + TaskStatus out; + out.taskId = taskId; + lastError_.clear(); + if (apiKey_.empty()) { lastError_ = "No API key"; return out; } + std::string path = "/api/v1/generate/record-info?taskId=" + taskId; + std::string body = get(path); + if (body.empty()) return out; + parseRecordInfo(body, &out); + return out; +} + +std::string SunoClient::uploadAudio(const std::vector& audioWavOrMp3, const std::string& fileName) { + lastError_.clear(); + if (apiKey_.empty()) { lastError_ = "No API key"; return {}; } + + std::string name = fileName.empty() ? "audio.wav" : fileName; + std::string boundary = "----SunoUploadBoundary"; + + // Build multipart body + std::ostringstream bodyStream; + bodyStream << "--" << boundary << "\r\n"; + bodyStream << "Content-Disposition: form-data; name=\"file\"; filename=\"" << name << "\"\r\n"; + bodyStream << "Content-Type: application/octet-stream\r\n\r\n"; + bodyStream.write(reinterpret_cast(audioWavOrMp3.data()), audioWavOrMp3.size()); + bodyStream << "\r\n--" << boundary << "--\r\n"; + + std::string body = bodyStream.str(); + std::wstring wpath = L"/api/file-stream-upload"; + + // Custom request with multipart content-type + HINTERNET hSession = WinHttpOpen(L"SunoClient/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) { lastError_ = "Failed to open session"; return {}; } + + HINTERNET hConnect = WinHttpConnect(hSession, L"api.sunoapi.org", 443, 0); + if (!hConnect) { + WinHttpCloseHandle(hSession); + lastError_ = "Failed to connect"; + return {}; + } + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST", wpath.c_str(), + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (!hRequest) { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + lastError_ = "Failed to open request"; + return {}; + } + + // Add headers + std::wstring authHeader = L"Authorization: Bearer " + stringToWString(apiKey_) + L"\r\n"; + WinHttpAddRequestHeaders(hRequest, authHeader.c_str(), (DWORD)authHeader.length(), + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); + + std::wstring contentType = L"Content-Type: multipart/form-data; boundary=" + stringToWString(boundary) + L"\r\n"; + WinHttpAddRequestHeaders(hRequest, contentType.c_str(), (DWORD)contentType.length(), + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); + + BOOL result = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + (LPVOID)body.data(), (DWORD)body.size(), (DWORD)body.size(), 0); + + std::string response; + if (result && WinHttpReceiveResponse(hRequest, nullptr)) { + DWORD bytesAvailable = 0; + while (WinHttpQueryDataAvailable(hRequest, &bytesAvailable) && bytesAvailable > 0) { + std::vector buffer(bytesAvailable + 1); + DWORD bytesRead = 0; + if (WinHttpReadData(hRequest, buffer.data(), bytesAvailable, &bytesRead)) { + response.append(buffer.data(), bytesRead); + } + } + } + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + + if (response.empty()) { lastError_ = "Upload failed"; return {}; } + + // Parse data.fileUrl from response + size_t i = response.find("\"fileUrl\""); + if (i == std::string::npos) { i = response.find("\"downloadUrl\""); } + if (i == std::string::npos) { lastError_ = "No fileUrl in upload response"; return {}; } + i = response.find('"', response.find(':', i) + 1); + size_t j = response.find('"', i + 1); + if (j != std::string::npos) + return response.substr(i + 1, j - (i + 1)); + return {}; +} + +std::vector SunoClient::fetchAudio(const std::string& url) { + lastError_.clear(); + + // Parse URL to extract host and path + std::string host, path; + bool isHttps = false; + + if (url.find("https://") == 0) { + isHttps = true; + size_t hostStart = 8; // after "https://" + size_t pathStart = url.find('/', hostStart); + if (pathStart != std::string::npos) { + host = url.substr(hostStart, pathStart - hostStart); + path = url.substr(pathStart); + } else { + host = url.substr(hostStart); + path = "/"; + } + } else if (url.find("http://") == 0) { + size_t hostStart = 7; // after "http://" + size_t pathStart = url.find('/', hostStart); + if (pathStart != std::string::npos) { + host = url.substr(hostStart, pathStart - hostStart); + path = url.substr(pathStart); + } else { + host = url.substr(hostStart); + path = "/"; + } + } else { + lastError_ = "Invalid URL"; + return {}; + } + + std::wstring whost = stringToWString(host); + std::wstring wpath = stringToWString(path); + int port = isHttps ? 443 : 80; + + HINTERNET hSession = WinHttpOpen(L"SunoClient/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) return {}; + + HINTERNET hConnect = WinHttpConnect(hSession, whost.c_str(), port, 0); + if (!hConnect) { + WinHttpCloseHandle(hSession); + return {}; + } + + DWORD flags = isHttps ? WINHTTP_FLAG_SECURE : 0; + HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", wpath.c_str(), + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); + if (!hRequest) { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return {}; + } + + BOOL result = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0); + + std::vector audioData; + if (result && WinHttpReceiveResponse(hRequest, nullptr)) { + DWORD bytesAvailable = 0; + while (WinHttpQueryDataAvailable(hRequest, &bytesAvailable) && bytesAvailable > 0) { + size_t oldSize = audioData.size(); + audioData.resize(oldSize + bytesAvailable); + DWORD bytesRead = 0; + if (WinHttpReadData(hRequest, audioData.data() + oldSize, bytesAvailable, &bytesRead)) { + audioData.resize(oldSize + bytesRead); + } + } + } + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return audioData; +} + +std::string SunoClient::postMultipart(const std::string& path, const std::string& filePartName, + const void* fileData, size_t fileSize, const std::string& fileName) { + // This is used by uploadAudio, implemented there directly + return {}; +} + +} // namespace suno + +#endif diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root new file mode 120000 index 0000000..945c9b4 --- /dev/null +++ b/_codeql_detected_source_root @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index 6c47da2..a4961f7 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -1,11 +1,6 @@ -# AceForge-Suno plugin — Suno API (Music Generation), AU + VST3, macOS +# AceForge-Suno plugin — Suno API (Music Generation), AU + VST3 (macOS), VST3 (Windows/Linux) cmake_minimum_required(VERSION 3.22) -if(NOT APPLE) - message(STATUS "AceForge-Suno plugin: skipping (macOS only)") - return() -endif() - include(FetchContent) FetchContent_Declare(JUCE GIT_REPOSITORY https://github.com/juce-framework/JUCE.git @@ -16,13 +11,30 @@ set(JUCE_ENABLE_GPL_MODE ON CACHE BOOL "" FORCE) FetchContent_MakeAvailable(JUCE) set(SUNO_CLIENT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../SunoClient) -add_library(SunoClient STATIC - ${SUNO_CLIENT_DIR}/SunoClientMac.mm -) + +# Platform-specific SunoClient implementation +if(APPLE) + add_library(SunoClient STATIC ${SUNO_CLIENT_DIR}/SunoClientMac.mm) + target_link_libraries(SunoClient PUBLIC "-framework Foundation" "-framework Security") +elseif(WIN32) + add_library(SunoClient STATIC ${SUNO_CLIENT_DIR}/SunoClientWin.cpp) + target_link_libraries(SunoClient PUBLIC winhttp) +elseif(UNIX) + add_library(SunoClient STATIC ${SUNO_CLIENT_DIR}/SunoClientLinux.cpp) + find_package(CURL REQUIRED) + target_link_libraries(SunoClient PUBLIC CURL::libcurl) +endif() + target_include_directories(SunoClient PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) -target_link_libraries(SunoClient PUBLIC "-framework Foundation" "-framework Security") target_compile_features(SunoClient PUBLIC cxx_std_17) +# Platform-specific plugin formats +if(APPLE) + set(PLUGIN_FORMATS AU VST3) +else() + set(PLUGIN_FORMATS VST3) +endif() + juce_add_plugin(AceForgeSuno VERSION 0.1.0 COMPANY_NAME "AudioHacking" @@ -33,7 +45,7 @@ juce_add_plugin(AceForgeSuno EDITOR_WANTS_KEYBOARD_FOCUS FALSE PLUGIN_MANUFACTURER_CODE AuHk PLUGIN_CODE AfSn - FORMATS AU VST3 + FORMATS ${PLUGIN_FORMATS} PRODUCT_NAME "AceForge-Suno" COPY_PLUGIN_AFTER_BUILD FALSE )