diff --git a/lang/template.json b/lang/template.json index 4179590..83260f3 100644 --- a/lang/template.json +++ b/lang/template.json @@ -7,6 +7,8 @@ "(chosen files are used instead of the folder)": "", "(deleted rule)": "", "(not connected)": "", + "0: recordings are never deleted": "", + "1 hour": "", "12+ characters": "", "12-band": "", "16 bands at most": "", @@ -14,6 +16,7 @@ "A copy of this event to change": "", "A copy of this template to change": "", "A different pattern for part of the day. The first one that covers the time is used; outside them the pattern above is.": "", + "A folder on this computer, a network folder (\\\\server\\share) or a cloud folder that syncs (OneDrive, Dropbox, Google Drive). Each day gets its own folder. If the folder can't be written, that hour goes into the data folder's recordings folder instead.": "", "A newer RadioMaster is on GitHub — click to see what's new and install it": "", "A plugin controls RadioMaster by printing lines such as RM: VOLUME 60 or RM: PLAYLIST Evening.": "", "A stopped server is off air until someone starts it again on that computer.": "", @@ -22,6 +25,7 @@ "API key": "", "API password": "", "AUX Playlist": "", + "About": "", "About RadioMaster": "", "About this song": "", "Account is active": "", @@ -114,6 +118,7 @@ "Bell": "", "Bell lifts or cuts around a frequency; shelves move everything above or below it; cuts remove it.": "", "Between lines": "", + "Bitrate": "", "Bitrate (kbps)": "", "Blue": "", "Broadcast / encoders": "", @@ -217,6 +222,7 @@ "Duplicate": "", "Duration": "", "ELAPSED": "", + "Each file": "", "Each keeps its own licence (MIT, Apache-2.0, MPL-2.0 or LGPL for LAME).": "", "Each runs a playlist command, e.g. PLAYLIST Evening, VOLUME 60, GENERATE Drive time, RULE Top of hour jingle or STOP.": "", "Each track is analysed: silence is skipped, cold endings get almost no overlap, and songs that fade out let the next one start as the outro dies away. Cues you set in the track editor always win.": "", @@ -244,11 +250,13 @@ "Every play (CSV / Excel)…": "", "Every track overlaps the next by this much. Tracks with cues set in the track editor still use their own.": "", "Everything": "", + "Everything that goes on air is saved, one file per hour (or the length chosen below), as proof of broadcast. It records in the background while RadioMaster runs.": "", "Exit": "", "Export as CSV…": "", "Export now-playing metadata": "", "Export translation template": "", "Extra": "", + "FLAC (lossless, about half the size of WAV; needs ffmpeg)": "", "FLAC in Ogg, lossless (ffmpeg)": "", "Fade": "", "Fade on cold endings": "", @@ -269,6 +277,7 @@ "Fixed crossfade": "", "Fixed keys": "", "Flat": "", + "Folder": "", "Folder in the profile": "", "Folder on the server": "", "Folder…": "", @@ -395,6 +404,7 @@ "MIDI isn't available:": "", "MIDI only": "", "MP3 (built in)": "", + "MP3 (compressed, 64–320 kbps)": "", "Make a jingle rule in the Advertisement scheduler first, or add a Jingles category": "", "Make a tunnel in the Cloudflare dashboard (Zero Trust → Networks → Tunnels) for your own domain, and point it at http://localhost and the portal port. Without a token you get a random trycloudflare.com address.": "", "Make everything larger or smaller if the text is hard to read.": "", @@ -455,6 +465,7 @@ "Not encrypted (http). Turn on HTTPS in Server settings for a server on the internet.": "", "Not found anywhere": "", "Not playing": "", + "Not recording": "", "Notch": "", "Notepad": "", "Nothing here.": "", @@ -567,6 +578,7 @@ "Read the play history again": "", "Recent": "", "Reconnecting…": "", + "Record the station's output": "", "Recordings": "", "Recordings are saved as WAV in the voicetracks folder of your RadioMaster data folder.": "", "Recordings folder": "", @@ -671,6 +683,9 @@ "Show every file in the folder, not only audio": "", "Show every file, not only audio": "", "Show every track in the library or playlist next to the editor": "", + "Show recorder": "", + "Show recording": "", + "Show recording: click to see it": "", "Shown on the pad": "", "Shuffle upcoming": "", "Sign in": "", @@ -705,6 +720,8 @@ "Start encoder #": "", "Start playing when the server starts": "", "Start time": "", + "Starting: waiting for sound from the mixer": "", + "Starting…": "", "Starts in": "", "Station": "", "Station audio": "", @@ -820,6 +837,8 @@ "Voice": "", "Vol ": "", "Volume": "", + "WAV (uncompressed, about 600 MB an hour)": "", + "Waiting: outside the days and hours set": "", "Want to be listed? Contribute code, a translation or testing — see CONTRIBUTORS.md on GitHub.": "", "Warn the presenter": "", "Web": "", @@ -857,6 +876,7 @@ "Yes": "", "You can't disable your own account": "", "You can't take away your own administrator access": "", + "You're connected to a RadioMaster Server: the server records, into a folder on the server.": "", "Your account can't change playlists": "", "Your account isn't allowed to do this": "", "Your account isn't allowed to edit playlists": "", @@ -866,6 +886,7 @@ "Your adverts, each with the dates and days it may run. Rules can play from this list.": "", "Zoom": "", "Zoom on the hand-over to the next track": "", + "a day": "", "a folder or chosen files": "", "administrator": "", "advert": "", @@ -878,6 +899,7 @@ "all day": "", "all files in order": "", "all of them": "", + "an hour": "", "analysing next track…": "", "analysing…": "", "any": "", @@ -888,6 +910,7 @@ "can't analyse the next track": "", "command, e.g. PLAYLIST Evening": "", "cut tail after": "", + "days": "", "delete for good": "", "e.g. station id, news": "", "egui · rodio · symphonia · LAME": "", @@ -898,6 +921,7 @@ "files": "", "files checked": "", "files deleted": "", + "files finished since it started": "", "files for good? Playlist rows that use them will play the kept copy.": "", "files moved to the Removed tracks folder": "", "files retagged": "", @@ -905,6 +929,7 @@ "filter": "", "folder": "", "folder or .m3u": "", + "for all the days kept": "", "from": "", "genre": "", "get ffmpeg": "", @@ -950,6 +975,7 @@ "now (the track on air moves on)": "", "of each track (0 = off)": "", "off": "", + "older recordings are deleted (only RadioMaster's own files)": "", "on air": "", "on the clock": "", "on the server": "", @@ -968,6 +994,7 @@ "port": "", "preset name": "", "random": "", + "recorded": "", "rows": "", "saved (type to replace)": "", "search requestable songs": "", @@ -1023,6 +1050,10 @@ "⏭ Skip song": "", "⏹ Disconnect": "", "⏹ Stop (F12)": "", + "⏺ REC": "", + "⏺ REC: problem": "", + "⏺ REC: starting": "", + "⏺ REC: waiting": "", "■ Stop": "", "■ Stop all jingles": "", "■ Stop jingles": "", @@ -1045,6 +1076,7 @@ "● Offline": "", "● On air": "", "● Record": "", + "● Recording": "", "● in force now": "", "● offline": "", "● online": "", @@ -1112,6 +1144,7 @@ "👯 Duplicates": "", "💾 Save as M3U…": "", "📁 Create folder": "", + "📁 Open the recordings folder": "", "📂 Also look in…": "", "📂 Load M3U into this tab…": "", "📂 Open plugins folder": "", diff --git a/src/app.rs b/src/app.rs index 814f802..aab0fca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,6 +34,7 @@ enum SettingsPage { Playback, Devices, Streaming, + Recording, Audio, Automation, Remote, @@ -41,12 +42,13 @@ enum SettingsPage { Accounts, } impl SettingsPage { - const ALL: [SettingsPage; 10] = [ + const ALL: [SettingsPage; 11] = [ Self::General, Self::Library, Self::Playback, Self::Devices, Self::Streaming, + Self::Recording, Self::Audio, Self::Automation, Self::Remote, @@ -60,6 +62,7 @@ impl SettingsPage { Self::Playback => "Playback", Self::Devices => "Audio devices", Self::Streaming => "Broadcast / encoders", + Self::Recording => "Show recording", Self::Audio => "Audio processing", Self::Automation => "Automation / metadata", Self::Remote => "Remote API", @@ -86,6 +89,8 @@ enum Dlg { CatFolder(usize, PathBuf), SaveReport(PathBuf), AnnounceFolder(PathBuf), + /// folder for show recordings + RecFolder(PathBuf), ContainerFiles(Vec), FfmpegPath(PathBuf), /// (outro?, file) for a speech row @@ -203,6 +208,8 @@ mod panes; mod pltools; #[path = "plview.rs"] mod plview; +#[path = "recui.rs"] +mod recui; #[path = "reportui.rs"] mod reportui; #[path = "serveradmin.rs"] @@ -897,6 +904,7 @@ impl App { self.ins_from_pl = Some((p, rows, vec![true; n])); } Dlg::AnnounceFolder(p) => core.cfg.announce.folder = p.to_string_lossy().to_string(), + Dlg::RecFolder(p) => core.cfg.recording.folder = p.to_string_lossy().to_string(), Dlg::ContainerFiles(v) => { if let Some(d) = self.item_dlg.as_mut() { d.items.extend(v); @@ -1196,6 +1204,7 @@ impl App { ToolWin::Scheduler, ToolWin::AudioFx, ToolWin::Streams, + ToolWin::Recorder, ToolWin::AzuraCast, ToolWin::Reports, ToolWin::History, @@ -1483,7 +1492,10 @@ impl App { } // ----------------------------------------------------------- status bar - fn status_bar(&self, ctx: &egui::Context, core: &Playout) { + /// The message line, clock, streams and recorder at the bottom. Needs `&mut self` because + /// clicking ⏺ REC opens the Show recorder window (its only caller is `update`). + fn status_bar(&mut self, ctx: &egui::Context, core: &Playout) { + let mut open_recorder = false; egui::TopBottomPanel::bottom("status").show(ctx, |ui| { ui.horizontal(|ui| { ui.label(core.notify.lock().clone()); @@ -1492,6 +1504,24 @@ impl App { ui.separator(); let live = core.handles.iter().flatten().filter(|h| h.status.lock().state == EncState::Live).count(); ui.label(RichText::new(format!("● {live} stream(s) live")).color(if live > 0 { GREEN } else { GRAY })); + if let Some(rec) = core.recording() { + ui.separator(); + let (text, color) = match rec.state.as_str() { + "recording" => (tr("⏺ REC"), RED), + "error" => (tr("⏺ REC: problem"), AMBER), + "starting" => (tr("⏺ REC: starting"), GRAY), + _ => (tr("⏺ REC: waiting"), GRAY), + }; + let hover = + if rec.error.is_empty() { tr("Show recording: click to see it").to_string() } else { rec.error.clone() }; + if ui + .add(egui::Label::new(RichText::new(text).color(color)).sense(egui::Sense::click())) + .on_hover_text(hover) + .clicked() + { + open_recorder = true; + } + } if let Some(np) = &core.azura.lock().np { ui.separator(); ui.label(format!("👥 {} listeners ({} unique)", np.listeners.current, np.listeners.unique)); @@ -1499,6 +1529,9 @@ impl App { }); }); }); + if open_recorder { + self.open_tool(ToolWin::Recorder); + } } // --------------------------------------------------------------- player @@ -2115,6 +2148,7 @@ impl App { self.smart_fade_settings(ui, core); }, SettingsPage::Streaming=>self.tab_streaming(ui,core), + SettingsPage::Recording=>self.tab_recording(ui,core), SettingsPage::Audio=>self.tab_dsp(ui,core), SettingsPage::Devices=>self.tab_devices(ui,core), SettingsPage::Automation=>self.tab_settings(ui,core), @@ -4062,6 +4096,7 @@ impl eframe::App for App { for i in 0..c.cfg.encoders.len() { c.stop_encoder(i); } + c.finish_recording(); c.save_all(); c.plugins.stop_services(); drop(c); diff --git a/src/config.rs b/src/config.rs index 57c62db..9cb7cd9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -760,6 +760,105 @@ pub struct AudioDevices { pub buffer_ms: u32, } +/// File type of the show recordings. +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum RecFormat { + /// compressed, built in (64–320 kbps) + #[default] + Mp3, + /// lossless and compressed (needs ffmpeg) + Flac, + /// uncompressed + Wav, +} + +impl RecFormat { + pub const ALL: [RecFormat; 3] = [RecFormat::Mp3, RecFormat::Flac, RecFormat::Wav]; + pub fn label(self) -> &'static str { + match self { + RecFormat::Mp3 => "MP3 (compressed, 64–320 kbps)", + RecFormat::Flac => "FLAC (lossless, about half the size of WAV; needs ffmpeg)", + RecFormat::Wav => "WAV (uncompressed, about 600 MB an hour)", + } + } + pub fn extension(self) -> &'static str { + match self { + RecFormat::Mp3 => "mp3", + RecFormat::Flac => "flac", + RecFormat::Wav => "wav", + } + } +} + +/// Show recording: the on-air mix is saved to a folder in files of `segment_mins` each, lined up +/// with the clock (a 60-minute file starts on the hour). +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +#[serde(default)] +pub struct RecordCfg { + pub enabled: bool, + /// a local, network (\\server\share) or cloud-synced folder; empty = "recordings" in the data folder + pub folder: String, + pub format: RecFormat, + /// MP3 bitrate + pub bitrate_kbps: u32, + /// length of each file in minutes + pub segment_mins: u32, + /// delete recordings older than this many days (0 = keep them all) + pub keep_days: u32, + /// days of the week to record (Monday first) + pub days: [bool; 7], + /// record between these hours (from == to: all day; 22 → 6 runs over midnight) + pub from_hour: u32, + pub to_hour: u32, +} + +impl Default for RecordCfg { + fn default() -> Self { + Self { + enabled: false, + folder: String::new(), + format: RecFormat::Mp3, + bitrate_kbps: 128, + segment_mins: 60, + keep_days: 90, + days: [true; 7], + from_hour: 0, + to_hour: 0, + } + } +} + +impl RecordCfg { + /// Whether recordings are made at `now` (days and hours). + pub fn active_at(&self, now: chrono::NaiveDateTime) -> bool { + use chrono::Timelike; + self.days[now.weekday().num_days_from_monday() as usize] && hours_ok(self.from_hour, self.to_hour, now.hour()) + } + + /// The folder recordings go into. + pub fn folder_path(&self) -> PathBuf { + if self.folder.trim().is_empty() { + data_dir().join("recordings") + } else { + PathBuf::from(self.folder.trim()) + } + } + + /// Minutes per file: one that divides the day (so files line up with the clock), and at most + /// 6 hours for WAV (a WAV file can't be bigger than 4 GB). + pub fn segment(&self) -> u32 { + let m = SEGMENT_MINS.iter().copied().filter(|&m| m <= self.segment_mins.max(1)).max().unwrap_or(60); + if self.format == RecFormat::Wav { + m.min(360) + } else { + m + } + } +} + +/// The file lengths offered for show recordings, in minutes. +pub const SEGMENT_MINS: [u32; 10] = [15, 30, 60, 120, 180, 240, 360, 480, 720, 1440]; + #[derive(Serialize, Deserialize, Clone)] #[serde(default)] pub struct Config { @@ -814,6 +913,8 @@ pub struct Config { pub ffmpeg_path: String, /// while connected to RadioMaster Server: play the station's mix on this machine's sound cards pub hear_server: bool, + /// show recording (logger): the on-air mix saved to files, one per hour + pub recording: RecordCfg, } impl Default for Config { @@ -857,6 +958,7 @@ impl Default for Config { rotations: vec![], ad_rules: vec![AdRule { id: 1, ..Default::default() }], ad_rules_v: 1, + recording: RecordCfg::default(), } } } diff --git a/src/main.rs b/src/main.rs index 6ec0e38..34d5aed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod midi; mod pdf; mod playout; mod plugins; +mod recorder; mod remote; mod report; mod scheduler; diff --git a/src/playout.rs b/src/playout.rs index 6323858..b93b5d2 100644 --- a/src/playout.rs +++ b/src/playout.rs @@ -460,6 +460,14 @@ pub struct Playout { wave_errors: HashMap, /// lines ever written to the log (how the server's clients follow it) pub log_total: u64, + // ---- show recording + recorder: Option, + /// recording settings that changed, and when (applied once they stay put for a moment) + rec_pending: Option<(crate::config::RecordCfg, Instant)>, + /// the server's recorder (when this is a mirror) + rec_mirror: Option, + /// the last recorder problem written to the log + rec_error: String, } #[path = "sync.rs"] @@ -589,6 +597,10 @@ impl Playout { clock_offset_ms: 0, wave_errors: HashMap::new(), log_total: 0, + recorder: None, + rec_pending: None, + rec_mirror: None, + rec_error: String::new(), } } @@ -751,11 +763,73 @@ impl Playout { self.enc_states = states; } + self.follow_recording(); + if self.last_save.elapsed().as_secs() >= 60 { self.save_all(); } } + // ------------------------------------------------------------- show recording + /// Start, stop or restart the show recorder to match the settings. A change is applied once it + /// has stayed the same for a couple of seconds, so typing a folder name doesn't start a file + /// per key press. + fn follow_recording(&mut self) { + let want = &self.cfg.recording; + let running = self.recorder.as_ref().map(|r| (&r.cfg, r.ffmpeg.as_str())); + let same = match running { + Some((c, f)) => want.enabled && c == want && f == self.cfg.ffmpeg_path, + None => !want.enabled, + }; + if same { + self.rec_pending = None; + } else { + match &self.rec_pending { + Some((c, t)) if c == want && t.elapsed() >= std::time::Duration::from_secs(2) => { + self.rec_pending = None; + self.recorder = None; // stops it (the file it was writing is closed) + if want.enabled { + self.recorder = Some(crate::recorder::Recorder::start(want.clone(), &self.engine, self.cfg.ffmpeg_path.clone())); + self.say(format!("Show recording on: {}", want.folder_path().display())); + } else { + self.say("Show recording off"); + } + } + Some((c, _)) if c == want => {} + _ => self.rec_pending = Some((want.clone(), Instant::now())), + } + } + let err = self.recorder.as_ref().map(|r| r.status.lock().error.clone()).unwrap_or_default(); + if err != self.rec_error { + if !err.is_empty() { + self.say(format!("Show recording: {err}")); + } + self.rec_error = err; + } + } + + /// What the show recorder is doing (the server's when this is a mirror); None when it is off. + pub fn recording(&self) -> Option { + if self.link.is_some() { + return self.rec_mirror.clone(); + } + self.recorder.as_ref().map(|r| r.status.lock().clone()) + } + + /// Stop recording here (this machine is connecting to a server, which records instead). It + /// starts again by itself when this playout runs again with recording on. + pub fn stop_recording(&mut self) { + self.recorder = None; + } + + /// Quitting: stop recording and wait for the last file to be closed (dropping the recorder + /// only asks its thread to stop, and the process may end before it has). + pub fn finish_recording(&mut self) { + if let Some(r) = self.recorder.take() { + r.finish(std::time::Duration::from_secs(12)); + } + } + pub fn save_all(&mut self) { if self.link.is_some() { return; diff --git a/src/recorder.rs b/src/recorder.rs new file mode 100644 index 0000000..46e2efb --- /dev/null +++ b/src/recorder.rs @@ -0,0 +1,492 @@ +//! Show recording (a logger): the on-air mix is saved to a folder, one file per hour (or another +//! length), lined up with the clock, as proof of broadcast. Files go into a folder per day: +//! `/2026-11-01/2026-11-01 14-00.mp3`. Recordings older than `keep_days` are deleted. +//! Everything here runs on its own thread; the UI only reads `status`. + +use crate::config::{data_dir, RecFormat, RecordCfg}; +use crate::engine::{Engine, SR}; +use crate::stream::{find_ffmpeg, mp3_encode, mp3_encoder, PipeEncoder}; +use anyhow::{anyhow, Context, Result}; +use chrono::{Duration as CDuration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone}; +use crossbeam_channel::{bounded, RecvTimeoutError}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::{ + fs::File, + io::{BufWriter, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::{Duration, Instant}, +}; + +/// What the recorder is doing (also sent to apps connected to a RadioMaster Server). +#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] +#[serde(default)] +pub struct RecStatus { + /// "starting" (no sound from the mixer yet), "recording", "waiting" (outside the days / hours + /// set), "error" or "stopped" + pub state: String, + /// the file being written + pub file: String, + /// when this file started and when the next one starts (unix seconds) + pub seg_start: i64, + pub seg_end: i64, + /// seconds of sound in this file + pub secs: f64, + pub bytes: u64, + /// the last problem (empty when there is none) + pub error: String, + /// files finished since recording started + pub files_done: u32, +} + +pub struct Recorder { + stop: Arc, + /// set when the thread has closed its file and ended + done: Arc, + pub status: Arc>, + /// the settings it was started with + pub cfg: RecordCfg, + pub ffmpeg: String, +} + +impl Recorder { + /// `ffmpeg`: the ffmpeg program for FLAC ("" = look for it). + pub fn start(cfg: RecordCfg, engine: &Engine, ffmpeg: String) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let done = Arc::new(AtomicBool::new(false)); + let status = Arc::new(Mutex::new(RecStatus { state: "starting".into(), ..Default::default() })); + // about 45 s of sound: enough to ride out a slow network folder + let (tx, rx) = bounded::>>(2048); + engine.add_tap(tx); + let (s2, d2, st2, c2, f2) = (stop.clone(), done.clone(), status.clone(), cfg.clone(), ffmpeg.clone()); + thread::spawn(move || { + run(&c2, &f2, rx, &s2, &st2); + d2.store(true, Ordering::Relaxed); + }); + Recorder { stop, done, status, cfg, ffmpeg } + } + + pub fn stop(&self) { + self.stop.store(true, Ordering::Relaxed); + } + + /// Stop and wait (up to `wait`) for the file being written to be closed. For quitting: the + /// process ending would otherwise lose the last seconds still buffered. + pub fn finish(&self, wait: Duration) { + self.stop(); + let until = Instant::now() + wait; + while !self.done.load(Ordering::Relaxed) && Instant::now() < until { + thread::sleep(Duration::from_millis(20)); + } + } +} + +impl Drop for Recorder { + fn drop(&mut self) { + self.stop(); + } +} + +fn run(cfg: &RecordCfg, ffmpeg: &str, rx: crossbeam_channel::Receiver>>, stop: &AtomicBool, status: &Mutex) { + let mut seg: Option = None; + // after a file couldn't be opened, wait a little before trying again + let mut retry_at: Option = None; + loop { + if stop.load(Ordering::Relaxed) { + break; + } + let pcm = match rx.recv_timeout(Duration::from_millis(250)) { + Ok(p) => p, + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => break, + }; + let now = Local::now().naive_local(); + if !cfg.active_at(now) { + if let Some(s) = seg.take() { + finish(s, status); + } + let mut st = status.lock(); + if st.state != "waiting" { + st.state = "waiting".into(); + st.file.clear(); + st.secs = 0.0; + st.bytes = 0; + } + continue; + } + let (start, end) = segment_bounds(now, cfg.segment()); + if seg.as_ref().is_some_and(|s| s.start != start) { + if let Some(s) = seg.take() { + finish(s, status); + } + } + if seg.is_none() && retry_at.is_none_or(|t| Instant::now() >= t) { + match Segment::open(cfg, ffmpeg, start) { + Ok((s, note)) => { + retry_at = None; + let mut st = status.lock(); + st.state = "recording".into(); + st.file = s.path.display().to_string(); + st.seg_start = unix(start); + st.seg_end = unix(end); + st.secs = 0.0; + st.bytes = 0; + st.error = note; + drop(st); + seg = Some(s); + if cfg.keep_days > 0 { + let (dir, days) = (cfg.folder_path(), cfg.keep_days); + thread::spawn(move || delete_old(&dir, now.date() - CDuration::days(days as i64))); + } + } + Err(e) => { + retry_at = Some(Instant::now() + Duration::from_secs(10)); + let mut st = status.lock(); + st.state = "error".into(); + st.error = format!("{e:#}"); + } + } + } + if let Some(s) = seg.as_mut() { + match s.write(&pcm) { + Ok(()) => { + let mut st = status.lock(); + st.secs = s.frames as f64 / SR as f64; + st.bytes = s.bytes; + } + Err(e) => { + seg = None; + retry_at = Some(Instant::now() + Duration::from_secs(10)); + let mut st = status.lock(); + st.state = "error".into(); + st.error = format!("{e:#}"); + } + } + } + } + if let Some(s) = seg.take() { + finish(s, status); + } + status.lock().state = "stopped".into(); +} + +fn finish(s: Segment, status: &Mutex) { + match s.close() { + Ok(()) => status.lock().files_done += 1, + Err(e) => status.lock().error = format!("{e:#}"), + } +} + +fn unix(t: NaiveDateTime) -> i64 { + Local.from_local_datetime(&t).earliest().map(|d| d.timestamp()).unwrap_or_else(|| t.and_utc().timestamp()) +} + +/// The file that `now` falls in: `mins` long, lined up with midnight, never running into the next day. +pub fn segment_bounds(now: NaiveDateTime, mins: u32) -> (NaiveDateTime, NaiveDateTime) { + use chrono::Timelike; + let mins = mins.clamp(1, 1440); + let day = now.date(); + let m = now.hour() * 60 + now.minute(); + let start_m = m / mins * mins; + let start = day.and_time(NaiveTime::from_hms_opt(start_m / 60, start_m % 60, 0).unwrap_or_default()); + let end = (start + CDuration::minutes(mins as i64)).min(day.and_time(NaiveTime::MIN) + CDuration::days(1)); + (start, end) +} + +/// Where the file for a segment starting at `start` goes: a folder per day, and a number added when +/// that name is taken (RadioMaster was restarted during the hour). +pub fn file_for(dir: &Path, start: NaiveDateTime, ext: &str) -> PathBuf { + let day = dir.join(start.format("%Y-%m-%d").to_string()); + let base = start.format("%Y-%m-%d %H-%M").to_string(); + let mut p = day.join(format!("{base}.{ext}")); + let mut n = 2; + while p.exists() { + p = day.join(format!("{base} ({n}).{ext}")); + n += 1; + } + p +} + +/// The date of a recording made by RadioMaster, from its file name ("2026-11-01 14-00.mp3", +/// "2026-11-01 14-00 (2).wav"); None for any other file. +pub fn recording_date(name: &str) -> Option { + let (stem, ext) = name.rsplit_once('.')?; + if !RecFormat::ALL.iter().any(|f| f.extension().eq_ignore_ascii_case(ext)) { + return None; + } + let date = NaiveDate::parse_from_str(stem.get(..10)?, "%Y-%m-%d").ok()?; + NaiveTime::parse_from_str(stem.get(11..16)?, "%H-%M").ok()?; + if stem.as_bytes().get(10) != Some(&b' ') { + return None; + } + Some(date) +} + +/// Delete RadioMaster's recordings from before `before` (only files it named itself, in the day +/// folders it made), and day folders left empty. +fn delete_old(dir: &Path, before: NaiveDate) { + let Ok(days) = std::fs::read_dir(dir) else { return }; + for d in days.flatten() { + let name = d.file_name().to_string_lossy().to_string(); + let Ok(day) = NaiveDate::parse_from_str(&name, "%Y-%m-%d") else { continue }; + if day >= before || !d.path().is_dir() { + continue; + } + let Ok(files) = std::fs::read_dir(d.path()) else { continue }; + for f in files.flatten() { + if recording_date(&f.file_name().to_string_lossy()).is_some_and(|fd| fd < before) { + let _ = std::fs::remove_file(f.path()); + } + } + let _ = std::fs::remove_dir(d.path()); // only goes when empty + } +} + +enum Out { + Mp3(Box), + Flac(PipeEncoder), + Wav { last_header: Instant }, +} + +struct Segment { + start: NaiveDateTime, + path: PathBuf, + file: BufWriter, + out: Out, + frames: u64, + bytes: u64, + buf: Vec, +} + +impl Segment { + /// Open the file in the chosen folder, or in the data folder's "recordings" when the chosen one + /// can't be written (a network folder that is offline). The note says when that happened. + fn open(cfg: &RecordCfg, ffmpeg: &str, start: NaiveDateTime) -> Result<(Segment, String)> { + let ext = cfg.format.extension(); + let chosen = cfg.folder_path(); + let (file, path, note) = match create(&file_for(&chosen, start, ext)) { + Ok((f, p)) => (f, p, String::new()), + Err(e) if !cfg.folder.trim().is_empty() => { + let fallback = data_dir().join("recordings"); + let (f, p) = create(&file_for(&fallback, start, ext))?; + (f, p, format!("Can't write to {} ({e:#}), so this hour is recorded in {}", chosen.display(), fallback.display())) + } + Err(e) => return Err(e), + }; + let mut file = BufWriter::with_capacity(256 * 1024, file); + let out = match cfg.format { + RecFormat::Mp3 => Out::Mp3(Box::new(mp3_encoder(cfg.bitrate_kbps)?)), + RecFormat::Flac => { + let prog = find_ffmpeg(ffmpeg).ok_or_else(|| { + anyhow!("FLAC recordings need ffmpeg: put ffmpeg next to RadioMaster or choose it in Streams → ffmpeg") + })?; + let sr = SR.to_string(); + let args: Vec = [ + "-hide_banner", + "-loglevel", + "error", + "-f", + "s16le", + "-ar", + &sr, + "-ac", + "2", + "-i", + "-", + "-c:a", + "flac", + "-f", + "flac", + "-", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + Out::Flac(PipeEncoder::start(&prog.to_string_lossy(), &args)?) + } + RecFormat::Wav => { + file.write_all(&wav_header(0))?; + Out::Wav { last_header: Instant::now() } + } + }; + Ok((Segment { start, path, file, out, frames: 0, bytes: 0, buf: Vec::new() }, note)) + } + + fn write(&mut self, pcm: &[i16]) -> Result<()> { + self.buf.clear(); + match &mut self.out { + Out::Mp3(enc) => mp3_encode(enc, pcm, &mut self.buf)?, + Out::Flac(p) => p.encode(pcm, &mut self.buf)?, + Out::Wav { .. } => self.buf.extend(pcm.iter().flat_map(|s| s.to_le_bytes())), + } + self.file.write_all(&self.buf).with_context(|| format!("can't write {}", self.path.display()))?; + self.bytes += self.buf.len() as u64; + self.frames += pcm.len() as u64 / 2; + // keep a WAV file's length right every few seconds, so it plays even if RadioMaster stops suddenly + if let Out::Wav { last_header } = &mut self.out { + if last_header.elapsed() > Duration::from_secs(10) { + *last_header = Instant::now(); + fix_wav_header(&mut self.file, self.bytes)?; + } + } + Ok(()) + } + + fn close(mut self) -> Result<()> { + self.buf.clear(); + match &mut self.out { + Out::Mp3(enc) => { + self.buf.reserve(7200); + let _ = enc.flush_to_vec::(&mut self.buf); + } + Out::Flac(p) => p.finish(&mut self.buf, Duration::from_secs(10)), + Out::Wav { .. } => {} + } + self.file.write_all(&self.buf)?; + self.bytes += self.buf.len() as u64; + if let Out::Wav { .. } = self.out { + fix_wav_header(&mut self.file, self.bytes)?; + } + self.file.flush()?; + if self.bytes == 0 { + let _ = std::fs::remove_file(&self.path); + } + Ok(()) + } +} + +fn create(p: &Path) -> Result<(File, PathBuf)> { + if let Some(d) = p.parent() { + std::fs::create_dir_all(d).with_context(|| format!("can't make the folder {}", d.display()))?; + } + let f = File::create(p).with_context(|| format!("can't make {}", p.display()))?; + Ok((f, p.to_path_buf())) +} + +/// A 44-byte header for 16-bit stereo PCM at `SR` with `data` bytes of sound. +fn wav_header(data: u32) -> Vec { + let mut h = Vec::with_capacity(44); + h.extend_from_slice(b"RIFF"); + h.extend_from_slice(&data.saturating_add(36).to_le_bytes()); + h.extend_from_slice(b"WAVEfmt "); + h.extend_from_slice(&16u32.to_le_bytes()); + h.extend_from_slice(&1u16.to_le_bytes()); // PCM + h.extend_from_slice(&2u16.to_le_bytes()); + h.extend_from_slice(&SR.to_le_bytes()); + h.extend_from_slice(&(SR * 4).to_le_bytes()); + h.extend_from_slice(&4u16.to_le_bytes()); + h.extend_from_slice(&16u16.to_le_bytes()); + h.extend_from_slice(b"data"); + h.extend_from_slice(&data.to_le_bytes()); + h +} + +fn fix_wav_header(file: &mut BufWriter, data: u64) -> Result<()> { + file.flush()?; + let f = file.get_mut(); + f.seek(SeekFrom::Start(0))?; + f.write_all(&wav_header(data.min(u32::MAX as u64 - 36) as u32))?; + f.seek(SeekFrom::End(0))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(s: &str) -> NaiveDateTime { + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").unwrap() + } + + #[test] + fn files_line_up_with_the_clock() { + assert_eq!(segment_bounds(at("2026-11-01 14:37:12"), 60), (at("2026-11-01 14:00:00"), at("2026-11-01 15:00:00"))); + assert_eq!(segment_bounds(at("2026-11-01 14:37:12"), 15), (at("2026-11-01 14:30:00"), at("2026-11-01 14:45:00"))); + assert_eq!(segment_bounds(at("2026-11-01 23:59:59"), 120), (at("2026-11-01 22:00:00"), at("2026-11-02 00:00:00"))); + assert_eq!(segment_bounds(at("2026-11-01 05:00:00"), 1440), (at("2026-11-01 00:00:00"), at("2026-11-02 00:00:00"))); + } + + #[test] + fn segment_lengths_divide_the_day() { + let mut c = RecordCfg { segment_mins: 50, ..Default::default() }; + assert_eq!(c.segment(), 30); + c.segment_mins = 1440; + assert_eq!(c.segment(), 1440); + c.format = RecFormat::Wav; + assert_eq!(c.segment(), 360, "a WAV file stays under 4 GB"); + } + + #[test] + fn only_recordings_are_recognised() { + assert_eq!(recording_date("2026-11-01 14-00.mp3"), NaiveDate::from_ymd_opt(2026, 11, 1)); + assert_eq!(recording_date("2026-11-01 14-00 (2).WAV"), NaiveDate::from_ymd_opt(2026, 11, 1)); + assert_eq!(recording_date("2026-11-01 14-00.txt"), None); + assert_eq!(recording_date("my show.mp3"), None); + assert_eq!(recording_date("2026-11-01_14-00.mp3"), None); + } + + #[test] + fn records_hourly_files_and_deletes_old_ones() { + let dir = std::env::temp_dir().join(format!("rm-rec-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let cfg = RecordCfg { enabled: true, folder: dir.display().to_string(), format: RecFormat::Wav, ..Default::default() }; + let start = at("2026-11-01 14:00:00"); + let (mut s, note) = Segment::open(&cfg, "", start).unwrap(); + assert!(note.is_empty()); + let pcm = vec![1000i16; 2048]; + for _ in 0..10 { + s.write(&pcm).unwrap(); + } + let path = s.path.clone(); + s.close().unwrap(); + assert_eq!(path, dir.join("2026-11-01").join("2026-11-01 14-00.wav")); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.len(), 44 + 10 * 2048 * 2); + assert_eq!(u32::from_le_bytes(bytes[40..44].try_into().unwrap()), 10 * 2048 * 2); + // a restart during the hour makes a second file instead of overwriting the first + assert_eq!(file_for(&dir, start, "wav"), dir.join("2026-11-01").join("2026-11-01 14-00 (2).wav")); + + // MP3 + let mp3 = RecordCfg { format: RecFormat::Mp3, bitrate_kbps: 64, ..cfg.clone() }; + let (mut s, _) = Segment::open(&mp3, "", at("2026-11-02 09:00:00")).unwrap(); + for _ in 0..50 { + s.write(&pcm).unwrap(); + } + let mp3_path = s.path.clone(); + s.close().unwrap(); + assert!(std::fs::metadata(&mp3_path).unwrap().len() > 1000); + + let other = dir.join("2026-11-01").join("notes.txt"); + std::fs::write(&other, "keep me").unwrap(); + delete_old(&dir, NaiveDate::from_ymd_opt(2026, 11, 2).unwrap()); + assert!(!path.exists(), "the old recording goes"); + assert!(other.exists(), "other files stay"); + assert!(mp3_path.exists(), "newer recordings stay"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_flac_through_ffmpeg() { + if find_ffmpeg("").is_none() { + return; // no ffmpeg on this machine + } + let dir = std::env::temp_dir().join(format!("rm-rec-flac-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let cfg = RecordCfg { enabled: true, folder: dir.display().to_string(), format: RecFormat::Flac, ..Default::default() }; + let (mut s, _) = Segment::open(&cfg, "", at("2026-11-01 08:00:00")).unwrap(); + let pcm: Vec = (0..2048).map(|i| ((i as f32 * 0.05).sin() * 8000.0) as i16).collect(); + for _ in 0..100 { + s.write(&pcm).unwrap(); + } + let path = s.path.clone(); + s.close().unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(&bytes[..4], b"fLaC"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/recui.rs b/src/recui.rs new file mode 100644 index 0000000..29fb330 --- /dev/null +++ b/src/recui.rs @@ -0,0 +1,219 @@ +//! Show recording: what the recorder is doing and its settings (Tools → Show recorder, and +//! Settings → Show recording). The recording itself runs in the background (see recorder.rs). +use super::*; +use crate::config::{RecFormat, SEGMENT_MINS}; +use chrono::TimeZone; + +const MP3_RATES: [u32; 7] = [64, 96, 128, 160, 192, 256, 320]; + +fn mins_label(m: u32) -> String { + match m { + 60 => tr("1 hour").to_string(), + m if m % 60 == 0 => format!("{} {}", m / 60, tr("hours")), + m => format!("{m} {}", tr("minutes")), + } +} + +fn size_label(bytes: f64) -> String { + if bytes >= 1e9 { + format!("{:.1} GB", bytes / 1e9) + } else if bytes >= 10e6 { + format!("{:.0} MB", bytes / 1e6) + } else { + format!("{:.1} MB", bytes / 1e6) + } +} + +/// Roughly how many bytes an hour of recording takes. +fn bytes_per_hour(f: RecFormat, kbps: u32) -> f64 { + match f { + RecFormat::Mp3 => kbps as f64 * 1000.0 / 8.0 * 3600.0, + RecFormat::Flac => 340e6, + RecFormat::Wav => crate::engine::SR as f64 * 4.0 * 3600.0, + } +} + +impl App { + pub(super) fn tab_recording(&mut self, ui: &mut egui::Ui, core: &mut Playout) { + self.recording_status(ui, core); + ui.separator(); + let connected = self.srv.connected(); + let rec = &mut core.cfg.recording; + ui.checkbox(&mut rec.enabled, RichText::new(tr("Record the station's output")).strong()); + ui.label( + RichText::new(tr( + "Everything that goes on air is saved, one file per hour (or the length chosen below), as proof of broadcast. It records in the background while RadioMaster runs.", + )) + .small() + .color(GRAY), + ); + if connected { + ui.label( + RichText::new(tr("You're connected to a RadioMaster Server: the server records, into a folder on the server.")) + .small() + .color(AMBER), + ); + } + ui.add_space(6.0); + let mut pick = false; + egui::Grid::new("rec_settings").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tr("Folder")); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut rec.folder) + .desired_width(320.0) + .hint_text(data_dir().join("recordings").display().to_string()), + ); + pick = ui.button(tr("Choose…")).clicked(); + }); + ui.label( + RichText::new(tr( + "A folder on this computer, a network folder (\\\\server\\share) or a cloud folder that syncs (OneDrive, Dropbox, Google Drive). Each day gets its own folder. If the folder can't be written, that hour goes into the data folder's recordings folder instead.", + )) + .small() + .color(GRAY), + ); + }); + ui.end_row(); + + ui.label(tr("Format")); + egui::ComboBox::from_id_salt("rec_format").selected_text(tr(rec.format.label())).width(320.0).show_ui(ui, |ui| { + for f in RecFormat::ALL { + ui.selectable_value(&mut rec.format, f, tr(f.label())); + } + }); + ui.end_row(); + + if rec.format == RecFormat::Mp3 { + ui.label(tr("Bitrate")); + egui::ComboBox::from_id_salt("rec_bitrate").selected_text(format!("{} kbps", rec.bitrate_kbps)).show_ui(ui, |ui| { + for k in MP3_RATES { + ui.selectable_value(&mut rec.bitrate_kbps, k, format!("{k} kbps")); + } + }); + ui.end_row(); + } + + ui.label(tr("Each file")); + let seg = rec.segment(); + egui::ComboBox::from_id_salt("rec_segment").selected_text(mins_label(seg)).show_ui(ui, |ui| { + for m in SEGMENT_MINS { + if rec.format == RecFormat::Wav && m > 360 { + continue; // a WAV file can't be bigger than 4 GB + } + if ui.selectable_label(seg == m, mins_label(m)).clicked() { + rec.segment_mins = m; + } + } + }); + ui.end_row(); + + ui.label(tr("Keep")); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut rec.keep_days).range(0..=3650).suffix(format!(" {}", tr("days")))); + ui.label( + RichText::new(if rec.keep_days == 0 { + tr("0: recordings are never deleted") + } else { + tr("older recordings are deleted (only RadioMaster's own files)") + }) + .small() + .color(GRAY), + ); + }); + ui.end_row(); + + ui.label(tr("Days")); + super::adsui::days_edit(ui, &mut rec.days); + ui.end_row(); + + ui.label(tr("Hours")); + ui.horizontal(|ui| { + let mut all_day = rec.from_hour == rec.to_hour; + if ui.checkbox(&mut all_day, tr("all day")).changed() { + (rec.from_hour, rec.to_hour) = if all_day { (0, 0) } else { (6, 22) }; + } + if !all_day { + ui.label(tr("from")); + ui.add(egui::DragValue::new(&mut rec.from_hour).range(0..=23).custom_formatter(|v, _| format!("{:02}:00", v as u32))); + ui.label(tr("until")); + ui.add(egui::DragValue::new(&mut rec.to_hour).range(0..=24).custom_formatter(|v, _| format!("{:02}:00", v as u32))); + if rec.to_hour == 24 { + rec.to_hour = 0; + } + } + }); + ui.end_row(); + }); + let per_hour = bytes_per_hour(rec.format, rec.bitrate_kbps); + let hours_a_day = if rec.from_hour == rec.to_hour { 24 } else { (rec.to_hour + 24 - rec.from_hour) % 24 }; + let days_a_week = rec.days.iter().filter(|d| **d).count() as f64; + let mut size = format!( + "{} {} {}, {} {}", + tr("About"), + size_label(per_hour), + tr("an hour"), + size_label(per_hour * hours_a_day as f64), + tr("a day") + ); + if rec.keep_days > 0 { + let total = per_hour * hours_a_day as f64 * days_a_week / 7.0 * rec.keep_days as f64; + size = format!("{size}; {} {}", size_label(total), tr("for all the days kept")); + } + ui.add_space(4.0); + ui.label(RichText::new(size).small().color(GRAY)); + if pick { + self.pick(Pick::Folder, tr("Recordings folder"), |_, v| Some(Dlg::RecFolder(v.into_iter().next()?))); + } + } + + /// The recorder's state: the file being written and how far into it. + fn recording_status(&mut self, ui: &mut egui::Ui, core: &Playout) { + ui.ctx().request_repaint_after(std::time::Duration::from_secs(1)); + let Some(st) = core.recording() else { + let pending = core.cfg.recording.enabled; + ui.label(RichText::new(if pending { tr("Starting…") } else { tr("Not recording") }).strong().color(GRAY)); + return; + }; + let (text, color) = match st.state.as_str() { + "recording" => (tr("● Recording"), RED), + "waiting" => (tr("Waiting: outside the days and hours set"), GRAY), + "starting" => (tr("Starting: waiting for sound from the mixer"), GRAY), + "error" => (tr("Not recording"), AMBER), + _ => (tr("Not recording"), GRAY), + }; + ui.label(RichText::new(text).strong().size(18.0).color(color)); + if st.state == "recording" { + let name = std::path::Path::new(&st.file).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + ui.label(RichText::new(name).monospace()); + let now = core.now().timestamp(); + let len = (st.seg_end - st.seg_start).max(1); + let done = ((now - st.seg_start) as f32 / len as f32).clamp(0.0, 1.0); + let at = |t: i64| Local.timestamp_opt(t, 0).single().map(|d| d.format("%H:%M").to_string()).unwrap_or_default(); + ui.add(egui::ProgressBar::new(done).desired_width(ui.available_width().min(460.0)).text(format!( + "{} – {} · {} {}", + at(st.seg_start), + at(st.seg_end), + fmt(st.secs as f32), + tr("recorded") + ))); + ui.label( + RichText::new(format!("{} · {} {}", size_label(st.bytes as f64), st.files_done, tr("files finished since it started"))) + .small() + .color(GRAY), + ); + } + if !st.error.is_empty() { + ui.label(RichText::new(&st.error).small().color(AMBER)); + } + if !self.srv.connected() && ui.button(tr("📁 Open the recordings folder")).clicked() { + let dir = std::path::Path::new(&st.file) + .parent() + .and_then(|p| p.parent()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| core.cfg.recording.folder_path()); + crate::plugins::open_folder(&dir); + } + } +} diff --git a/src/server_main.rs b/src/server_main.rs index dc945f7..bf648d2 100644 --- a/src/server_main.rs +++ b/src/server_main.rs @@ -20,6 +20,7 @@ mod live; mod mic; mod playout; mod plugins; +mod recorder; mod remote; mod scheduler; mod selfupdate; @@ -570,6 +571,7 @@ fn main() { for i in 0..c.cfg.encoders.len() { c.stop_encoder(i); } + c.finish_recording(); c.save_all(); c.plugins.stop_services(); drop(c); diff --git a/src/serverui.rs b/src/serverui.rs index 7b1a2c5..c5bda8e 100644 --- a/src/serverui.rs +++ b/src/serverui.rs @@ -449,6 +449,7 @@ impl App { } core.stop(); core.stop_carts(); + core.stop_recording(); self.mic_stream = None; self.mic_on = false; core.save_all(); diff --git a/src/stream.rs b/src/stream.rs index d3186b0..8264a0c 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -288,7 +288,7 @@ pub fn split_args(line: &str) -> Vec { } /// An encoder program fed through pipes: PCM in, encoded stream out. -struct PipeEncoder { +pub(crate) struct PipeEncoder { child: std::process::Child, pcm_tx: Option>>, out_rx: Receiver>, @@ -296,7 +296,7 @@ struct PipeEncoder { } impl PipeEncoder { - fn start(program: &str, args: &[String]) -> Result { + pub(crate) fn start(program: &str, args: &[String]) -> Result { use std::process::{Command, Stdio}; let mut c = Command::new(program); c.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -341,7 +341,7 @@ impl PipeEncoder { Ok(PipeEncoder { child, pcm_tx: Some(pcm_tx), out_rx, errors }) } - fn encode(&mut self, pcm: &[i16], out: &mut Vec) -> Result<()> { + pub(crate) fn encode(&mut self, pcm: &[i16], out: &mut Vec) -> Result<()> { let bytes: Vec = pcm.iter().flat_map(|s| s.to_le_bytes()).collect(); let gone = self.pcm_tx.as_ref().is_none_or(|tx| tx.send(bytes).is_err()); while let Ok(b) = self.out_rx.try_recv() { @@ -356,6 +356,28 @@ impl PipeEncoder { } } +impl PipeEncoder { + /// End the input and collect the rest of the output (for a file: the encoder writes its last + /// frames when its input ends). Gives up after `wait`. + pub(crate) fn finish(&mut self, out: &mut Vec, wait: Duration) { + self.pcm_tx = None; + let until = std::time::Instant::now() + wait; + loop { + while let Ok(b) = self.out_rx.try_recv() { + out.extend_from_slice(&b); + } + if self.child.try_wait().ok().flatten().is_some() || std::time::Instant::now() > until { + break; + } + thread::sleep(Duration::from_millis(20)); + } + // the output reader ends once the program has closed its output + while let Ok(b) = self.out_rx.recv_timeout(Duration::from_millis(500)) { + out.extend_from_slice(&b); + } + } +} + impl Drop for PipeEncoder { fn drop(&mut self) { self.pcm_tx = None; diff --git a/src/sync.rs b/src/sync.rs index 34b72b7..428d817 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -146,7 +146,7 @@ pub fn cfg_denied(old: &Config, new: &Config, p: &crate::auth::Perms) -> Option< "dsp" | "eq_presets" => p.tool(tool::AUDIO_FX), "azuracast" => p.tool(tool::AZURACAST), "carts" => p.jingles, - "encoders" => p.streaming, + "encoders" | "recording" => p.streaming, "mic_gain" | "mic_duck" | "mic_mode" | "volume" => p.playback, "loop_playlist" | "random_fill" | "hour_lines" | "announce" => p.edit_playlists, _ => p.settings, @@ -299,6 +299,8 @@ pub struct Live { pub now_ms: i64, /// interrupting advert / jingle breaks counting down pub breaks: Vec, + /// the show recorder (None: off, or an older server) + pub recording: Option, } /// One poll's answer. The lists come only when they changed since the revision the app has. @@ -735,6 +737,7 @@ impl Playout { notify: self.notify.lock().clone(), now_ms: chrono::Utc::now().timestamp_millis(), breaks: self.breaks.clone(), + recording: self.recording(), } } @@ -778,6 +781,7 @@ impl Playout { } self.mirror_carts = l.carts; self.breaks = l.breaks; + self.rec_mirror = l.recording; self.scanning = l.scanning; self.scan_progress = l.scan_progress; } diff --git a/src/tools.rs b/src/tools.rs index 5ada6d0..1978511 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -12,6 +12,7 @@ pub(super) enum ToolWin { Ads, AudioFx, Streams, + Recorder, AzuraCast, Reports, History, @@ -23,12 +24,13 @@ pub(super) enum ToolWin { } impl ToolWin { - pub(super) const ALL: [ToolWin; 13] = [ + pub(super) const ALL: [ToolWin; 14] = [ ToolWin::FadeEditor, ToolWin::Generator, ToolWin::Ads, ToolWin::AudioFx, ToolWin::Streams, + ToolWin::Recorder, ToolWin::AzuraCast, ToolWin::Reports, ToolWin::History, @@ -45,7 +47,7 @@ impl ToolWin { ToolWin::FadeEditor => p.tool(tool::TRACK_EDITOR), ToolWin::Generator => p.tool(tool::GENERATOR), ToolWin::Ads => p.tool(tool::ADS), - ToolWin::Streams => p.streaming, + ToolWin::Streams | ToolWin::Recorder => p.streaming, ToolWin::AzuraCast => p.tool(tool::AZURACAST), ToolWin::AudioFx => p.tool(tool::AUDIO_FX), ToolWin::Plugins => p.tool(tool::PLUGINS), @@ -65,6 +67,7 @@ impl ToolWin { ToolWin::Ads => "Advertisement scheduler", ToolWin::AudioFx => "Audio processing", ToolWin::Streams => "Streams / encoders", + ToolWin::Recorder => "Show recorder", ToolWin::AzuraCast => "AzuraCast station manager", ToolWin::Reports => "Reports", ToolWin::History => "Play history", @@ -82,6 +85,7 @@ impl ToolWin { ToolWin::Ads => "📢", ToolWin::AudioFx => "🔈", ToolWin::Streams => "📡", + ToolWin::Recorder => "⏺", ToolWin::AzuraCast => "☁", ToolWin::Reports => "📊", ToolWin::History => "📜", @@ -99,6 +103,7 @@ impl ToolWin { ToolWin::Ads => [1000.0, 600.0], ToolWin::AudioFx => [760.0, 560.0], ToolWin::Streams => [820.0, 520.0], + ToolWin::Recorder => [700.0, 620.0], ToolWin::AzuraCast => [900.0, 560.0], ToolWin::Reports => [760.0, 480.0], ToolWin::History => [560.0, 480.0], @@ -393,6 +398,7 @@ impl App { ToolWin::Ads => self.tab_ads(ui, core), ToolWin::AudioFx => self.tab_dsp(ui, core), ToolWin::Streams => self.tab_streaming(ui, core), + ToolWin::Recorder => self.tab_recording(ui, core), ToolWin::Notepad => self.tab_segment(ui, core), ToolWin::Plugins => self.tab_plugins(ui, core), ToolWin::Scheduler => self.tab_scheduler(ui, core),