From b6b77c8169301b79921e0e1396a5f8e2bb1aa47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Fri, 16 Oct 2026 04:04:53 +0300 Subject: [PATCH 1/2] export: realtime recording can follow the timeline; the two modes are named apart (#530) Co-authored-by: caganturgut <271973834+caganturgut@users.noreply.github.com> --- src/admin/admin.js | 6 ++- src/admin/record-panel.js | 79 ++++++++++++++++++++++++++++++++++++- src/shared/defaults.js | 2 + src/shared/i18n.js | 4 ++ tests/record-follow.test.js | 61 ++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 tests/record-follow.test.js diff --git a/src/admin/admin.js b/src/admin/admin.js index 7e9a0d8f..209953cb 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -2946,7 +2946,7 @@ category: 'output', icon: 'record', title: 'Kayıt ve Anlık Görüntü', - desc: 'Ekranda göründüğü gibi kaydedin: canlı sesle, modülasyon, geçiş ve efektler dahil. MP4, WebM, GIF ve PNG.', + desc: 'Gerçek zamanlı kip: ekranda göründüğü gibi, canlı sesle kaydedin; modülasyon, geçiş ve efektler dahil. Zaman çizelgesini izleyebilir. MP4, WebM, GIF ve PNG.', controls: [{ type: 'recordpanel' }], }, { @@ -3510,7 +3510,7 @@ icon: 'film', wide: true, title: 'Video Dışa Aktar (MP3 → Video)', - desc: 'Bir ses dosyası seçin; mevcut sahne ayarlarıyla kayıpsız videoya dönüştürülür. Ekran kaydı değildir — her kare birebir render edilir.', + desc: 'Çevrimdışı kip: bir ses dosyası seçin, sahne kare kare ve olabildiğince hızlı çizilir. Ekran kaydı değildir; aynı iş her seferinde aynı videoyu verir.', controls: [ { type: 'audiofile' }, { @@ -6032,6 +6032,8 @@ } // GPU (NVENC) kodlayıcı var mı? Yoksa CPU'ya zorla. + // Çalma kafasını izleyen kayıt (#530) kart açılmadan da çalışsın + if (window.SVRecordPanel && cfg.recording && cfg.recording.followTimeline) window.SVRecordPanel.ensureFollow(true); /* Biçim listesi açılışı BEKLETMİYOR: kodlayıcı yoklaması (GPU'lar sırayla) birkaç saniye sürebiliyor. Kart gelene kadar yalnız MP4'ü gösterir, liste gelince yeniden çizilir. */ diff --git a/src/admin/record-panel.js b/src/admin/record-panel.js index 0b4df7a5..bf193ad9 100644 --- a/src/admin/record-panel.js +++ b/src/admin/record-panel.js @@ -105,6 +105,76 @@ pumpRaf = 0; } + /* ÇALMA KAFASINI İZLE (#530). + + İki kip ayrı kalıyor: bu kart GERÇEK ZAMANLI kayıt (ekranda olan, o + anki sesle), dışa aktarma kartı ÇEVRİMDIŞI render (ses dosyasından, + kare kare, belirlenimli). Bu seçenekle gerçek zamanlı kayıt zaman + çizelgesini izliyor: çalma başlayınca kayıt başlıyor; duraklatma, + durdurma ya da çizelgenin sonu (döngü kapalıyken) kaydı bitiriyor. + MIDI, OSC ve MCP'den gelen oynat/durdur da aynı taşımayı sürdüğü için + onları da izliyor. + + Karar saf bir fonksiyon. Kayıt yalnız çalmanın BAŞLADIĞI anda açılıyor: + çalarken elle durdurulan kayıt kendiliğinden yeniden başlamasın. + Yalnız izleyicinin açtığı kayıt durmayla bitiyor; elle başlatılana + dokunulmuyor. */ + function followDecision(s) { + if (!s || !s.enabled) return null; + if (!s.recording && !s.busy && s.playing && !s.wasPlaying && !s.atEnd) return 'start'; + if (s.recording && s.byFollow && (!s.playing || s.atEnd)) return 'stop'; + return null; + } + + let followTimer = 0; + let followWasPlaying = false; + let followRec = false; // şu anki kaydı izleyici mi açtı + let followPending = false; // başlatma sürüyor (yüzey bekleniyor, 1,5 sn'ye kadar) + function transportState() { + const T = window.SVTimelinePanel; + const tr = T && typeof T.transport === 'function' ? T.transport() : null; + if (!tr) return { playing: false, atEnd: false }; + let atEnd = false; + try { + const len = window.SVTimeline && tr.tl ? window.SVTimeline.timelineLength(tr.tl) : 0; + const loop = !!(tr.tl && tr.tl.loop && tr.tl.loop.enabled); + atEnd = !loop && len > 0 && tr.time >= len - 0.02; + } catch { atEnd = false; } + return { playing: !!tr.playing, atEnd }; + } + function followTick() { + const panel = P(); + const cfg = panel && panel.cfg ? panel.cfg() : null; + const r = cfg && cfg.recording; + const R = rec; + const st = transportState(); + const decision = followDecision({ + enabled: !!(r && r.followTimeline), + playing: st.playing, + wasPlaying: followWasPlaying, + atEnd: st.atEnd, + recording: !!(R && R.recording), + busy: busy || saving || followPending, + byFollow: followRec, + }); + followWasPlaying = st.playing; + if (decision === 'start') { + followRec = true; + followPending = true; + Promise.resolve(start(cfg)).finally(() => { followPending = false; }); + } else if (decision === 'stop') { + followRec = false; + stop(); + } else if (!followPending && !(R && R.recording)) { + // Kayıt başka yoldan bitti (süre sınırı, elle): izleyicinin değil artık + followRec = false; + } + } + function ensureFollow(on) { + if (on && !followTimer) followTimer = setInterval(followTick, 150); + if (!on && followTimer) { clearInterval(followTimer); followTimer = 0; followWasPlaying = false; } + } + function engine() { if (!rec && window.SVRecorder) rec = new window.SVRecorder.Recorder(); return rec; @@ -218,6 +288,10 @@ nodes.push(el('div', { class: 'studio-note dim-hint', text: 'GIF iki geçişte üretilir: önce sahneye özel renk paleti çıkarılır, sonra o paletle kodlanır. Tek geçişte sonuç gözle görülür biçimde bantlanır.' })); } + nodes.push(SP().miniToggle('Zaman çizelgesini izle (çalınca başla, durunca bitir)', + () => !!r.followTimeline, (v) => { r.followTimeline = !!v; ensureFollow(!!v); }, () => P().apply())); + ensureFollow(!!r.followTimeline); + nodes.push(SP().miniSlider('Anlık Görüntü Ölçeği', () => r.snapshotScale || 1, (v) => { r.snapshotScale = v; }, { min: 1, max: 4, step: 0.5, fmt: (v) => (+v).toFixed(1) + '×', })); @@ -385,5 +459,8 @@ } // Kayıt sürüyor mu: önizleme yüzeyi kayda gidiyor (medya hata yazısı gizlenir) - window.SVRecordPanel = { panel: panel, start: start, stop: stop, isRecording: () => pumpRaf !== 0 }; + window.SVRecordPanel = { + panel: panel, start: start, stop: stop, isRecording: () => pumpRaf !== 0, + followDecision: followDecision, ensureFollow: ensureFollow, + }; })(); diff --git a/src/shared/defaults.js b/src/shared/defaults.js index 3bb6c5b0..a649957c 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -672,6 +672,8 @@ fps: 60, bitrate: 16000000, limit: 0, // saniye; 0 = sınırsız + // Zaman çizelgesi çalınca başla, durunca bitir (#530) + followTimeline: false, gifFps: 15, gifWidth: 640, snapshotScale: 1, diff --git a/src/shared/i18n.js b/src/shared/i18n.js index bb3421cf..2b73a8fd 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -2968,6 +2968,10 @@ /* Dışa aktarma profilleri (#522); export-formats.js etiketleriyle aynı */ 'İşaretçilerden bölümlere ayır': 'Split into chapters at the markers', 'Hazır Profil': 'Preset Profile', + /* İki render kipi (#530) */ + 'Zaman çizelgesini izle (çalınca başla, durunca bitir)': 'Follow the timeline (start on play, stop on stop)', + 'Gerçek zamanlı kip: ekranda göründüğü gibi, canlı sesle kaydedin; modülasyon, geçiş ve efektler dahil. Zaman çizelgesini izleyebilir. MP4, WebM, GIF ve PNG.': 'Realtime mode: record what is on screen with the live audio, including modulation, transitions and effects. It can follow the timeline. MP4, WebM, GIF and PNG.', + 'Çevrimdışı kip: bir ses dosyası seçin, sahne kare kare ve olabildiğince hızlı çizilir. Ekran kaydı değildir; aynı iş her seferinde aynı videoyu verir.': 'Offline mode: choose an audio file and the scene is drawn frame by frame, as fast as possible. It is not a screen recording; the same job gives the same video every time.', /* Dışa aktarma kuyruğu (#526) */ 'Kuyruğa Ekle': 'Add to Queue', 'Birden Çok Ses Ekle': 'Add Several Audio Files', 'Kuyruğu Başlat': 'Start Queue', 'Kuyruğu Durdur': 'Stop Queue', 'Bitenleri Temizle': 'Clear Finished', diff --git a/tests/record-follow.test.js b/tests/record-follow.test.js new file mode 100644 index 00000000..9eb1a0e1 --- /dev/null +++ b/tests/record-follow.test.js @@ -0,0 +1,61 @@ +'use strict'; +/* İKİ KİP: GERÇEK ZAMANLI KAYIT ÇALMA KAFASINI İZLİYOR (#530). + * + * Çevrimdışı render (dışa aktarma kartı) ve gerçek zamanlı kayıt (kayıt + * kartı) ayrı kalıyor. Gerçek zamanlı kayıt istenirse zaman çizelgesini + * izliyor. Panelde ölçüldü: çal -> 2,5 sn sonra kayıt açık; duraklat -> kapalı. + * 2 sn'lik çizelgede (döngü kapalı) kayıt 1. saniyede açık, sondan sonra + * kapalı; taşıma çalmayı sürdürse de. + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8'); + +function loadPanel() { + const saved = global.window; + global.window = {}; + delete require.cache[require.resolve('../src/admin/record-panel.js')]; + require('../src/admin/record-panel.js'); + const api = global.window.SVRecordPanel; + if (saved === undefined) delete global.window; else global.window = saved; + return api; +} + +test('karar: yalnız çalma başlarken açar, yalnız kendi açtığını kapatır', () => { + const d = loadPanel().followDecision; + const base = { enabled: true, playing: false, wasPlaying: false, atEnd: false, recording: false, busy: false, byFollow: false }; + const s = (o) => Object.assign({}, base, o); + assert.strictEqual(d(s({ playing: true })), 'start', 'çalma başladı'); + assert.strictEqual(d(s({ playing: true, wasPlaying: true })), null, 'çalarken elle durdurulan kayıt yeniden açılmaz'); + assert.strictEqual(d(s({ playing: true, busy: true })), null, 'dosya yazılırken açılmaz'); + assert.strictEqual(d(s({ playing: true, atEnd: true })), null, 'sondayken açılmaz'); + assert.strictEqual(d(s({ recording: true, byFollow: true, playing: false })), 'stop', 'durdu'); + assert.strictEqual(d(s({ recording: true, byFollow: true, playing: true, wasPlaying: true, atEnd: true })), 'stop', 'çizelge bitti'); + assert.strictEqual(d(s({ recording: true, byFollow: false, playing: false })), null, 'elle başlatılan kayda dokunmaz'); + assert.strictEqual(d(s({ enabled: false, playing: true })), null, 'kapalıyken hiçbir şey'); + assert.strictEqual(d(null), null); +}); + +test('izleyici: başlatma sürerken bayrak düşmüyor, açılışta da çalışıyor', () => { + const src = read('src/admin/record-panel.js'); + // start() asenkron (yüzey 1,5 sn'ye kadar bekleniyor); bu arada kayıt + // "bitmiş" sayılırsa durmada kapanmazdı + assert.match(src, /busy: busy \|\| saving \|\| followPending,/); + assert.match(src, /Promise\.resolve\(start\(cfg\)\)\.finally\(\(\) => \{ followPending = false; \}\);/); + assert.match(src, /\} else if \(!followPending && !\(R && R\.recording\)\) \{/); + assert.match(read('src/admin/admin.js'), /if \(window\.SVRecordPanel && cfg\.recording && cfg\.recording\.followTimeline\) window\.SVRecordPanel\.ensureFollow\(true\);/); + assert.match(read('src/shared/defaults.js'), /followTimeline: false,/, 'varsayılan kapalı'); +}); + +test('iki kip açıkça adlandırılıyor ve çevriliyor', () => { + const A = read('src/admin/admin.js'); + assert.match(A, /desc: 'Gerçek zamanlı kip: /); + assert.match(A, /desc: 'Çevrimdışı kip: /); + const I = read('src/shared/i18n.js'); + assert.ok(I.includes("'Zaman çizelgesini izle (çalınca başla, durunca bitir)': 'Follow the timeline (start on play, stop on stop)'")); + assert.ok(I.includes("'Çevrimdışı kip: bir ses dosyası seçin")); + assert.ok(I.includes("'Gerçek zamanlı kip: ekranda göründüğü gibi")); +}); From bd3365da0b2f6f5e462622a451ad99fa963a2b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Fri, 16 Oct 2026 04:05:47 +0300 Subject: [PATCH 2/2] docs: two render modes, test count (#530) Co-authored-by: caganturgut <271973834+caganturgut@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ README.md | 6 ++++-- README.tr.md | 6 ++++-- ROADMAP.md | 17 +++++++++++++++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd19f17f..c4ebf888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ GitHub. The full reasoning and the measurements behind each change are in [ROADM CPU choice and the speed balance stay yours. The profile is read from the settings themselves, so changing any of them by hand shows "Custom", and every setting stays available underneath. MCP's `sv_start_export` takes a `profile` too, with explicit arguments taking precedence. +- **Two render modes, named apart [530].** The export card is the offline mode: an audio file + drawn frame by frame as fast as possible, the same video every time. The recording card is the + realtime mode: what is on screen with the live audio. Realtime recording can now follow the + timeline: it starts when playback starts and stops on pause, stop or the end of the timeline, + whether the transport is driven from the panel, MIDI, OSC or MCP. - **A batch queue [526].** Queue the current song with the current settings, or several audio files at once into one folder, and start the queue. Each job keeps the scene and settings from the moment it was added, so you can change the scene and queue the next variation; one song at diff --git a/README.md b/README.md index cc1745d6..35a2f9b9 100644 --- a/README.md +++ b/README.md @@ -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-3151%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-3154%20passing-2ea043.svg)](#tests) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Download for Windows @@ -940,6 +940,8 @@ the fixtures on the left take the colours on the left of the screen. split at the timeline's markers into files named after them, from a single render. A batch queue renders one scene across many songs, or one song at several sizes, and keeps going past a failed job. +- 3.1.6 **Live recording can follow the timeline**: it starts when playback starts and + stops at pause, stop or the end, including when MIDI, OSC or an AI agent drives the transport. - **Live recording** of the output exactly as it appears — with the live audio, modulation, transitions and effects — to MP4 or WebM, on one key. - **GIF export** with two-pass palette generation, because one pass bands visibly. @@ -1719,7 +1721,7 @@ npm test npm start -- --smoke ``` -**3151 unit tests, all passing.** They are written to check answers, not to exercise lines: +**3154 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 diff --git a/README.tr.md b/README.tr.md index 6e2f3ac9..db174ffc 100644 --- a/README.tr.md +++ b/README.tr.md @@ -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-3151%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-3154%20geçiyor-2ea043.svg)](#testler) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Windows için indir @@ -939,6 +939,8 @@ ekranın solundaki renkleri alıyor. gösteri tek render'dan zaman çizelgesinin işaretçilerinde, onların adlarını taşıyan dosyalara bölünebiliyor. Toplu kuyruk bir sahneyi birçok şarkıya ya da bir şarkıyı birkaç boyuta çıkarıyor ve hata veren bir işte durmuyor. +- 3.1.6 **Canlı kayıt zaman çizelgesini izleyebiliyor**: çalma başlayınca başlıyor, + duraklatma, durdurma ya da sonda bitiyor; taşımayı MIDI, OSC ya da bir yapay zekâ ajanı sürse de. - Çıkışın göründüğü gibi **canlı kaydı** — canlı ses, modülasyon, geçişler ve efektlerle — MP4 ya da WebM'e, tek tuşla. - Tek geçiş gözle görülür bantlandığı için iki geçişli palet üretimli **GIF dışa aktarma**. @@ -1722,7 +1724,7 @@ npm test npm start -- --smoke ``` -**3151 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**3154 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üş diff --git a/ROADMAP.md b/ROADMAP.md index fc785c3a..c12070b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,7 +93,7 @@ hands, and a row only moves up once it ships. |---|:--:|---| | Per-app audio capture on macOS and Linux | v3.1.6 | On `main` (#514, #517). Linux verified in WSLg with PulseAudio; macOS written on ScreenCaptureKit and compiled in CI, never run on a Mac. Windows shipped in v3.1.3 | | Live level meter per application | v3.1.6 | Done on `main` (#516): a moving dB-scale bar per application, streamed only while the picker is on screen | -| Comprehensive video export | v3.1.6 | Formats, alpha, frame sequences (#521, #525, #529), about 3× faster export (#531), duration sources, metadata and clean cancel (#523, #524, #528, #532), profiles (#522), chapters (#527), batch queue (#526) done; the two render modes in progress | +| Comprehensive video export | v3.1.6 | Done on `main`: formats, alpha and frame sequences (#521, #525, #529), about 3× faster export (#531), duration sources, metadata and clean cancel (#523, #524, #528, #532), profiles (#522), chapters (#527), batch queue (#526), the two render modes (#530) | | Broadcast layout editor | v3.1.7 | Not started | | NDI | v3.1.8 | Deferred for licence reasons, not difficulty — see "Not done, and why" | | Redundancy / genlock | v3.2.0 | Not started | @@ -131,7 +131,7 @@ npm test npm start -- --smoke ``` -- **3151 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **3154 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 @@ -3352,6 +3352,19 @@ Export is about three times faster for MP4 and WebM, with no change to the pictu - A saved "GPU" preference is no longer rewritten to "CPU" on a machine without one: the export runs on the CPU and the box says so. - The bench-only options, a raw frame dump and a forced RGBA path, are a separate argument that neither the panel nor MCP can set, because the dump writes a file. +### Two render modes (#530) · done + +- **Two modes, two cards.** Offline render is the export card: an audio file drawn frame by frame as fast as possible, deterministic, the regression net. Realtime capture is the recording card: what is on screen, with the live audio. The cards now say which mode they are, in both languages, rather than leaving it to be inferred. +- **Realtime follows the playhead.** With "Follow the timeline" on, recording starts when playback starts. It stops on pause or stop, or at the end of the timeline when the loop is off. MIDI, OSC and MCP drive the same transport, so they start and stop it too. +- **The decision is a pure function.** + - It starts only on the transition into playing, so a recording stopped by hand during playback does not restart itself. + - It stops only a recording it started; a manual one is left alone. + - `start()` waits up to 1.5 s for the preview surface. A pending start keeps the follower's flag, because otherwise the recording would have looked finished and pause would not have stopped it. + - The follower is armed at launch when the option is saved, not only after the recording card has been opened. +- **Measured in the panel:** + - Play gave a recording after 2.5 s, and pause stopped it. + - On a 2 s timeline with the loop off, recording was on at 1 s and off after the end. The transport itself kept playing to 3.7 s. + ### Batch queue (#526) · done - **Where it lives.** The queue is in the main process and runs jobs one after another. Each job carries a copy of the scene configuration taken when it was added, and `startExportJob` renders that copy rather than the live config. You can therefore change the scene and queue the next variation.