Add local Suno companion service
This commit is contained in:
@@ -52,6 +52,14 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
|
|||||||
2. `처리용 manifest 저장`을 눌러 Downloads 폴더에 `suno-library-YYYY-MM-DD.manifest.json`을 저장합니다. 이 단계에서 브라우저가 SUNO의 `convert_wav/`와 `wav_file/` API를 호출해 WAV URL을 준비합니다.
|
2. `처리용 manifest 저장`을 눌러 Downloads 폴더에 `suno-library-YYYY-MM-DD.manifest.json`을 저장합니다. 이 단계에서 브라우저가 SUNO의 `convert_wav/`와 `wav_file/` API를 호출해 WAV URL을 준비합니다.
|
||||||
3. `run_process_suno_library.ps1`을 실행합니다.
|
3. `run_process_suno_library.ps1`을 실행합니다.
|
||||||
|
|
||||||
|
더 편한 흐름:
|
||||||
|
|
||||||
|
1. 먼저 `run_suno_companion.ps1`을 실행해서 로컬 수신 프로그램을 켜둡니다.
|
||||||
|
2. SUNO 라이브러리에서 `라이브러리 곡 감지`를 누릅니다.
|
||||||
|
3. `로컬 프로그램으로 처리`를 누릅니다.
|
||||||
|
|
||||||
|
이 경우 Tampermonkey가 manifest를 `http://127.0.0.1:17873/process`로 보내고, 로컬 프로그램이 자동으로 다운로드/FLAC 변환/정리를 시작합니다.
|
||||||
|
|
||||||
기본 출력 위치:
|
기본 출력 위치:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -59,3 +67,5 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
|
|||||||
```
|
```
|
||||||
|
|
||||||
로컬 처리기는 manifest에 저장된 WAV URL을 다운로드하고, 실패하면 manifest의 audio URL로 fallback합니다. 이후 ffmpeg로 FLAC 변환하면서 title, artist, album, genre/style, lyrics, Suno clip id, cover art를 메타데이터로 넣습니다.
|
로컬 처리기는 manifest에 저장된 WAV URL을 다운로드하고, 실패하면 manifest의 audio URL로 fallback합니다. 이후 ffmpeg로 FLAC 변환하면서 title, artist, album, genre/style, lyrics, Suno clip id, cover art를 메타데이터로 넣습니다.
|
||||||
|
|
||||||
|
로그는 `companion-logs` 폴더에 저장됩니다.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name ChatGPT to Suno Prompt Copier
|
// @name ChatGPT to Suno Prompt Copier
|
||||||
// @namespace local.suno-helper
|
// @namespace local.suno-helper
|
||||||
// @version 0.4.3
|
// @version 0.5.0
|
||||||
// @description Copy structured ChatGPT song prompt versions into Suno fields.
|
// @description Copy structured ChatGPT song prompt versions into Suno fields.
|
||||||
// @author local
|
// @author local
|
||||||
// @match https://chatgpt.com/*
|
// @match https://chatgpt.com/*
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
// @connect cdn*.suno.ai
|
// @connect cdn*.suno.ai
|
||||||
// @connect *.suno.com
|
// @connect *.suno.com
|
||||||
// @connect studio-api-prod.suno.com
|
// @connect studio-api-prod.suno.com
|
||||||
|
// @connect 127.0.0.1
|
||||||
|
// @connect localhost
|
||||||
// @run-at document-start
|
// @run-at document-start
|
||||||
// @grant GM_addStyle
|
// @grant GM_addStyle
|
||||||
// @grant GM_getValue
|
// @grant GM_getValue
|
||||||
@@ -953,6 +955,37 @@
|
|||||||
setStatus(statusTarget, `${preparedTracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`);
|
setStatus(statusTarget, `${preparedTracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function postToCompanion(tracks, statusTarget) {
|
||||||
|
if (!tracks.length) {
|
||||||
|
setStatus(statusTarget, "보낼 곡을 못 찾았어. 라이브러리 곡 감지를 먼저 해줘.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const preparedTracks = [];
|
||||||
|
for (let index = 0; index < tracks.length; index += 1) {
|
||||||
|
const track = { ...tracks[index] };
|
||||||
|
setStatus(statusTarget, `${index + 1}/${tracks.length} WAV 준비 중: ${track.title || track.id}`);
|
||||||
|
track.wavUrl = await requestSunoWavUrl(track);
|
||||||
|
preparedTracks.push(track);
|
||||||
|
}
|
||||||
|
const manifest = {
|
||||||
|
pageUrl: location.href,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
count: preparedTracks.length,
|
||||||
|
tracks: preparedTracks,
|
||||||
|
};
|
||||||
|
const response = await fetch("http://127.0.0.1:17873/process", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(manifest),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(text || `${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = tryParseJson(text) || {};
|
||||||
|
setStatus(statusTarget, `로컬 처리 시작: ${data.jobId || "job"} / ${data.log || "companion console 확인"}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function bootSuno() {
|
async function bootSuno() {
|
||||||
const body = createPanel("Suno Prompt Paste");
|
const body = createPanel("Suno Prompt Paste");
|
||||||
const slots = await readSlots();
|
const slots = await readSlots();
|
||||||
@@ -979,6 +1012,9 @@
|
|||||||
<div class="cgs-row">
|
<div class="cgs-row">
|
||||||
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-export-library>처리용 manifest 저장</button>
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-export-library>처리용 manifest 저장</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cgs-row">
|
||||||
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-send-companion>로컬 프로그램으로 처리</button>
|
||||||
|
</div>
|
||||||
<div class="cgs-row">
|
<div class="cgs-row">
|
||||||
<button class="cgs-btn cgs-wide" type="button" data-cgs-download-library>브라우저 일괄 다운로드</button>
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-download-library>브라우저 일괄 다운로드</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1063,6 +1099,35 @@
|
|||||||
updateSummary();
|
updateSummary();
|
||||||
await exportSunoManifest(libraryTracks, body);
|
await exportSunoManifest(libraryTracks, body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
body.querySelector("[data-cgs-send-companion]").addEventListener("click", async () => {
|
||||||
|
if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks();
|
||||||
|
updateSummary();
|
||||||
|
try {
|
||||||
|
await postToCompanion(libraryTracks, body);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(body, `로컬 프로그램 연결 실패: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeBootSuno() {
|
||||||
|
try {
|
||||||
|
bootSuno();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ChatGPT to Suno]", error);
|
||||||
|
createErrorPanel(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeBootChatGPT() {
|
||||||
|
try {
|
||||||
|
const versions = parseVersions(getConversationText());
|
||||||
|
if (versions.length) bootChatGPT();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ChatGPT to Suno]", error);
|
||||||
|
createErrorPanel(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startApp() {
|
function startApp() {
|
||||||
@@ -1071,23 +1136,18 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (isSuno) safeBootSuno();
|
||||||
if (isChatGPT) bootChatGPT();
|
if (isChatGPT) safeBootChatGPT();
|
||||||
if (isSuno) bootSuno();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[ChatGPT to Suno]", error);
|
|
||||||
createErrorPanel(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!document.querySelector("#cgs-panel")) {
|
if (isSuno && !document.querySelector("#cgs-panel")) {
|
||||||
try {
|
safeBootSuno();
|
||||||
if (isChatGPT) bootChatGPT();
|
|
||||||
if (isSuno) bootSuno();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[ChatGPT to Suno]", error);
|
|
||||||
createErrorPanel(error);
|
|
||||||
}
|
}
|
||||||
|
if (isChatGPT) {
|
||||||
|
const hasPanel = Boolean(document.querySelector("#cgs-panel"));
|
||||||
|
const hasVersions = parseVersions(getConversationText()).length > 0;
|
||||||
|
if (hasVersions && !hasPanel) safeBootChatGPT();
|
||||||
|
if (!hasVersions && hasPanel) document.querySelector("#cgs-panel")?.remove();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
& "$PSScriptRoot\suno-companion.ps1" `
|
||||||
|
-Port 17873 `
|
||||||
|
-OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨\SUNO"
|
||||||
|
|
||||||
|
Read-Host "Enter를 누르면 닫힘"
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
param(
|
||||||
|
[int]$Port = 17873,
|
||||||
|
[string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨\SUNO",
|
||||||
|
[string]$FfmpegPath = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$processor = Join-Path $scriptRoot "process-suno-library.ps1"
|
||||||
|
$inbox = Join-Path $scriptRoot "companion-inbox"
|
||||||
|
$logs = Join-Path $scriptRoot "companion-logs"
|
||||||
|
New-Item -ItemType Directory -Force -Path $inbox, $logs | Out-Null
|
||||||
|
|
||||||
|
function Write-Response {
|
||||||
|
param(
|
||||||
|
[System.Net.Sockets.NetworkStream]$Stream,
|
||||||
|
[int]$StatusCode,
|
||||||
|
[string]$StatusText,
|
||||||
|
[string]$Body,
|
||||||
|
[string]$ContentType = "application/json; charset=utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body)
|
||||||
|
$header = @(
|
||||||
|
"HTTP/1.1 $StatusCode $StatusText",
|
||||||
|
"Content-Type: $ContentType",
|
||||||
|
"Content-Length: $($bodyBytes.Length)",
|
||||||
|
"Access-Control-Allow-Origin: *",
|
||||||
|
"Access-Control-Allow-Methods: GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers: content-type",
|
||||||
|
"Connection: close",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
) -join "`r`n"
|
||||||
|
$headerBytes = [Text.Encoding]::ASCII.GetBytes($header)
|
||||||
|
$Stream.Write($headerBytes, 0, $headerBytes.Length)
|
||||||
|
$Stream.Write($bodyBytes, 0, $bodyBytes.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-HttpRequest {
|
||||||
|
param([System.Net.Sockets.NetworkStream]$Stream)
|
||||||
|
|
||||||
|
$buffer = New-Object byte[] 65536
|
||||||
|
$received = New-Object System.Collections.Generic.List[byte]
|
||||||
|
$headerEnd = -1
|
||||||
|
|
||||||
|
while ($headerEnd -lt 0) {
|
||||||
|
$read = $Stream.Read($buffer, 0, $buffer.Length)
|
||||||
|
if ($read -le 0) { break }
|
||||||
|
for ($i = 0; $i -lt $read; $i++) { $received.Add($buffer[$i]) }
|
||||||
|
$text = [Text.Encoding]::ASCII.GetString($received.ToArray())
|
||||||
|
$headerEnd = $text.IndexOf("`r`n`r`n", [StringComparison]::Ordinal)
|
||||||
|
if ($received.Count -gt 1048576) { throw "HTTP header too large" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($headerEnd -lt 0) { throw "Invalid HTTP request" }
|
||||||
|
|
||||||
|
$allBytes = $received.ToArray()
|
||||||
|
$headerText = [Text.Encoding]::ASCII.GetString($allBytes, 0, $headerEnd)
|
||||||
|
$lines = $headerText -split "`r`n"
|
||||||
|
$requestLine = $lines[0] -split " "
|
||||||
|
$headers = @{}
|
||||||
|
foreach ($line in $lines | Select-Object -Skip 1) {
|
||||||
|
$idx = $line.IndexOf(":")
|
||||||
|
if ($idx -gt 0) {
|
||||||
|
$headers[$line.Substring(0, $idx).Trim().ToLowerInvariant()] = $line.Substring($idx + 1).Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentLength = 0
|
||||||
|
if ($headers.ContainsKey("content-length")) {
|
||||||
|
$contentLength = [int]$headers["content-length"]
|
||||||
|
}
|
||||||
|
|
||||||
|
$bodyStart = $headerEnd + 4
|
||||||
|
$bodyBytes = New-Object System.Collections.Generic.List[byte]
|
||||||
|
for ($i = $bodyStart; $i -lt $allBytes.Length; $i++) { $bodyBytes.Add($allBytes[$i]) }
|
||||||
|
|
||||||
|
while ($bodyBytes.Count -lt $contentLength) {
|
||||||
|
$read = $Stream.Read($buffer, 0, [Math]::Min($buffer.Length, $contentLength - $bodyBytes.Count))
|
||||||
|
if ($read -le 0) { break }
|
||||||
|
for ($i = 0; $i -lt $read; $i++) { $bodyBytes.Add($buffer[$i]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Method = $requestLine[0]
|
||||||
|
Path = $requestLine[1]
|
||||||
|
Headers = $headers
|
||||||
|
Body = [Text.Encoding]::UTF8.GetString($bodyBytes.ToArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-SunoProcess {
|
||||||
|
param([string]$ManifestPath)
|
||||||
|
|
||||||
|
$jobId = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$stdout = Join-Path $logs "$jobId.out.log"
|
||||||
|
$stderr = Join-Path $logs "$jobId.err.log"
|
||||||
|
$args = @(
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $processor,
|
||||||
|
"-ManifestPath", $ManifestPath,
|
||||||
|
"-OutputRoot", $OutputRoot
|
||||||
|
)
|
||||||
|
if ($FfmpegPath) {
|
||||||
|
$args += @("-FfmpegPath", $FfmpegPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = Start-Process -FilePath "powershell.exe" -ArgumentList $args -PassThru -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
||||||
|
return [pscustomobject]@{
|
||||||
|
jobId = $jobId
|
||||||
|
pid = $process.Id
|
||||||
|
log = $stdout
|
||||||
|
errorLog = $stderr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $processor)) {
|
||||||
|
throw "processor not found: $processor"
|
||||||
|
}
|
||||||
|
|
||||||
|
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), $Port)
|
||||||
|
$listener.Start()
|
||||||
|
|
||||||
|
Write-Host "SUNO companion listening on http://127.0.0.1:$Port"
|
||||||
|
Write-Host "OutputRoot: $OutputRoot"
|
||||||
|
Write-Host "Logs: $logs"
|
||||||
|
Write-Host "Press Ctrl+C to stop."
|
||||||
|
|
||||||
|
try {
|
||||||
|
while ($true) {
|
||||||
|
$client = $listener.AcceptTcpClient()
|
||||||
|
try {
|
||||||
|
$stream = $client.GetStream()
|
||||||
|
$request = Read-HttpRequest $stream
|
||||||
|
|
||||||
|
if ($request.Method -eq "OPTIONS") {
|
||||||
|
Write-Response $stream 204 "No Content" ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request.Method -eq "GET" -and $request.Path -eq "/health") {
|
||||||
|
Write-Response $stream 200 "OK" (@{ ok = $true; outputRoot = $OutputRoot } | ConvertTo-Json -Compress)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request.Method -eq "POST" -and $request.Path -eq "/process") {
|
||||||
|
$manifest = $request.Body | ConvertFrom-Json
|
||||||
|
if (-not $manifest.tracks -or @($manifest.tracks).Count -eq 0) {
|
||||||
|
Write-Response $stream 400 "Bad Request" (@{ ok = $false; error = "tracks is empty" } | ConvertTo-Json -Compress)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifestPath = Join-Path $inbox ("suno-library-{0}.manifest.json" -f (Get-Date -Format "yyyyMMdd-HHmmss"))
|
||||||
|
$request.Body | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||||
|
$job = Start-SunoProcess $manifestPath
|
||||||
|
|
||||||
|
Write-Host "Started job $($job.jobId) for $(@($manifest.tracks).Count) tracks"
|
||||||
|
Write-Response $stream 200 "OK" (@{
|
||||||
|
ok = $true
|
||||||
|
jobId = $job.jobId
|
||||||
|
pid = $job.pid
|
||||||
|
log = $job.log
|
||||||
|
errorLog = $job.errorLog
|
||||||
|
} | ConvertTo-Json -Compress)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Response $stream 404 "Not Found" (@{ ok = $false; error = "not found" } | ConvertTo-Json -Compress)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
Write-Response $stream 500 "Internal Server Error" (@{ ok = $false; error = "$($_.Exception.Message)" } | ConvertTo-Json -Compress)
|
||||||
|
} catch {}
|
||||||
|
Write-Warning $_.Exception.Message
|
||||||
|
} finally {
|
||||||
|
$client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$listener.Stop()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user