diff --git a/CHANGELOG.md b/CHANGELOG.md index df4fe73..d0aa0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ per Windows (portatile e installabile), Mac e Android, e va online su [GitHub Pa - **Clear** (licenza source-available, non permissiva) si può spegnere dal Centro AI. - **Nota onesta**: i modelli veri non sono stati scaricati né eseguiti; prove con motori finti (`test/unit/funzioni-ai.test.mjs`, `test/unit/motore_ai_server*.py`). +## [1.8.9] — 2026-10-30 · Audio del LIVE giusto, telecomando invisibile nella registrazione + +- **Audio del LIVE**: si registrano le tracce vere del microfono e del computer. Prima passavano dal mixer, e se il suo contesto audio restava in pausa (registrazione partita dal telecomando o dal conto alla rovescia) i file venivano **muti**. +- **Tasti VOCE e COMPUTER**: ognuno spegne solo la sua traccia (prima il tasto del computer poteva spegnere il microfono). +- **Audio del computer vero**: nell'app su Windows lo prende direttamente Windows (quello che esce dalle casse o dalle cuffie): la traccia che dava WebView2 era finta e muta. Nelle Altre opzioni del LIVE scegli **l'uscita** (casse, cuffie…) e la barra COMPUTER mostra il suo livello mentre registri. Finisce in un file WAV accanto al video, sulla prima traccia audio, legato alla ripresa. +- **Premi REC e il telecomando si apre da solo**, e **non finisce nella registrazione**: Windows lo esclude dalla cattura dello schermo mentre è aperto. +- **Anteprima dello zoom fluida**: niente più scatti fra un passo della camera e l'altro. + ## [1.8.8] — 2026-10-30 · Lo zoom si vede mentre registri - **Anteprima dello zoom in diretta**: mentre registri con lo zoom che segue il cursore, un riquadro piccolo nell'anteprima del LIVE e l'anteprima del telecomando mostrano lo zoom che sta facendo, con la stessa camera che poi finisce nel montaggio. diff --git a/package-lock.json b/package-lock.json index 69126b3..824b0ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-video", - "version": "1.8.8", + "version": "1.8.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-video", - "version": "1.8.8", + "version": "1.8.9", "license": "MIT", "dependencies": { "@desert-ant-labs/clear": "3.6.0", diff --git a/package.json b/package.json index 5f15b36..9e8face 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-video", "productName": "DaProd Video", - "version": "1.8.8", + "version": "1.8.9", "description": "DaProd Video: il montaggio video vecchio stile, moderno dentro. Timeline, due monitor, pulsantiera e VU a lancetta.", "author": "DaProd", "license": "MIT", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7fd4cc2..f47a392 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -32,6 +32,28 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -231,6 +253,24 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -424,7 +464,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] @@ -433,6 +475,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfb" version = "0.14.0" @@ -472,6 +523,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "combine" version = "4.6.8" @@ -541,6 +603,49 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -615,8 +720,9 @@ checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "daprod-video" -version = "1.8.8" +version = "1.8.9" dependencies = [ + "cpal", "getrandom 0.3.4", "libc", "serde", @@ -666,6 +772,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "dbus" version = "0.9.12" @@ -866,6 +978,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "embed-resource" version = "3.0.11" @@ -1743,6 +1861,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1870,6 +1997,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.105" @@ -1939,7 +2076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -1968,6 +2105,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.25" @@ -2004,6 +2151,15 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -2036,6 +2192,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2088,6 +2250,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2097,7 +2273,7 @@ dependencies = [ "bitflags 2.13.2", "jni-sys 0.3.1", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -2109,6 +2285,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2124,6 +2309,16 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2139,6 +2334,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2367,6 +2573,29 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3143,6 +3372,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -3201,7 +3436,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk", + "ndk 0.9.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3479,9 +3714,9 @@ dependencies = [ "jni", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "objc2", "objc2-app-kit", "objc2-foundation", @@ -4529,6 +4764,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4572,6 +4817,16 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -4674,6 +4929,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5019,7 +5283,7 @@ dependencies = [ "javascriptcore-rs", "jni", "libc", - "ndk", + "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-core-foundation", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 03c40dc..356d245 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daprod-video" -version = "1.8.8" +version = "1.8.9" description = "DaProd Video: il montaggio vecchio stile, moderno dentro" authors = ["DaProd"] license = "MIT" @@ -32,6 +32,7 @@ tauri-plugin-single-instance = "2" # solo Windows: l'apertura dei .dap/.daprod/.dpv col programma si scrive nel registro dell'utente (come un'installazione "per utente") [target.'cfg(windows)'.dependencies] winreg = "0.55" +cpal = "0.15" windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_System_JobObjects", "Win32_Security", "Win32_Storage_FileSystem"] } # Mac, Linux e Android: lo spazio libero sul disco (statvfs) per il controllo prima dell'uscita nel Finale diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d0bb8de..69ac1cb 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,6 +18,7 @@ "core:window:allow-is-fullscreen", "core:webview:allow-set-webview-zoom", "core:window:allow-set-always-on-top", + "core:window:allow-set-content-protected", "core:window:allow-set-size", "core:window:allow-set-min-size", "core:window:allow-maximize", diff --git a/src-tauri/src/audio_pc.rs b/src-tauri/src/audio_pc.rs new file mode 100644 index 0000000..c5b1bdb --- /dev/null +++ b/src-tauri/src/audio_pc.rs @@ -0,0 +1,298 @@ +//! L'audio del computer per LIVE: quello che esce dalle casse o dalle cuffie, preso da Windows (WASAPI in loopback). +//! WebView2 con lo schermo scelto in automatico consegna una traccia audio finta e muta: qui lo prendiamo noi. +//! Il file provvisorio cresce sul disco (WAV 16 bit); alla fine si tengono solo i pezzi registrati (le pause escono). +use serde::Serialize; +use std::sync::Mutex; + +#[derive(Clone, Serialize)] +pub struct UscitaAudio { + nome: String, + predefinita: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AvviatoAudio { + id: u64, + trascorsi_ms: f64, +} + +#[derive(Default)] +pub struct AudioPc(Mutex>); + +pub struct Sessione { + id: u64, + stop: std::sync::mpsc::Sender<()>, + thread: std::thread::JoinHandle>, +} + +struct Registrato { + path: std::path::PathBuf, + frequenza: u32, + canali: u16, +} + +impl AudioPc { + pub fn chiudi(&self) { + if let Ok(mut s) = self.0.lock() { + if let Some(s) = s.take() { + let _ = s.stop.send(()); + if let Ok(Ok(r)) = s.thread.join() { + let _ = std::fs::remove_file(r.path); + } + } + } + } +} + +/// il picco dell'audio del computer dall'ultima lettura (bit di un f32 positivo: l'ordine dei bit è quello dei numeri) +static LIVELLO: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +fn intestazione_wav(f: &mut impl std::io::Write, frequenza: u32, canali: u16, byte_dati: u32) -> std::io::Result<()> { + let blocco = canali * 2; + f.write_all(b"RIFF")?; + f.write_all(&(36 + byte_dati).to_le_bytes())?; + f.write_all(b"WAVEfmt ")?; + f.write_all(&16u32.to_le_bytes())?; + f.write_all(&1u16.to_le_bytes())?; + f.write_all(&canali.to_le_bytes())?; + f.write_all(&frequenza.to_le_bytes())?; + f.write_all(&(frequenza * blocco as u32).to_le_bytes())?; + f.write_all(&blocco.to_le_bytes())?; + f.write_all(&16u16.to_le_bytes())?; + f.write_all(b"data")?; + f.write_all(&byte_dati.to_le_bytes()) +} + +#[cfg(windows)] +mod win { + use super::*; + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + use std::io::{BufWriter, Seek, SeekFrom, Write}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + pub fn uscite() -> Result, String> { + let host = cpal::default_host(); + let pred = host.default_output_device().and_then(|d| d.name().ok()); + let v = host + .output_devices() + .map_err(|e| e.to_string())? + .filter_map(|d| d.name().ok()) + .map(|nome| UscitaAudio { predefinita: Some(&nome) == pred.as_ref(), nome }) + .collect(); + Ok(v) + } + + pub fn avvia(stato: &AudioPc, nome: Option) -> Result { + let mut attiva = stato.0.lock().map_err(|e| e.to_string())?; + if attiva.is_some() { + return Err("L'audio del computer sta già registrando".into()); + } + static SEQ: AtomicU64 = AtomicU64::new(1); + let id = SEQ.fetch_add(1, Ordering::Relaxed); + let (stop, ricevi) = mpsc::channel::<()>(); + let (pronto, avvio) = mpsc::sync_channel::>(1); + let thread = std::thread::Builder::new() + .name("audio-computer".into()) + .spawn(move || -> Result { + let host = cpal::default_host(); + let dev = match &nome { + Some(n) => host.output_devices().ok().and_then(|mut it| it.find(|d| d.name().ok().as_deref() == Some(n))), + None => None, + } + .or_else(|| host.default_output_device()); + let dev = match dev { + Some(d) => d, + None => { + let _ = pronto.send(Err("Nessuna uscita audio trovata".into())); + return Err("nessuna uscita".into()); + } + }; + let cfg = match dev.default_output_config() { + Ok(c) => c, + Err(e) => { + let _ = pronto.send(Err(e.to_string())); + return Err(e.to_string()); + } + }; + let frequenza = cfg.sample_rate().0; + let canali = cfg.channels(); + let path = std::env::temp_dir().join(format!("daprod-audio-pc-{}-{}.wav", std::process::id(), id)); + let file = std::fs::File::create(&path).map_err(|e| e.to_string())?; + let mut out = BufWriter::new(file); + intestazione_wav(&mut out, frequenza, canali, 0).map_err(|e| e.to_string())?; + let (tx, rx) = mpsc::channel::>(); + let errore = |e: cpal::StreamError| eprintln!("audio del computer: {e}"); + let picco = |d: &[f32]| { + let m = d.iter().fold(0f32, |a, x| a.max(x.abs())); + LIVELLO.fetch_max(m.to_bits(), Ordering::Relaxed); + }; + let sc: cpal::StreamConfig = cfg.clone().into(); + // su un'uscita, uno stream d'ingresso di WASAPI è il loopback: quello che si sente + let stream = match cfg.sample_format() { + cpal::SampleFormat::F32 => { + let tx = tx.clone(); + dev.build_input_stream(&sc, move |d: &[f32], _: &_| { picco(d); let _ = tx.send(d.to_vec()); }, errore, None) + } + cpal::SampleFormat::I16 => { + let tx = tx.clone(); + dev.build_input_stream(&sc, move |d: &[i16], _: &_| { + let v: Vec = d.iter().map(|x| *x as f32 / 32768.0).collect(); + picco(&v); + let _ = tx.send(v); + }, errore, None) + } + f => { + let _ = pronto.send(Err(format!("formato audio non gestito: {f:?}"))); + return Err("formato".into()); + } + }; + let stream = match stream { + Ok(s) => s, + Err(e) => { + let _ = pronto.send(Err(e.to_string())); + return Err(e.to_string()); + } + }; + if let Err(e) = stream.play() { + let _ = pronto.send(Err(e.to_string())); + return Err(e.to_string()); + } + let t0 = Instant::now(); + let _ = pronto.send(Ok(t0)); + // WASAPI in loopback non manda niente quando il computer è muto: i buchi si riempiono di silenzio, + // così il file resta allineato all'orologio (e quindi al video) + let per_ms = frequenza as f64 * canali as f64 / 1000.0; + let mut scritti: u64 = 0; + let scrivi = |out: &mut BufWriter, d: &[f32], scritti: &mut u64| -> std::io::Result<()> { + for x in d { + out.write_all(&((x.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes())?; + } + *scritti += d.len() as u64; + Ok(()) + }; + loop { + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(d) => { + let atteso = (t0.elapsed().as_secs_f64() * 1000.0 * per_ms) as u64; + let dopo = scritti + d.len() as u64; + if atteso > dopo + (per_ms * 80.0) as u64 { + let mancano = ((atteso - dopo) / canali as u64) * canali as u64; + scrivi(&mut out, &vec![0f32; mancano as usize], &mut scritti).map_err(|e| e.to_string())?; + } + scrivi(&mut out, &d, &mut scritti).map_err(|e| e.to_string())?; + } + Err(mpsc::RecvTimeoutError::Timeout) => { + let atteso = (t0.elapsed().as_secs_f64() * 1000.0 * per_ms) as u64; + if atteso > scritti + (per_ms * 120.0) as u64 { + let mancano = ((atteso - scritti) / canali as u64) * canali as u64; + scrivi(&mut out, &vec![0f32; mancano as usize], &mut scritti).map_err(|e| e.to_string())?; + } + } + Err(_) => break, + } + if ricevi.try_recv().is_ok() { + break; + } + } + drop(stream); + while let Ok(d) = rx.try_recv() { + scrivi(&mut out, &d, &mut scritti).map_err(|e| e.to_string())?; + } + let byte = (scritti * 2) as u32; + out.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?; + intestazione_wav(&mut out, frequenza, canali, byte).map_err(|e| e.to_string())?; + out.flush().map_err(|e| e.to_string())?; + Ok(Registrato { path, frequenza, canali }) + }) + .map_err(|e| e.to_string())?; + let t0 = match avvio.recv() { + Ok(Ok(t)) => t, + Ok(Err(e)) => { + let _ = thread.join(); + return Err(e); + } + Err(e) => return Err(e.to_string()), + }; + *attiva = Some(Sessione { id, stop, thread }); + Ok(AvviatoAudio { id, trascorsi_ms: t0.elapsed().as_secs_f64() * 1000.0 }) + } +} + +/// Le uscite audio (casse, cuffie…) da cui si può prendere l'audio del computer. +#[tauri::command] +pub fn audio_pc_uscite() -> Result, String> { + #[cfg(windows)] + { + win::uscite() + } + #[cfg(not(windows))] + { + Ok(Vec::new()) + } +} + +/// Comincia a prendere l'audio del computer dall'uscita scelta (None = quella predefinita di Windows). +#[tauri::command] +pub fn audio_pc_avvia(stato: tauri::State<'_, AudioPc>, uscita: Option) -> Result { + #[cfg(windows)] + { + win::avvia(&stato, uscita) + } + #[cfg(not(windows))] + { + let _ = (stato, uscita); + Err("L'audio del computer si prende così solo su Windows".into()) + } +} + +/// Il picco dall'ultima lettura (0..1), per la barra del livello. +#[tauri::command] +pub fn audio_pc_livello() -> f32 { + f32::from_bits(LIVELLO.swap(0, std::sync::atomic::Ordering::Relaxed)) +} + +/// Ferma e tiene solo i pezzi registrati (millisecondi nell'orologio della cattura), scritti in `destinazione`. +/// Senza pezzi il file provvisorio si butta e basta. +#[tauri::command] +pub async fn audio_pc_ferma(stato: tauri::State<'_, AudioPc>, id: u64, pezzi: Vec<[f64; 2]>, destinazione: Option) -> Result, String> { + let s = { + let mut attiva = stato.0.lock().map_err(|e| e.to_string())?; + if attiva.as_ref().map(|s| s.id) != Some(id) { + return Err("Audio del computer già chiuso".into()); + } + attiva.take().unwrap() + }; + let _ = s.stop.send(()); + tauri::async_runtime::spawn_blocking(move || -> Result, String> { + let r = s.thread.join().map_err(|_| "La cattura dell'audio del computer si è fermata".to_string())??; + let esito = (|| -> Result, String> { + let dest = match destinazione { + Some(d) if !pezzi.is_empty() => d, + _ => return Ok(None), + }; + let dati = std::fs::read(&r.path).map_err(|e| e.to_string())?; + let campioni = &dati[44.min(dati.len())..]; + let blocco = r.canali as usize * 2; + let per_ms = r.frequenza as f64 / 1000.0; + let mut tenuti: Vec = Vec::new(); + for [da, fine] in pezzi { + let a = ((da.max(0.0) * per_ms) as usize * blocco).min(campioni.len()); + let b = ((fine.max(0.0) * per_ms) as usize * blocco).min(campioni.len()); + if b > a { + tenuti.extend_from_slice(&campioni[a..b]); + } + } + let mut f = std::io::BufWriter::new(std::fs::File::create(&dest).map_err(|e| e.to_string())?); + intestazione_wav(&mut f, r.frequenza, r.canali, tenuti.len() as u32).map_err(|e| e.to_string())?; + std::io::Write::write_all(&mut f, &tenuti).map_err(|e| e.to_string())?; + Ok(Some(dest)) + })(); + let _ = std::fs::remove_file(&r.path); + esito + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 56a2d8c..02d8bd1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod associazioni; mod cartelle; mod conversioni; mod cursore; +mod audio_pc; mod motori; mod motore_ai; @@ -578,6 +579,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .manage(Stato::default()) .manage(cursore::Cursore::default()) + .manage(audio_pc::AudioPc::default()) .manage(Avvio(Mutex::new(dall_avvio))) .manage(audio_riserva::Decoder::default()) .invoke_handler(tauri::generate_handler![ @@ -609,6 +611,10 @@ pub fn run() { cursore::cursore_avvia, cursore::cursore_ferma, cursore::cursore_ora, + audio_pc::audio_pc_uscite, + audio_pc::audio_pc_avvia, + audio_pc::audio_pc_livello, + audio_pc::audio_pc_ferma, audio_apri, audio_decodifica, audio_chiudi, @@ -641,6 +647,7 @@ pub fn run() { .run(|_app, _ev| { if matches!(_ev, tauri::RunEvent::Exit) { _app.state::().chiudi(); + _app.state::().chiudi(); motore_ai::chiudi(); } // sul Mac il doppio clic su un progetto arriva come evento (anche ad app già aperta) diff --git a/src/media/audioComputer.ts b/src/media/audioComputer.ts new file mode 100644 index 0000000..b806598 --- /dev/null +++ b/src/media/audioComputer.ts @@ -0,0 +1,38 @@ +// L'audio del computer preso da Windows (src-tauri/src/audio_pc.rs): WebView2, con lo schermo scelto in automatico, +// consegna una traccia audio finta e muta. Qui si prende quello che esce dalle casse o dalle cuffie scelte. +// L'orologio è quello della cattura: pezzi e pause si rimappano come per il cursore (cursoreLive.ts). +import { invoke, isAndroid, isTauri } from '../platform'; + +export const audioComputerNativo = isTauri && !isAndroid && /Windows/i.test(navigator.userAgent); +export interface UscitaAudio { nome: string; predefinita: boolean } +export const usciteAudio = () => (audioComputerNativo ? invoke('audio_pc_uscite') : Promise.resolve([] as UscitaAudio[])); +/** il picco dall'ultima lettura (0..1) */ +export const livelloAudioComputer = () => (audioComputerNativo ? invoke('audio_pc_livello').catch(() => 0) : Promise.resolve(0)); + +export class AudioComputer { + private pezzi: { da: number; fine: number }[] = []; + private aperto: { da: number; fine: number } | null = null; + private constructor(private id: number, private origine: number) {} + + /** `uscita` = il nome dell'uscita audio ('' = la predefinita di Windows) */ + static async avvia(uscita = '') { + const a = performance.now(); + const r = await invoke<{ id: number; trascorsiMs: number }>('audio_pc_avvia', { uscita: uscita || null }); + const fine = performance.now(); + return new AudioComputer(r.id, fine - r.trascorsiMs - Math.min(8, (fine - a) / 2)); + } + + pezzo(t0: number) { + this.aperto = { da: t0 - this.origine, fine: Infinity }; + this.pezzi.push(this.aperto); + } + + pausa(t: number) { if (this.aperto) this.aperto.fine = t - this.origine; this.aperto = null; } + + /** ferma; con un nome scrive il file (solo i pezzi registrati) in Video/DaProd Video e ne dà il percorso */ + async ferma(nome?: string): Promise { + this.pausa(performance.now()); + const destinazione = nome ? await invoke('registrazione_percorso', { name: nome }) : null; + return invoke('audio_pc_ferma', { id: this.id, pezzi: this.pezzi.map((p) => [p.da, p.fine]), destinazione }); + } +} diff --git a/src/media/liveAudio.ts b/src/media/liveAudio.ts index bb39ef2..e1b840a 100644 --- a/src/media/liveAudio.ts +++ b/src/media/liveAudio.ts @@ -285,10 +285,14 @@ export class MixerLive { flussoMix(): MediaStream { return this.destMix!.stream; } // ——— i livelli per le barre ——— + /** il livello dell'audio del computer preso da Windows (null = quello del grafo) */ + livelloComputer: number | null = null; livelli(): { voce: Picchi; computer: Picchi; mix: Picchi } { const zero: Picchi = [0, 0]; - if (!this.ok || !this.ctx || this.ctx.state === 'closed') return { voce: zero, computer: zero, mix: zero }; - return { voce: this.voce!.misura.picchi(), computer: this.computer!.misura.picchi(), mix: this.misuraMix!.picchi() }; + const pc = this.livelloComputer; + const esterno: Picchi | null = pc === null ? null : [pc, pc]; + if (!this.ok || !this.ctx || this.ctx.state === 'closed') return { voce: zero, computer: esterno ?? zero, mix: zero }; + return { voce: this.voce!.misura.picchi(), computer: esterno ?? this.computer!.misura.picchi(), mix: this.misuraMix!.picchi() }; } /** quanto stanno abbassando i vari pezzi (dB, zero o meno) */ diff --git a/src/stile/live.css b/src/stile/live.css index 816b31d..562f812 100644 --- a/src/stile/live.css +++ b/src/stile/live.css @@ -202,7 +202,7 @@ /* anteprima in diretta dello zoom che segue il cursore */ .live-zoom-anteprima { grid-area: 1 / 1; align-self: end; justify-self: end; margin: 12px; width: clamp(180px, 24%, 360px); aspect-ratio: 16 / 9; overflow: hidden; border: 2px solid var(--cia); border-radius: 6px; background: #000; box-shadow: 0 8px 24px rgba(0, 0, 0, .55); position: relative; z-index: 3; pointer-events: none; } -.live-zoom-anteprima video { width: 100%; height: 100%; object-fit: contain; transform-origin: center; transition: transform .1s linear; } +.live-zoom-anteprima video { width: 100%; height: 100%; object-fit: contain; transform-origin: center; } .live-zoom-etichetta { position: absolute; left: 6px; top: 6px; padding: 1px 6px; border-radius: 4px; background: rgba(5, 4, 7, .8); color: var(--cia); font: 700 11px/1.4 var(--ui); letter-spacing: 2px; } -.tele-video { transform-origin: center; transition: transform .1s linear; } +.tele-video { transform-origin: center; } .tele-quadro { overflow: hidden; } diff --git a/src/ui/live.ts b/src/ui/live.ts index 8b18188..26844d7 100644 --- a/src/ui/live.ts +++ b/src/ui/live.ts @@ -32,8 +32,9 @@ import type { MediaItem } from '../core/tipi'; import { importaFile } from '../progetti'; import { invoke, isAndroid, isTauri, salvaMediaSulDisco, spazioLibero } from '../platform'; import { CursoreLive } from '../media/cursoreLive'; +import { AudioComputer, audioComputerNativo, livelloAudioComputer, usciteAudio } from '../media/audioComputer'; import { SceltaSchermoLive } from './liveCursore'; -import { applicaZoomCursore, bloccoClicLive, CameraCursore, cssCamera } from '../core/zoomCursore'; +import { applicaZoomCursore, bloccoClicLive, CameraCursore, cssCamera, type PuntoCamera } from '../core/zoomCursore'; import { motore } from '../motore'; import { avviso, h, icona } from './dom'; import { Telecomando, type StatoTelecomando } from './telecomando'; @@ -304,6 +305,8 @@ interface Opzioni { clicAnimati: boolean; mic: boolean; micId: string; + /** l'uscita audio da cui prendere l'audio del computer ('' = la predefinita di Windows) */ + uscitaPc: string; /** microfono e audio del computer in due file (e due tracce), così dopo si livellano i volumi */ separato: boolean; sistema: boolean; @@ -327,7 +330,7 @@ interface Opzioni { } const OPZ0: Opzioni = { zoomCursore: false, clicAnimati: false, - mic: true, micId: '', separato: true, sistema: true, cam: false, camId: '', camAngolo: 'bd', camTonda: true, + mic: true, micId: '', uscitaPc: '', separato: true, sistema: true, cam: false, camId: '', camAngolo: 'bd', camTonda: true, qualita: 1080, fps: 30, cursore: true, conto: true, timeline: true, presentazione: false, sfondo: 'notte', finestre: false, layout: 'solo', audio: copiaImpostazioni(IMPOSTAZIONI0), }; @@ -440,6 +443,7 @@ export class Live { /** i livelli, il mixer e i filtri dell'audio (src/ui/liveAudio.ts) */ private audio: PannelloAudio; private selMic: HTMLSelectElement; + private selPc: HTMLSelectElement; private selCam: HTMLSelectElement; /** il telecomando: anteprima, tempo, pausa, segno, ferma (src/ui/telecomando.ts) */ private tele: Telecomando; @@ -494,6 +498,11 @@ export class Live { /** il microfono registrato a parte (audio separato) */ private pezziMic: Promise[] = []; private micSep: MediaStream | null = null; + /** l'audio del computer che si registra (dallo schermo), per il suo tasto muto */ + private tracceComputer: MediaStreamTrack[] = []; + /** l'audio del computer preso da Windows (la traccia di WebView2 è finta e muta) */ + private audioPcLive: AudioComputer | null = null; + private giroLivelloPc = 0; /** millisecondi registrati (senza le pause) e da quando si sta registrando adesso */ private fatto = 0; private da = 0; @@ -527,10 +536,9 @@ export class Live { private muto(chi: 'voce' | 'computer') { this.opz.audio[chi].muto = !this.opz.audio[chi].muto; this.mixer?.imposta(this.opz.audio); - if (!this.mixer) { - const sorgente = chi === 'voce' ? this.micReg : this.flussi[0]; - sorgente?.getAudioTracks().forEach(t => { t.enabled = !this.opz.audio[chi].muto; }); - } + // ogni tasto spegne solo la sua traccia: la voce il microfono, il computer l'audio dello schermo + const tracce = chi === 'voce' ? this.micReg?.getAudioTracks() ?? [] : this.tracceComputer; + tracce.forEach(t => { t.enabled = !this.opz.audio[chi].muto; }); this.audio.aggiorna(); this.salvaOpzioni(); this.aggiornaTempo(); } private annullaConto: (() => void) | null = null; @@ -578,7 +586,7 @@ export class Live { // ——— le scelte ——— const salva = () => this.salvaOpzioni(); - const fisse = new Set(['mic', 'sistema', 'separato', 'qualita', 'fps', 'cursore', 'micId', 'camId', 'finestre', 'zoomCursore', 'clicAnimati']); + const fisse = new Set(['mic', 'sistema', 'separato', 'qualita', 'fps', 'cursore', 'micId', 'uscitaPc', 'camId', 'finestre', 'zoomCursore', 'clicAnimati']); const cambia = (k: K, v: Opzioni[K]) => { if (this.fase !== 'fermo' && fisse.has(k)) { avviso('Questo si cambia prima di registrare', 'info'); this.sinc.forEach((f) => f()); return false; } this.opz[k] = v; @@ -601,6 +609,7 @@ export class Live { }); return h('div', { class: 'live-chips' }, bb); }; + this.selPc = h('select', { class: 'live-sel', title: 'Da quale uscita prendere l’audio del computer (casse, cuffie…)', on: { change: () => cambia('uscitaPc', this.selPc.value) } }, h('option', { value: '' }, 'Uscita predefinita di Windows')) as HTMLSelectElement; this.selMic = h('select', { class: 'live-sel', title: 'Quale microfono', on: { change: () => { cambia('micId', this.selMic.value); if (this.micProva) { this.fermaProvaMic(); void this.provaMic(); } } } }, h('option', { value: '' }, 'Predefinito')) as HTMLSelectElement; this.selCam = h('select', { class: 'live-sel live-sel-cam', title: 'Quale webcam', on: { change: () => { if (cambia('camId', this.selCam.value) && this.opz.cam) { this.chiudiCam(); void this.apriCam(); } } } }, h('option', { value: '' }, 'Predefinita')) as HTMLSelectElement; const sfondi = h('div', { class: 'live-sfondi' }, SFONDI.map((x) => { @@ -695,6 +704,7 @@ export class Live { sezione('Audio e dispositivi'), h('div', { class: 'live-dispositivi' }, h('label', null, h('span', null, 'Microfono'), this.selMic), + audioComputerNativo ? h('label', null, h('span', null, 'Audio PC'), this.selPc) : null, h('label', null, h('span', null, 'Webcam'), this.selCam))), h('section', { class: 'live-configurazione', 'aria-label': 'Altre opzioni' }, sezione('Altre opzioni'), sezione('Cursore e zoom'), @@ -851,6 +861,10 @@ export class Live { sel.value = lista.some((x) => x.deviceId === scelto) ? scelto : ''; }; riempi(this.selMic, 'audioinput', this.opz.micId, 'Microfono', 'Predefinito'); + if (audioComputerNativo) void usciteAudio().then((u) => { + this.selPc.replaceChildren(h('option', { value: '' }, 'Uscita predefinita di Windows'), ...u.map((x) => h('option', { value: x.nome }, x.nome + (x.predefinita ? ' · predefinita' : '')))); + this.selPc.value = u.some((x) => x.nome === this.opz.uscitaPc) ? this.opz.uscitaPc : ''; + }).catch(() => {}); riempi(this.selCam, 'videoinput', this.opz.camId, 'Webcam', 'Predefinita'); } catch { /* niente elenco: resta il predefinito */ } } @@ -964,12 +978,20 @@ export class Live { if (ora?.win) win = ora.win; if (ora?.visible && ora.clicks) clic = { cx: ora.cx, cy: ora.cy, t }; if (clic && t - clic.t > 1900) clic = null; - const css = cssCamera(cam.passo(t, ora?.visible ? [ora.cx, ora.cy] : null, clic ?? undefined, () => win)); - v.style.transform = css; - this.tele.zoom(css); + prima = dopo; dopo = { ...cam.passo(t, ora?.visible ? [ora.cx, ora.cy] : null, clic ?? undefined, () => win) }; arrivato = performance.now(); } finally { occupato = false; } }, 100); - this.zoomDiretta = { ferma: () => { clearInterval(giro); v.srcObject = null; box.remove(); this.tele.zoom(''); } }; + // fra un passo della camera e l'altro (100 ms) la posizione si mescola a ogni fotogramma dello schermo: niente scatti + let prima: PuntoCamera = { tMs: 0, cx: .5, cy: .5, z: 1 }, dopo = prima, arrivato = performance.now(), raf = 0; + const disegna = () => { + const k = Math.min(1, (performance.now() - arrivato) / 100); + const css = cssCamera({ tMs: 0, cx: prima.cx + (dopo.cx - prima.cx) * k, cy: prima.cy + (dopo.cy - prima.cy) * k, z: prima.z + (dopo.z - prima.z) * k }); + v.style.transform = css; + this.tele.zoom(css); + raf = requestAnimationFrame(disegna); + }; + raf = requestAnimationFrame(disegna); + this.zoomDiretta = { ferma: () => { clearInterval(giro); cancelAnimationFrame(raf); v.srcObject = null; box.remove(); this.tele.zoom(''); } }; } private fermaAnteprimaZoom() { this.zoomDiretta?.ferma(); this.zoomDiretta = null; } @@ -1130,6 +1152,7 @@ export class Live { const t0 = performance.now(); const n = nastro(this.flusso!, this.tipo, bitrate(this.opz.qualita, this.opz.fps)); this.cursoreLive?.pezzo(t0); + this.audioPcLive?.pezzo(t0); this.pezzi.push(n.fatto); // la webcam parte con lo schermo solo se è accesa; dopo una pausa riprende nel suo segmento if (this.cam && this.opz.cam) this.avviaNastroCam(this.fase === 'pausa' ? this.fatto / 1000 : 0); @@ -1140,7 +1163,7 @@ export class Live { this.pezziMic.push(mc.fatto); } catch { /* il microfono a parte no: resta muto, lo schermo va lo stesso */ } } - this.pezzo = { chiudi: () => { const adesso = performance.now(); const t = (adesso - t0) / 1000; this.cursoreLive?.pausa(adesso); n.ferma(t); this.fermaNastroCam(); mc?.ferma(t); } }; + this.pezzo = { chiudi: () => { const adesso = performance.now(); const t = (adesso - t0) / 1000; this.cursoreLive?.pausa(adesso); this.audioPcLive?.pausa(adesso); n.ferma(t); this.fermaNastroCam(); mc?.ferma(t); } }; } /** la webcam registra da adesso (`da` = a che punto della registrazione, in secondi): un segmento nuovo, o quello aperto */ @@ -1282,7 +1305,19 @@ export class Live { } else tracce = [...schermo.getVideoTracks()]; // l'audio: il microfono e quello del computer passano dal mixer (livelli, volumi, filtri); a seconda delle scelte // escono in una traccia mescolata, oppure in due file (la voce a parte) o in uno solo se c'è una sola sorgente - const audioPc = schermo.getAudioTracks(); + let audioPc = schermo.getAudioTracks(); + if (audioComputerNativo && o.sistema) { + // nell'app su Windows l'audio del computer lo prende Windows: la traccia dello schermo non si registra + audioPc.forEach((t) => t.stop()); + audioPc = []; + await this.audioPcLive?.ferma().catch(() => null); + this.audioPcLive = null; + try { + this.audioPcLive = await AudioComputer.avvia(o.uscitaPc); + clearInterval(this.giroLivelloPc); + this.giroLivelloPc = window.setInterval(async () => { const l = await livelloAudioComputer(); if (this.mixer) this.mixer.livelloComputer = l; }, 80); + } catch (e) { avviso('⚠ Non riesco a prendere l’audio del computer: ' + String(e).slice(0, 90), 'errore', 7000); } + } let mic: MediaStream | null = null; this.micReg = null; if (o.mic) { @@ -1293,22 +1328,22 @@ export class Live { } catch (e) { avviso('⚠ Il microfono non si apre, la voce NON viene registrata. Controlla in Windows → Privacy → Microfono. (' + String(e).slice(0, 80) + ')', 'errore', 9000); } } await mix.pronto; + // il contesto audio può essere nato senza un clic diretto (telecomando, conto alla rovescia): si sveglia qui + void mix.ctx?.resume().catch(() => {}); this.micSep = null; const haPc = audioPc.length > 0; + this.tracceComputer = audioPc; + // le barre dei livelli: il mixer ascolta le sorgenti, ognuna sulla sua barra if (mix.ok) { mix.imposta(o.audio); if (haPc) mix.collegaComputer(new MediaStream(audioPc)); if (mic) mix.collegaVoce(mic); - if (o.separato && haPc && mic) { - tracce.push(...mix.flussoComputer().getAudioTracks()); - this.micSep = mix.flussoVoce(); - } else if (haPc && mic) tracce.push(...mix.flussoMix().getAudioTracks()); - else if (haPc) tracce.push(...mix.flussoComputer().getAudioTracks()); - else if (mic) tracce.push(...mix.flussoVoce().getAudioTracks()); - } else { - // niente audio elaborato (il browser non ha il mixer): si registra com'è, la voce a parte - audioPc.forEach(t => { t.enabled = !o.audio.computer.muto; }); - mic?.getAudioTracks().forEach(t => { t.enabled = !o.audio.voce.muto; }); + } + // si registrano le tracce vere, non le uscite del mixer: se il contesto audio restava in pausa i file venivano muti + audioPc.forEach(t => { t.enabled = !o.audio.computer.muto; }); + mic?.getAudioTracks().forEach(t => { t.enabled = !o.audio.voce.muto; }); + if (haPc && mic && !o.separato && mix.ok) tracce.push(...mix.flussoMix().getAudioTracks()); // una traccia sola: serve il mescolatore + else { tracce.push(...audioPc); if (mic) { if (haPc) this.micSep = new MediaStream(mic.getAudioTracks()); @@ -1383,6 +1418,8 @@ export class Live { this.da = performance.now(); this.aggiornaTasti(); this.stato.textContent = '● Sto registrando'; + // premuto REC, il telecomando si apre da solo (e Windows lo tiene fuori dalla registrazione) + if (!this.tele.aperto) void this.apriTelecomando()?.catch?.(() => {}); this.fermaGiro(); this.giro = window.setInterval(() => this.aggiornaTempo(), 250); this.chiudiGiro = risorseLive.apri('intervallo'); @@ -1507,6 +1544,10 @@ export class Live { }) ?? Promise.resolve(null); const scelteCursore = { ...this.scelteCursore }; const scene = structuredClone(this.scene); + const audioPc = this.audioPcLive; + this.audioPcLive = null; + clearInterval(this.giroLivelloPc); + if (this.mixer) this.mixer.livelloComputer = null; this.cursoreLive = null; this.fermaAnteprimaZoom(); this.fase = 'fermo'; @@ -1551,7 +1592,7 @@ export class Live { // la webcam resta accesa per l'anteprima solo se si è ancora su LIVE if (!this.visibile) this.chiudiCam(); await this.consegna(tratti.filter((x) => x.blob.size), segmentiCam.map((g) => ({ da: g.da, tratti: g.tratti.filter((x) => x.blob.size) })).filter((g) => g.tratti.length), trattiMic.filter((x) => x.blob.size), durata, segni, foto, - await cursore, scelteCursore, tratti.flatMap((x, i) => x.blob.size ? [i] : []), cuciti, scene); + await cursore, scelteCursore, tratti.flatMap((x, i) => x.blob.size ? [i] : []), cuciti, scene, audioPc); })().catch((e) => { // anche su errore tutto quello che era aperto si chiude try { regia?.ferma(); this.chiudiFlussi(flussi, mix); } catch { /* già chiusi */ } @@ -1576,8 +1617,9 @@ export class Live { /** la registrazione finita: cucita, sul disco (nell'app), nel contenitore e in fondo alla timeline */ private async consegna(tratti: Tratto[], segmentiCam: { da: number; tratti: Tratto[] }[], trattiMic: Tratto[], durata: number, segni: number[], foto: string, cursore: Awaited> | null = null, scelteCursore = { zoom: false, clic: false }, indici: number[] = tratti.map((_, i) => i), - /** i file cuciti sul disco temporaneo: chi chiama li toglie sempre, anche se qualcosa va storto */ cuciti: Blob[] = [], scene: CambioScena[] = []) { - if (!tratti.length) { this.stato.textContent = 'La registrazione è vuota'; return; } + /** i file cuciti sul disco temporaneo: chi chiama li toglie sempre, anche se qualcosa va storto */ cuciti: Blob[] = [], scene: CambioScena[] = [], + audioPc: AudioComputer | null = null) { + if (!tratti.length) { void audioPc?.ferma().catch(() => null); this.stato.textContent = 'La registrazione è vuota'; return; } const mp4 = /mp4/.test(tratti[0].blob.type); // di solito un file solo; se la cucitura non riesce, i pezzi vanno uno dopo l'altro (sempre senza buchi) const tempi: { indice: number; daMs: number; durataMs: number }[] = []; @@ -1604,6 +1646,18 @@ export class Live { const ora = new Date(); const radice = `Registrazione ${ora.getFullYear()}-${due(ora.getMonth() + 1)}-${due(ora.getDate())} ${due(ora.getHours())}.${due(ora.getMinutes())}.${due(ora.getSeconds())}`; const cartella = this.cartellaRegistrazioni(); + // l'audio del computer preso da Windows: un file WAV accanto al video, con gli stessi pezzi + let pcAudio: MediaItem | null = null; + if (audioPc) { + try { + const percorso = await audioPc.ferma(`${radice} computer.wav`); + if (percorso) { + const nome = percorso.split(/[\\/]/).pop() ?? `${radice} computer.wav`; + const [m] = await importaFile([{ name: nome, path: percorso }], { chiediFormato: false, cartella, controlla: false }); + pcAudio = (m as MediaItem | undefined) ?? null; + } + } catch (e) { avviso('La registrazione resta, ma non l’audio del computer: ' + String(e).slice(0, 90), 'info', 6000); } + } const importa = async (nome: string, blob: Blob) => { const path = await salvaSulDisco(nome, blob); // senza un percorso sul disco (browser) il file vive in memoria: non può restare legato al file temporaneo, che si cancella @@ -1654,11 +1708,11 @@ export class Live { catch (e) { avviso('Le scene restano nel progetto, ma non riesco a salvare i dati accanto al video: ' + String(e), 'info', 5000); } } const mCam = cams[0]?.m; - let ids = this.opz.timeline ? this.inTimeline(schermi, cams, segni, mics, scelteCursore, scene) : []; + let ids = this.opz.timeline ? this.inTimeline(schermi, cams, segni, mics, scelteCursore, scene, pcAudio) : []; if (ids.length) store.select(ids); const { id, name: nome } = schermi[0]; this.stato.textContent = `Fatto: ${nome}`; - const riga = h('button', { class: 'live-voce', title: 'Apri le scene nel montaggio', on: { click: () => { if (!ids.length) ids = this.inTimeline(schermi, cams, segni, mics, scelteCursore, scene); store.select(ids); this.vaiAlMontaggio(id, ids); } } }, + const riga = h('button', { class: 'live-voce', title: 'Apri le scene nel montaggio', on: { click: () => { if (!ids.length) ids = this.inTimeline(schermi, cams, segni, mics, scelteCursore, scene, pcAudio); store.select(ids); this.vaiAlMontaggio(id, ids); } } }, foto ? h('img', { class: 'live-voce-foto', src: foto, alt: '' }) : icona('video', 14), h('span', { class: 'live-voce-testo' }, h('b', null, nome.replace(/\.(webm|mp4)$/, '')), h('small', null, [orologio(durata), mCam ? 'webcam' : '', mics.length ? 'voce a parte' : '', segni.length ? `${segni.length} segni` : ''].filter(Boolean).join(' · ')))); @@ -1685,7 +1739,7 @@ export class Live { * In fondo alla timeline, dal basso: lo sfondo (stile presentazione), lo schermo, la webcam sopra. Se le tracce * video non bastano se ne aggiungono. Tutto legato insieme, così si sposta in un colpo; i segni diventano marcatori. */ - private inTimeline(schermi: MediaItem[], cams: { m: MediaItem; da: number }[], segni: number[], mics: MediaItem[] = [], cursore = { zoom: false, clic: false }, scene: CambioScena[] = []): string[] { + private inTimeline(schermi: MediaItem[], cams: { m: MediaItem; da: number }[], segni: number[], mics: MediaItem[] = [], cursore = { zoom: false, clic: false }, scene: CambioScena[] = [], pcAudio: MediaItem | null = null): string[] { const o = { ...this.opz, presentazione: scene[0]?.presentazione ?? this.opz.presentazione }; return store.edit('Registrazione in timeline', (p) => { const inizio = projectEnd(p); @@ -1722,6 +1776,12 @@ export class Live { if (o.presentazione) v.tf = presentazione(); link ??= v.link ?? (v.link = uid('l')); for (const c of p.clips) if (nuovi.includes(c.id)) { c.link = link; if (tMic && c.track !== tSchermo) c.name = 'Audio del computer'; } + // l'audio del computer preso da Windows: sulla prima traccia audio, legato alla ripresa + if (pcAudio && i === 0 && a) { + const pc = M.placeSource(p, { mediaId: pcAudio.id, srcIn: pcAudio.t0 || 0, srcOut: pcAudio.duration }, v.start, null, { video: null, audio: [a] }, 'libero'); + for (const c of p.clips) if (pc.includes(c.id)) { c.link = link; c.name = 'Audio del computer'; c.len = Math.min(c.len, v.len); } + ids.push(...pc); + } if (cursore.clic && m.cursoreLive?.clic.length) { const blocco = bloccoClicLive(v); p.clips.push(blocco); ids.push(blocco.id); diff --git a/src/ui/telecomando.ts b/src/ui/telecomando.ts index d1b2836..f6f49a8 100644 --- a/src/ui/telecomando.ts +++ b/src/ui/telecomando.ts @@ -228,9 +228,12 @@ export class Telecomando { await w.setMinSize(new LogicalSize(360, 500)); await w.setSize(new LogicalSize(MISURA.w, MISURA.h)); await w.setAlwaysOnTop(true); + // il telecomando non deve finire nella registrazione dello schermo (Windows lo esclude dalla cattura) + await w.setContentProtected(true).catch(() => {}); this.modoPiccolo(true); } else { this.modoPiccolo(false); + await w.setContentProtected(false).catch(() => {}); await w.setAlwaysOnTop(false); await w.setMinSize(new LogicalSize(1000, 640)); const p = this.prima;