Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ layers and **40 GPU effects**, an **OBS overlay**, **Spout / Syphon**, **project
[![Downloads](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=downloads)](https://github.com/CaYatur/SoundVisualizer/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-e11d2a.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#download)
[![Tests](https://img.shields.io/badge/tests-3154%20passing-2ea043.svg)](#tests)
[![Tests](https://img.shields.io/badge/tests-3165%20passing-2ea043.svg)](#tests)
[![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/)

<a href="https://github.com/CaYatur/SoundVisualizer/releases/latest"><img src="https://img.shields.io/badge/Download-Windows-0a84ff?style=for-the-badge" alt="Download for Windows" /></a>
Expand Down Expand Up @@ -1721,7 +1721,7 @@ npm test
npm start -- --smoke
```

**3154 unit tests, all passing.** They are written to check answers, not to exercise lines:
**3165 unit tests, all passing.** They are written to check answers, not to exercise lines:

- **Formulas** are checked against values derived by hand from their definitions — Viviani's curve
staying on its sphere, the torus tube radius, Chladni's m↔n antisymmetry, every attractor staying
Expand Down
4 changes: 2 additions & 2 deletions README.tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ destesi**, ve **MCP üzerinden yapay zekâ kontrolü**.
[![İndirme](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=indirme)](https://github.com/CaYatur/SoundVisualizer/releases)
[![Lisans: MIT](https://img.shields.io/badge/Lisans-MIT-e11d2a.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#hızlı-başlangıç)
[![Test](https://img.shields.io/badge/test-3154%20geçiyor-2ea043.svg)](#testler)
[![Test](https://img.shields.io/badge/test-3165%20geçiyor-2ea043.svg)](#testler)
[![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/)

<a href="https://github.com/CaYatur/SoundVisualizer/releases/latest"><img src="https://img.shields.io/badge/%C4%B0ndir-Windows-0a84ff?style=for-the-badge" alt="Windows için indir" /></a>
Expand Down Expand Up @@ -1724,7 +1724,7 @@ npm test
npm start -- --smoke
```

**3154 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar:
**3165 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar:

- **Formüller**, tanımlarından elle türetilmiş değerlerle sınanıyor — Viviani eğrisinin küre üzerinde
kalması, simidin boru yarıçapı, Chladni'nin m↔n antisimetrisi, her çekicinin sınırlı kalması ve görüş
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ npm test
npm start -- --smoke
```

- **3154 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0;
- **3165 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0;
105 came with v3.1.1; 163 came with v3.1.2; 157 came with v3.1.3 — 1128 at
that tag — 469 more with v3.1.4, most of them from the MilkDrop work — 1597 at
that tag — 1402 more with v3.1.5 — 2999 at that tag — and 27
Expand Down
23 changes: 21 additions & 2 deletions src/admin/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@
host = el('div', { class: 'toast hidden', id: 'toast' });
document.body.appendChild(host);
}
host.textContent = message;
// Messages from the main process and modules are Turkish source text
host.textContent = window.SVI18n && window.SVI18n.t ? window.SVI18n.t(String(message)) : message;
host.className = 'toast' + (kind ? ' ' + kind : '');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => host.classList.add('hidden'), 4200);
Expand Down Expand Up @@ -1633,6 +1634,19 @@
tarafı da uygulama açılınca kendiliğinden bağlanıyor. */
for (const s of selectedApps) {
if (shown.has(AA.baseName(s.match))) continue;
/* A plain key saved before Linux split apps by name ("pacat")
still captures every app of that binary: show it as running. */
const live = AA.sameApp ? apps.filter((a) => AA.sameApp(a.match, AA.baseName(s.match))) : [];
if (live.length) {
appRows.push(makeRow(
{ kind: 'app', match: s.match, label: s.label },
live.some((a) => a.audible) ? 'music' : 'mute',
s.label || s.match,
live.length > 1 ? live.length + ' süreç' : '',
appMeter(s.match)
));
continue;
}
appRows.push(makeRow(
{ kind: 'app', match: s.match, label: s.label },
'pause', s.label || s.match, 'çalışmıyor'
Expand Down Expand Up @@ -1807,7 +1821,7 @@
});
const list = el('div', { class: 'export-queue', id: 'exportQueueList' });
const box = el('div', { class: 'ctrl' }, [
el('label', { class: 'lbl', text: 'Kuyruk' }),
el('label', { class: 'lbl', text: 'Dışa Aktarma Kuyruğu' }),
el('div', { class: 'studio-note dim-hint', text: 'Her iş eklendiği andaki sahneyi ve ayarları kullanır; biri hata verse de sonraki çalışır. Bir sahneyi birden çok şarkıya ya da bir şarkıyı birden çok çözünürlüğe aynı klasöre çıkarmak için.' }),
el('div', { class: 'row', style: 'flex-wrap:wrap;gap:6px' }, [add, addMany, run, clear]),
list,
Expand Down Expand Up @@ -6182,6 +6196,11 @@
/* render() önizlemeyi katman paneli cfg'yi düzeltmeden önce kuruyor.
Panel açılıp kapanmadan çıkış ve önizleme aynı cfg'yi görsün. */
if (window.SVPreview) window.SVPreview.setConfig(cfg);
/* Timeline-follow recording turned on/off by MCP or another client
must take effect now, not only when the Output card renders. */
if (window.SVRecordPanel && window.SVRecordPanel.ensureFollow) {
window.SVRecordPanel.ensureFollow(!!(cfg.recording && cfg.recording.followTimeline));
}
});

// Aynı ayar klasörünü kullanan başka kopyalar ve ayar çakışması (#564)
Expand Down
89 changes: 63 additions & 26 deletions src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -526,14 +526,41 @@ if (process.platform === 'win32') {
let forceQuitApp = false;
let isConfirmingClose = false;

/* SIGTERM/SIGINT (systemd, kill, Ctrl+C) must not wait on the confirm-close
prompt or a running export: on a headless session nobody answers it.
The force flag is set BEFORE any window closes so the admin window's
'close' handler and 'before-quit' skip the prompt; the fallback exit
does not depend on dialogs or the quit sequence finishing. */
let signalQuit = false;
function quitOnSignal() {
if (signalQuit) { process.exit(0); return; } // second signal: no more waiting
signalQuit = true;
forceQuitApp = true;
try { exportQueue.stopping = true; } catch { /* not ready yet */ }
try { if (exportState) exportState.cancel = true; } catch { /* not ready yet */ }
setTimeout(() => { try { app.exit(0); } finally { process.exit(0); } }, 5000);
try { app.quit(); } catch { app.exit(0); }
}
/* Chromium installs its own SIGTERM handler during startup, which closes
windows without telling us. Re-registering after it (on ready and after
the first window) puts Node's handler back in charge. */
function installSignalHandlers() {
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
process.removeListener(sig, quitOnSignal);
process.on(sig, quitOnSignal);
}
}
installSignalHandlers();
app.whenReady().then(() => { installSignalHandlers(); setTimeout(installSignalHandlers, 1000); });

ipcMain.on('confirm-close-approved', () => {
forceQuitApp = true;
if (adminWin && !adminWin.isDestroyed()) adminWin.close();
else app.quit();
});

async function confirmCloseIfNeeded(parentWin) {
if (forceQuitApp || SMOKE) return true;
if (forceQuitApp || signalQuit || SMOKE) return true;
const cfg = currentConfig || loadSettings();
const confirmClose = !!(cfg && cfg.power && cfg.power.confirmClose);
if (!confirmClose || !anyVisualizerOpen()) return true;
Expand Down Expand Up @@ -608,7 +635,7 @@ function createAdminWindow() {
}

adminWin.on('close', (e) => {
if (forceQuitApp || SMOKE) return;
if (forceQuitApp || signalQuit || SMOKE) return;
const cfg = currentConfig || loadSettings();
if (cfg?.power?.confirmClose && anyVisualizerOpen()) {
e.preventDefault();
Expand Down Expand Up @@ -4620,21 +4647,21 @@ async function startExportJob(opts, bench, extra) {
/* Sahne yapılandırması: kuyruk işi eklendiği anın kopyasını taşıyor
(#526); sonradan yapılan değişiklik sıradaki işleri değiştirmesin. */
const jobCfg = (extra && extra.cfg) || currentConfig;
if (exportState) return { ok: false, error: 'Zaten bir dışa aktarma sürüyor.' };
if (exportState) return { ok: false, error: trUi('Zaten bir dışa aktarma sürüyor.', 'An export is already running.') };
opts = opts || {};
const audioPath = opts.audioPath;
const outputPath = opts.outputPath;
/* Yollar panelden ya da MCP'den gelir. ffmpeg -y ile üzerine yazar ve
tcp:// ya da pipe: gibi adresleri de çıktı sayar: MCP'nin kuralı burada
da geçerli, yerel, mutlak bir .mp4. */
if (typeof audioPath !== 'string' || !path.isAbsolute(audioPath) || !fs.existsSync(audioPath)) {
return { ok: false, error: 'Ses dosyası bulunamadı.' };
return { ok: false, error: trUi('Ses dosyası bulunamadı.', 'Audio file not found.') };
}
if (!outputPath) return { ok: false, error: 'Çıktı yolu seçilmedi.' };
if (!outputPath) return { ok: false, error: trUi('Çıktı yolu seçilmedi.', 'No output path was chosen.') };
const fmt = exportFormats.format(opts.format || exportFormats.DEFAULT_FORMAT);
if (!fmt) return { ok: false, error: trUi('Bilinmeyen biçim: ', 'Unknown format: ') + opts.format };
const outBad = require('../shared/mcp').badOutPath(outputPath, [fmt.ext]);
if (outBad) return { ok: false, error: 'Çıktı yolu geçersiz: ' + outBad };
if (outBad) return { ok: false, error: trUi('Çıktı yolu geçersiz: ', 'Invalid output path: ') + outBad };

const res = exportFormats.RESOLUTIONS[opts.resolution] || exportFormats.RESOLUTIONS['1080p'];
const w = res[0];
Expand Down Expand Up @@ -4683,7 +4710,7 @@ async function startExportJob(opts, bench, extra) {
try {
audioBuf = fs.readFileSync(audioPath);
} catch (err) {
return { ok: false, error: 'Ses dosyası okunamadı: ' + err.message };
return { ok: false, error: trUi('Ses dosyası okunamadı: ', 'Could not read the audio file: ') + err.message };
}

let track = {
Expand Down Expand Up @@ -4782,7 +4809,7 @@ async function startExportJob(opts, bench, extra) {
try {
ffmpegProc = spawn(ff, args, { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] });
} catch (err) {
return { ok: false, error: 'ffmpeg başlatılamadı: ' + err.message };
return { ok: false, error: trUi('ffmpeg başlatılamadı: ', 'Could not start ffmpeg: ') + err.message };
}

exportState = {
Expand All @@ -4802,23 +4829,29 @@ async function startExportJob(opts, bench, extra) {
phase: 'start', encoder,
encoderLabel: exportFormats.ENCODERS[encoderId] ? exportFormats.ENCODERS[encoderId].label : '',
});
ffmpegProc.stderr.on('data', (d) => {
if (!exportState) return;
/* Every handler below is bound to THIS job. After a cancel the old
ffmpeg's late 'exit'/'close' used to finalize the NEXT export (it
checked the current exportState), destroying its render window. */
const job = exportState;
const proc = ffmpegProc;
const isMine = () => exportState === job;
proc.stderr.on('data', (d) => {
if (!isMine()) return;
exportState.ffErr += d.toString();
if (exportState.ffErr.length > 8000) exportState.ffErr = exportState.ffErr.slice(-8000);
});
ffmpegProc.stdin.on('error', () => {}); // iptal/kapanışta EPIPE'i yut
ffmpegProc.on('error', (err) => finalizeExport('error', 'ffmpeg hatası: ' + err.message));
proc.stdin.on('error', () => {}); // iptal/kapanışta EPIPE'i yut
proc.on('error', (err) => { if (isMine()) finalizeExport('error', trUi('ffmpeg hatası: ', 'ffmpeg error: ') + err.message); });
const onFfClosed = (code) => {
// Süre dolunca değil, süreç gerçekten kapanınca. null kod ikinci
// olayın sayısal sonucunu bekler; erken 'tamam' arayüzü yanıltıyordu.
if (!exportState || code == null) return;
if (!isMine() || code == null) return;
if (code === 0) finalizeExport('done');
else if (exportState.cancel) finalizeExport('cancelled');
else finalizeExport('error', 'ffmpeg çıkış kodu ' + code + '\n' + (exportState.ffErr || '').slice(-1200));
else finalizeExport('error', trUi('ffmpeg çıkış kodu ', 'ffmpeg exit code ') + code + '\n' + (exportState.ffErr || '').slice(-1200));
};
ffmpegProc.on('exit', onFfClosed);
ffmpegProc.on('close', onFfClosed);
proc.on('exit', onFfClosed);
proc.on('close', onFfClosed);

// Gizli render penceresi (offscreen kanvas; ekrana çizilmez)
exportWin = new BrowserWindow({
Expand All @@ -4833,23 +4866,27 @@ async function startExportJob(opts, bench, extra) {
backgroundThrottling: false,
},
});
attachSmoke(exportWin, 'EXPORT');
exportWin.on('closed', () => { exportWin = null; });
exportWin.webContents.on('render-process-gone', () => {
if (!exportState) return;
const win = exportWin;
attachSmoke(win, 'EXPORT');
// Only clear the global if it still points at this job's window
win.on('closed', () => { if (exportWin === win) exportWin = null; });
win.webContents.on('render-process-gone', () => {
if (!isMine()) return;
if (exportState.finishAt) {
endExportStdin();
return;
}
finalizeExport('error', 'Görüntü süreci kapandı. Dışa aktarma durduruldu.', { keep: true });
finalizeExport('error', trUi('Görüntü süreci kapandı. Dışa aktarma durduruldu.', 'The render process exited. Export stopped.'), { keep: true });
});

try {
await exportWin.loadFile(path.join(__dirname, '..', 'exporter', 'index.html'));
await win.loadFile(path.join(__dirname, '..', 'exporter', 'index.html'));
} catch (err) {
finalizeExport('error', 'Render penceresi yüklenemedi: ' + err.message);
return { ok: false, error: 'Render penceresi yüklenemedi.' };
if (isMine()) finalizeExport('error', trUi('Render penceresi yüklenemedi: ', 'Could not load the render window: ') + err.message);
return { ok: false, error: trUi('Render penceresi yüklenemedi.', 'Could not load the render window.') };
}
// Cancelled or failed while the window was loading: do not send the job
if (!isMine() || win.isDestroyed()) return { ok: false, error: trUi('Dışa aktarma başlamadan durdu.', 'The export stopped before it started.') };

const ab = audioBuf.buffer.slice(audioBuf.byteOffset, audioBuf.byteOffset + audioBuf.byteLength);
exportWin.webContents.send('export:job', {
Expand Down Expand Up @@ -4982,7 +5019,7 @@ ipcMain.handle('export:frame', async (e, data) => {
if (prof) prof.drainMs += performance.now() - td;
if (!exportState || exportState.cancel) return { cancel: true };
if (!drained) {
finalizeExport('error', 'Kodlayıcı borusu kapandı. Dışa aktarma durduruldu.');
finalizeExport('error', trUi('Kodlayıcı borusu kapandı. Dışa aktarma durduruldu.', 'The encoder pipe closed. Export stopped.'));
return { cancel: true };
}
}
Expand Down Expand Up @@ -7698,7 +7735,7 @@ function shutdownCleanup() {
}

app.on('before-quit', (e) => {
if (forceQuitApp || SMOKE) {
if (forceQuitApp || signalQuit || SMOKE) {
shutdownCleanup();
return;
}
Expand Down
30 changes: 27 additions & 3 deletions src/shared/app-audio-linux.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,24 @@
hedef ancak adla yeniden bulunur. İkili adı bildirmeyen istemcilerde
uygulamanın kendi verdiği ad kullanılıyor. */
function streamName(s) {
return (s && (s.binary || s.appName)) || '';
if (!s) return '';
/* Generic hosts (pacat, python3, electron, java) run many different
apps under one binary. Grouping by binary alone merged them into one
row ("ToneB · 2 processes"), so the key carries the app name too
when it differs from the binary: "pacat|ToneA". A plain binary key
saved by an older version still matches (see belongsTo). */
const bin = s.binary || '';
const app = String(s.appName || '').replace(/[\\/|]/g, '-');
if (!bin) return app;
if (!app || app.toLowerCase() === baseName(bin)) return bin;
return bin + APP_SEP + app;
}
const APP_SEP = '|';
/* Splits a target key into its binary and optional app name. */
function splitKey(match) {
const m = String(match || '');
const i = m.indexOf(APP_SEP);
return i < 0 ? { bin: baseName(m), app: '' } : { bin: baseName(m.slice(0, i)), app: m.slice(i + 1).toLowerCase() };
}

/* Ortak süreç biçimi — Windows yardımcısının --list çıktısıyla aynı:
Expand Down Expand Up @@ -172,7 +189,14 @@
içinden bildiriyor. Ad verilmemişse kimlik. */
function belongsTo(stream, target) {
if (!stream || !target) return false;
const want = baseName(target.match || target.name);
const raw = target.match || target.name;
const want = baseName(raw);
if (want && String(raw).indexOf(APP_SEP) >= 0) {
// Binary + app name key: only that app's streams, not the whole host
const k = splitKey(raw);
return baseName(stream.binary) === k.bin
&& String(stream.appName || '').replace(/[\\/|]/g, '-').toLowerCase() === k.app;
}
if (want) {
return baseName(stream.binary) === want || (!stream.binary && baseName(stream.appName) === want);
}
Expand Down Expand Up @@ -299,7 +323,7 @@
const api = {
SAMPLE_RATE, CHANNELS, BYTES_PER_FRAME, C_LOCALE_ENV,
parseSinkInputsJson, parseSinkInputsText, parseSinkInputs,
streamName, toProcesses, belongsTo, selectStreams, parecArgs,
streamName, splitKey, toProcesses, belongsTo, selectStreams, parecArgs,
parseSubscribeLine, PcmMixer,
};

Expand Down
Loading
Loading