Desktop bridge for Soundnode. Describe a sound, build a VIRGINIA/BeatStars mission with XUNIA SOUNDS, or save SoundCloud media you own or are allowed to save.
+
+
+ BEGINNER FLOW
+ YOU DESCRIBE → XUNIA SOUNDS BUILDS THE PLAN → CLAUDE/BEATSTARS DISCOVERY → REVIEW LICENSE → OPTIONAL SOUNDCLOUDOPEN SAVE
+
+
+
Checking SoundCloudOpen…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
The save button does not bypass SoundCloud permissions. Use it only for audio you own, downloads SoundCloud permits, or material you otherwise have permission to save.
+
Ready.
+
+
+
+
diff --git a/doc/XUNIA_SOUNDCLOUDOPEN.md b/doc/XUNIA_SOUNDCLOUDOPEN.md
new file mode 100644
index 00000000..403a1c47
--- /dev/null
+++ b/doc/XUNIA_SOUNDCLOUDOPEN.md
@@ -0,0 +1,79 @@
+# XUNIA SOUNDS + SoundCloudOpen Desktop Bridge
+
+## Beginner version
+
+This fork turns Soundnode into a desktop front end for the creator-owned SoundCloudOpen workflow.
+
+**PLAY / BROWSE IN SOUNDNODE → OPEN XUNIA SOUNDS → DESCRIBE A SOUND → BUILD A VIRGINIA + BEATSTARS DISCOVERY MISSION → REVIEW THE BEAT/LiCENSE → OPTIONALLY SAVE SOUNDCLOUD MEDIA YOU OWN OR ARE ALLOWED TO SAVE**
+
+The original Soundnode player remains intact. The new integration lives beside it.
+
+## What each part does
+
+| Part | Job |
+|---|---|
+| Soundnode | Desktop SoundCloud listening/browsing interface |
+| XUNIA SOUNDS panel | Beginner desktop control surface |
+| `integrations/soundcloudopenBridge.js` | Shell-safe bridge from Electron to the SoundCloudOpen CLI |
+| `xunia-sounds` | Builds the VIRGINIA / 3LM CLAUDE / BeatStars discovery mission |
+| `soundcloudopen` | Saves authorized SoundCloud tracks/playlists with the existing SoundCloudOpen rules |
+| BeatStars MCP | Discovery tool provider used through Claude |
+
+## Install the companion CLI
+
+Soundnode does not embed Python. Install SoundCloudOpen 1.2+ on the same computer:
+
+```bash
+python3 -m pip install git+https://github.com/sonoxo/soundcloudopen.git
+```
+
+Then verify:
+
+```bash
+soundcloudopen --version
+xunia-sounds --version
+```
+
+## Open the desktop panel
+
+Start Soundnode, then use:
+
+**XUNIA SOUNDS → Open XUNIA SOUNDS + SoundCloudOpen**
+
+Keyboard shortcut:
+
+```text
+Cmd/Ctrl + Shift + X
+```
+
+The panel can:
+
+1. check whether both SoundCloudOpen commands are available;
+2. build a structured XUNIA SOUNDS mission;
+3. build only the Claude/BeatStars natural-language prompt;
+4. pass an optional authorized SoundCloud URL into the mission; and
+5. run the existing SoundCloudOpen save command for authorized media.
+
+## Security boundary
+
+The Electron bridge never constructs a shell command string. It calls child processes with an executable plus an argument array and `shell: false`.
+
+SoundCloudOpen remains responsible for its existing URL validation, dependency checks, browser-cookie selection, metadata handling, and yt-dlp/FFmpeg execution.
+
+The desktop panel also validates SoundCloud hostnames before execution and asks for confirmation before starting a save.
+
+## Verification
+
+The integration has a dependency-free Node test lane:
+
+```bash
+npm run check:xunia
+```
+
+CI runs that verification on:
+
+- Windows / Node 18 and 20
+- macOS / Node 18 and 20
+- Ubuntu / Node 18 and 20
+
+This focused lane intentionally does not install the legacy Electron 8 / node-sass dependency tree just to verify the new bridge.
diff --git a/integrations/soundcloudopenBridge.js b/integrations/soundcloudopenBridge.js
new file mode 100644
index 00000000..fc63038d
--- /dev/null
+++ b/integrations/soundcloudopenBridge.js
@@ -0,0 +1,198 @@
+'use strict';
+
+const { spawn } = require('child_process');
+
+const AUDIO_FORMATS = ['mp3', 'm4a', 'opus', 'flac', 'wav', 'aac'];
+const BROWSERS = ['auto', 'chrome', 'chromium', 'edge', 'firefox', 'brave', 'opera', 'safari', 'none'];
+
+function clean(value) {
+ if (value === undefined || value === null) return null;
+ const text = String(value).trim();
+ return text || null;
+}
+
+function validateSoundCloudUrl(value) {
+ const url = clean(value);
+ if (!url) throw new Error('A SoundCloud URL is required.');
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch (_error) {
+ throw new Error('Enter a valid SoundCloud URL.');
+ }
+ const host = parsed.hostname.toLowerCase();
+ if (host !== 'soundcloud.com' && !host.endsWith('.soundcloud.com')) {
+ throw new Error('Only soundcloud.com URLs are accepted.');
+ }
+ return parsed.toString();
+}
+
+function normalizeFormat(value) {
+ const format = clean(value) || 'mp3';
+ if (!AUDIO_FORMATS.includes(format)) {
+ throw new Error(`Unsupported audio format: ${format}`);
+ }
+ return format;
+}
+
+function normalizeBrowser(value) {
+ const browser = clean(value) || 'auto';
+ if (!BROWSERS.includes(browser)) {
+ throw new Error(`Unsupported browser option: ${browser}`);
+ }
+ return browser;
+}
+
+function buildMissionArgs(input) {
+ const options = input || {};
+ const prompt = clean(options.prompt);
+ if (!prompt) throw new Error('Describe the sound you want.');
+
+ const args = [prompt];
+ const soundcloudUrl = clean(options.soundcloudUrl);
+ if (soundcloudUrl) args.push('--soundcloud', validateSoundCloudUrl(soundcloudUrl));
+
+ const format = normalizeFormat(options.format);
+ if (format !== 'mp3') args.push('--format', format);
+
+ if (options.bpm !== undefined && options.bpm !== null && String(options.bpm).trim() !== '') {
+ const bpm = Number(options.bpm);
+ if (!Number.isInteger(bpm) || bpm < 1 || bpm > 400) {
+ throw new Error('BPM must be a whole number between 1 and 400.');
+ }
+ args.push('--bpm', String(bpm));
+ }
+
+ const genre = clean(options.genre);
+ const mood = clean(options.mood);
+ const useCase = clean(options.useCase);
+ if (genre) args.push('--genre', genre);
+ if (mood) args.push('--mood', mood);
+ if (useCase) args.push('--use-case', useCase);
+
+ if (options.count !== undefined && options.count !== null && String(options.count).trim() !== '') {
+ const count = Number(options.count);
+ if (!Number.isInteger(count) || count < 1 || count > 10) {
+ throw new Error('Candidate count must be a whole number between 1 and 10.');
+ }
+ args.push('--count', String(count));
+ }
+
+ if (options.promptOnly) args.push('--prompt-only');
+ return args;
+}
+
+function buildDownloadArgs(input) {
+ const options = input || {};
+ const url = validateSoundCloudUrl(options.url);
+ const args = [url, '--format', normalizeFormat(options.format), '--browser', normalizeBrowser(options.browser)];
+
+ const output = clean(options.output);
+ if (output) args.push('--output', output);
+ if (options.saveJson) args.push('--save-json');
+ if (options.listOnly) args.push('--list');
+ if (options.printCommand) args.push('--print-command');
+ return args;
+}
+
+function runProcess(command, args, options) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ cwd: options && options.cwd ? options.cwd : process.cwd(),
+ env: Object.assign({}, process.env, options && options.env ? options.env : {}),
+ shell: false,
+ windowsHide: true
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout.on('data', chunk => { stdout += chunk.toString(); });
+ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
+ child.on('error', reject);
+ child.on('close', code => {
+ resolve({ code, stdout: stdout.trim(), stderr: stderr.trim(), command, args: args.slice() });
+ });
+ });
+}
+
+function xuniaCandidates() {
+ return [
+ { command: 'xunia-sounds', prefix: [] },
+ { command: 'xuniasounds', prefix: [] },
+ { command: 'python3', prefix: ['-m', 'soundcloudopen.xunia_sounds'] },
+ { command: 'python', prefix: ['-m', 'soundcloudopen.xunia_sounds'] }
+ ];
+}
+
+function soundCloudOpenCandidates() {
+ return [
+ { command: 'soundcloudopen', prefix: [] },
+ { command: 'sco', prefix: [] },
+ { command: 'python3', prefix: ['-m', 'soundcloudopen.cli'] },
+ { command: 'python', prefix: ['-m', 'soundcloudopen.cli'] }
+ ];
+}
+
+async function runCandidates(candidates, args) {
+ const failures = [];
+ for (const candidate of candidates) {
+ try {
+ const result = await runProcess(candidate.command, candidate.prefix.concat(args));
+ if (result.code === 0) return result;
+ failures.push(`${candidate.command}: ${result.stderr || `exit ${result.code}`}`);
+ } catch (error) {
+ failures.push(`${candidate.command}: ${error.message}`);
+ }
+ }
+ const error = new Error('SoundCloudOpen is not ready on this computer. Install SoundCloudOpen 1.2+ and try again.');
+ error.details = failures;
+ throw error;
+}
+
+async function checkAvailability() {
+ const [xunia, downloader] = await Promise.all([
+ runCandidates(xuniaCandidates(), ['--version']).catch(error => ({ error })),
+ runCandidates(soundCloudOpenCandidates(), ['--version']).catch(error => ({ error }))
+ ]);
+
+ return {
+ ready: !xunia.error && !downloader.error,
+ xunia: xunia.error ? { ready: false, error: xunia.error.message } : { ready: true, version: xunia.stdout, command: xunia.command },
+ downloader: downloader.error ? { ready: false, error: downloader.error.message } : { ready: true, version: downloader.stdout, command: downloader.command }
+ };
+}
+
+async function buildMission(input) {
+ const result = await runCandidates(xuniaCandidates(), buildMissionArgs(input));
+ if (input && input.promptOnly) return { type: 'prompt', text: result.stdout, command: result.command };
+
+ let mission;
+ try {
+ mission = JSON.parse(result.stdout);
+ } catch (_error) {
+ throw new Error(`XUNIA SOUNDS returned non-JSON output: ${result.stdout || result.stderr}`);
+ }
+ return { type: 'mission', mission, command: result.command };
+}
+
+async function saveAuthorizedMedia(input) {
+ const result = await runCandidates(soundCloudOpenCandidates(), buildDownloadArgs(input));
+ return {
+ ok: result.code === 0,
+ stdout: result.stdout,
+ stderr: result.stderr,
+ command: result.command
+ };
+}
+
+module.exports = {
+ AUDIO_FORMATS,
+ BROWSERS,
+ validateSoundCloudUrl,
+ buildMissionArgs,
+ buildDownloadArgs,
+ checkAvailability,
+ buildMission,
+ saveAuthorizedMedia
+};
diff --git a/main.js b/main.js
index dfc195ac..0b6e2257 100644
--- a/main.js
+++ b/main.js
@@ -10,6 +10,7 @@ const {
} = require('electron');
const windowStateKeeper = require('electron-window-state');
const configuration = require('./app/public/js/common/configLocation');
+const soundCloudOpenBridge = require('./integrations/soundcloudopenBridge');
// custom constants
const clientId = '342b8a7af638944906dcdb46f9d56d98';
@@ -18,6 +19,7 @@ const SCconnect = `https://soundcloud.com/connect?&client_id=${clientId}&redirec
let mainWindow;
let authenticationWindow;
+let xuniaWindow;
app.on('ready', () => {
checkUserConfig();
@@ -124,6 +126,42 @@ function initMainWindow() {
menuBar();
}
+function openXuniaWindow() {
+ if (xuniaWindow && !xuniaWindow.isDestroyed()) {
+ xuniaWindow.show();
+ xuniaWindow.focus();
+ return;
+ }
+
+ xuniaWindow = new BrowserWindow({
+ width: 980,
+ height: 780,
+ minWidth: 720,
+ minHeight: 600,
+ title: 'XUNIA SOUNDS + SoundCloudOpen',
+ webPreferences: {
+ nodeIntegration: true
+ }
+ });
+
+ xuniaWindow.loadURL(`file://${__dirname}/app/xunia.html`);
+ xuniaWindow.on('closed', () => {
+ xuniaWindow = null;
+ });
+}
+
+ipcMain.handle('xunia:check', async () => {
+ return soundCloudOpenBridge.checkAvailability();
+});
+
+ipcMain.handle('xunia:mission', async (_event, payload) => {
+ return soundCloudOpenBridge.buildMission(payload || {});
+});
+
+ipcMain.handle('xunia:download', async (_event, payload) => {
+ return soundCloudOpenBridge.saveAuthorizedMedia(payload || {});
+});
+
app.on('will-quit', () => {
// Unregister all shortcuts.
globalShortcut.unregisterAll()
@@ -207,6 +245,24 @@ function menuBar() {
role: 'editMenu',
label: 'Soundnode'
},
+ {
+ label: 'XUNIA SOUNDS',
+ submenu: [
+ {
+ label: 'Open XUNIA SOUNDS + SoundCloudOpen',
+ accelerator: 'CmdOrCtrl+Shift+X',
+ click() {
+ openXuniaWindow();
+ }
+ },
+ {
+ label: 'SoundCloudOpen on GitHub',
+ click() {
+ require('electron').shell.openExternal('https://github.com/sonoxo/soundcloudopen')
+ }
+ }
+ ]
+ },
{
role: 'view',
label: 'View',
diff --git a/package.json b/package.json
index 09d077a0..bf718460 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,11 @@
{
"name": "Soundnode",
- "version": "7.0.0",
+ "version": "7.1.0",
"main": "main.js",
- "description": "Soundnode App is the Soundcloud for desktop",
+ "description": "Soundnode desktop with XUNIA SOUNDS and SoundCloudOpen integration",
"repository": {
"type": "git",
- "url": "git://github.com/Soundnode/soundnode-app.git"
+ "url": "git://github.com/sonoxo/soundnode-app-xunia.git"
},
"scripts": {
"prestart": "npm run watch",
@@ -20,9 +20,11 @@
"package:osx": "electron-packager ./ Soundnode --platform=darwin --out ./dist/Soundnode --electron-version 8.0.1 --overwrite --icon ./app/soundnode.ico",
"package:linux": "electron-packager ./ Soundnode --platform=linux --out ./dist/Soundnode --electron-version 8.0.1 --overwrite --icon ./app/soundnode.icns",
"package:win32": "electron-packager ./ Soundnode --platform=win32 --out ./dist/Soundnode --electron-version 8.0.1 --overwrite --icon ./app/soundnode.icns",
- "package:all": "npm run package:osx && npm run package:linux && npm run package:win32"
+ "package:all": "npm run package:osx && npm run package:linux && npm run package:win32",
+ "test:xunia": "node tests/xunia-soundcloudopen.test.js",
+ "check:xunia": "node --check integrations/soundcloudopenBridge.js && node --check app/public/js/xuniaSoundCloudOpen.js && node tests/xunia-soundcloudopen.test.js"
},
- "author": "Michael Lancaster",
+ "author": "Michael Lancaster / XUNIA integration by sonoxo",
"license": "GPL-3.0",
"devDependencies": {
"babel-core": "6.26.3",
diff --git a/tests/xunia-soundcloudopen.test.js b/tests/xunia-soundcloudopen.test.js
new file mode 100644
index 00000000..f0e917af
--- /dev/null
+++ b/tests/xunia-soundcloudopen.test.js
@@ -0,0 +1,67 @@
+'use strict';
+
+const assert = require('assert');
+const bridge = require('../integrations/soundcloudopenBridge');
+
+function testMissionArgs() {
+ const args = bridge.buildMissionArgs({
+ prompt: 'dark melodic trap',
+ genre: 'trap',
+ mood: 'cold',
+ bpm: 90,
+ count: 4,
+ useCase: 'album',
+ format: 'wav',
+ soundcloudUrl: 'https://soundcloud.com/example/owned-track'
+ });
+
+ assert.strictEqual(args[0], 'dark melodic trap');
+ assert.ok(args.includes('--genre'));
+ assert.ok(args.includes('trap'));
+ assert.ok(args.includes('--mood'));
+ assert.ok(args.includes('cold'));
+ assert.ok(args.includes('--bpm'));
+ assert.ok(args.includes('90'));
+ assert.ok(args.includes('--count'));
+ assert.ok(args.includes('4'));
+ assert.ok(args.includes('--use-case'));
+ assert.ok(args.includes('album'));
+ assert.ok(args.includes('--format'));
+ assert.ok(args.includes('wav'));
+ assert.ok(args.includes('--soundcloud'));
+}
+
+function testPromptOnly() {
+ const args = bridge.buildMissionArgs({ prompt: 'warm soul', promptOnly: true });
+ assert.ok(args.includes('--prompt-only'));
+}
+
+function testDownloadArgs() {
+ const args = bridge.buildDownloadArgs({
+ url: 'https://soundcloud.com/example/sets/owned-playlist',
+ format: 'flac',
+ browser: 'none',
+ output: '/tmp/xunia',
+ saveJson: true
+ });
+
+ assert.strictEqual(args[0], 'https://soundcloud.com/example/sets/owned-playlist');
+ assert.deepStrictEqual(args.slice(1, 5), ['--format', 'flac', '--browser', 'none']);
+ assert.ok(args.includes('--output'));
+ assert.ok(args.includes('/tmp/xunia'));
+ assert.ok(args.includes('--save-json'));
+}
+
+function testValidation() {
+ assert.throws(() => bridge.buildMissionArgs({ prompt: '' }), /Describe the sound/);
+ assert.throws(() => bridge.buildMissionArgs({ prompt: 'x', bpm: 0 }), /BPM/);
+ assert.throws(() => bridge.buildMissionArgs({ prompt: 'x', count: 11 }), /Candidate count/);
+ assert.throws(() => bridge.buildDownloadArgs({ url: 'https://example.com/file.mp3' }), /soundcloud.com/);
+ assert.throws(() => bridge.buildDownloadArgs({ url: 'https://soundcloud.com/example/x', format: 'exe' }), /Unsupported audio format/);
+}
+
+testMissionArgs();
+testPromptOnly();
+testDownloadArgs();
+testValidation();
+console.log('XUNIA SoundCloudOpen bridge tests: PASS');