Merge extracted audiotater branch

This commit is contained in:
Josh Hansen 2023-06-27 15:47:15 -07:00
commit 232fb69de9
3 changed files with 483 additions and 1 deletions

1
.gitignore vendored
View file

@ -13,4 +13,3 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

16
Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "podcast-adblock"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.3.0", features = ["derive"] }
cpal = "0.15.2"
crossterm = "0.26.1"
magic = "0.13.0"
minimp3 = "0.5.1"
rand = "0.8.5"
serde = { version = "1.0.163", features = ["derive"] }
serde_json = "1.0.96"
simple-mutex = "1.1.5"
xattr = "1.0.0"

467
src/main.rs Normal file
View file

@ -0,0 +1,467 @@
use std::fs::File;
use std::io::{stdout, Write};
use std::path::PathBuf;
use std::process::exit;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{mpsc, Arc};
use std::thread::{self, sleep};
use std::time::Duration;
use clap::Parser;
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::{self, traits::HostTrait};
use cpal::{OutputCallbackInfo, StreamConfig};
use crossterm::cursor::{Hide, MoveTo, MoveToColumn, MoveToNextLine, Show};
use crossterm::event::{Event, KeyCode, KeyModifiers, MediaKeyCode};
use crossterm::style::{Print, PrintStyledContent, Stylize};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
};
use crossterm::{execute, queue};
use minimp3::Decoder;
use rand::{seq::SliceRandom, thread_rng};
use serde::Serialize;
use simple_mutex::Mutex as FairMutex;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
path: Vec<PathBuf>,
#[arg(short = 'b', long = "bufsize", default_value_t = 1000000000)]
initial_buffer_size: usize,
}
#[derive(Debug, PartialEq, Serialize)]
enum Class {
/// A third-party advertisement, including those read by the host
Advertisement,
// Not incidental music: the main content
Music,
// Including incidental music, self-promos, and main content
Speech,
}
impl Class {
fn sym(&self) -> char {
match self {
Self::Advertisement => '🥫',
Self::Music => '🎶',
Self::Speech => '💬',
}
}
}
/// Input thread commands sent to main thread
#[derive(Debug, PartialEq)]
enum Command {
DisplayHelp,
FastForward,
Rewind,
AnnotateSince(Class),
AnnotateEntireFile(Class),
// AnnotateYesNow,
// AnnotateNoNow,
NextFile,
Play,
Pause,
PlayPause,
Quit,
TogglePlaybackSpeed,
Undo,
}
impl TryFrom<char> for Command {
type Error = ();
fn try_from(c: char) -> Result<Self, Self::Error> {
match c {
',' => Ok(Self::Rewind),
'.' => Ok(Self::FastForward),
'>' => Ok(Self::NextFile),
'a' => Ok(Self::AnnotateSince(Class::Advertisement)),
'm' => Ok(Self::AnnotateSince(Class::Music)),
's' => Ok(Self::AnnotateSince(Class::Speech)),
'M' => Ok(Self::AnnotateEntireFile(Class::Music)),
'S' => Ok(Self::AnnotateEntireFile(Class::Speech)),
'q' => Ok(Self::Quit),
'u' => Ok(Self::Undo),
' ' => Ok(Self::PlayPause),
'?' => Ok(Self::DisplayHelp),
_ => Err(()),
}
}
}
impl TryFrom<MediaKeyCode> for Command {
type Error = ();
fn try_from(m: MediaKeyCode) -> Result<Self, Self::Error> {
match m {
MediaKeyCode::Play => Ok(Command::Play),
MediaKeyCode::Pause => Ok(Command::Pause),
MediaKeyCode::PlayPause => Ok(Command::PlayPause),
_ => Err(()),
}
}
}
impl TryFrom<Event> for Command {
type Error = ();
fn try_from(e: Event) -> Result<Self, Self::Error> {
match e {
Event::Key(evt)
if evt.code == KeyCode::Char('c')
&& evt.modifiers.contains(KeyModifiers::CONTROL) =>
{
Ok(Command::Quit)
}
Event::Key(evt) => match evt.code {
KeyCode::Char(c) => Command::try_from(c),
KeyCode::Media(media) => Command::try_from(media),
KeyCode::Pause => Ok(Command::PlayPause),
_ => Err(()),
},
_ => Err(()),
}
}
}
#[derive(Serialize)]
struct Annotation {
filename: String,
start: usize,
end: usize,
class: Class,
}
impl Annotation {
fn contains(&self, idx: usize) -> bool {
self.start <= idx && idx <= self.end
}
}
impl std::fmt::Display for Annotation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "{}{}:{} {:?}", self.class.sym(), self.start, self.end, self.class)
}
}
const MOVEMENT_DURATION_SECONDS: usize = 5;
fn main() {
enable_raw_mode().unwrap();
let mut stdout = stdout();
execute!(stdout, Hide, EnterAlternateScreen).unwrap();
let mut cli = Cli::parse();
let (input_tx, input_rx): (Sender<Command>, Receiver<Command>) = mpsc::channel();
let _input_thread = thread::spawn(move || loop {
let evt = crossterm::event::read().unwrap();
if let Ok(cmd) = Command::try_from(evt) {
input_tx.send(cmd).unwrap();
}
});
let mut rng = thread_rng();
cli.path.shuffle(&mut rng);
let mut path_iter = cli.path.into_iter();
let path: Arc<FairMutex<Option<PathBuf>>> = Arc::new(FairMutex::new(None));
let idx: Arc<FairMutex<usize>> = Arc::new(FairMutex::new(0));
let pending_movement: Arc<FairMutex<isize>> = Arc::new(FairMutex::new(0));
let mut paused = false;
let buf: Arc<FairMutex<Vec<i16>>> =
Arc::new(FairMutex::new(Vec::with_capacity(cli.initial_buffer_size)));
let host = cpal::default_host();
let device = host
.default_output_device()
.expect("failed to find output device");
let config: StreamConfig = device.default_output_config().unwrap().into();
let channels: Arc<FairMutex<usize>> = Arc::new(FairMutex::new(2));
let sample_rate: Arc<FairMutex<usize>> = Arc::new(FairMutex::new(44100));
let stream = {
let idx = Arc::clone(&idx);
let buf = Arc::clone(&buf);
let channels = Arc::clone(&channels);
let pending_movement = Arc::clone(&pending_movement);
let sample_rate = Arc::clone(&sample_rate);
device
.build_output_stream(
&config,
move |data: &mut [i16], _: &OutputCallbackInfo| {
let channels = *channels.lock();
// Sleep until the buffer has been sufficiently populated
{
let idx = *idx.lock();
while {
let buf = buf.lock();
buf.is_empty() || buf.len() < idx
} {
sleep(Duration::from_millis(50));
}
}
// Step through sample by sample, taking channel interleaving into account
for out_frame in data.chunks_mut(channels) {
// Get the current playback index, adjusted for any pending movements
let mut idx = {
let mut movement = pending_movement.lock();
let mut idx = idx.lock();
let sample_rate = *sample_rate.lock() as isize;
let samples_moved: isize = *movement
* channels as isize
* MOVEMENT_DURATION_SECONDS as isize
* sample_rate;
*idx = (*idx).saturating_add_signed(samples_moved);
*movement = 0;
idx
};
let buf = buf.lock();
out_frame[0] = buf[*idx];
out_frame[1] = buf[*idx + 1];
*idx += 2;
}
},
|err| {
execute!(std::io::stdout(), Print("Audio stream error"), Print(err)).unwrap();
},
None,
)
.unwrap()
};
// Multiplier that determines rate of playback
let mut playback_speed: usize = 1;
let _dataload_thread = {
let buf = Arc::clone(&buf);
let path = Arc::clone(&path);
thread::spawn(move || {
let mut stdout = std::io::stdout();
let cookie = magic::Cookie::open(magic::CookieFlags::MIME_TYPE).unwrap();
cookie.load(&vec!["/usr/share/misc/magic.mgc"]).unwrap();
while buf.lock().is_empty() {
let mut path = path.lock();
let pathbuf: PathBuf = path_iter
.find(|p| cookie.file(p).unwrap() == "audio/mpeg")
.unwrap();
*path = Some(pathbuf);
execute!(
stdout,
MoveToNextLine(1),
MoveToColumn(0),
Print("🔊"),
Print(path.as_ref().unwrap().display())
)
.unwrap();
let file = File::open(path.as_ref().unwrap()).unwrap();
let mut mp3dec = Decoder::new(file);
{
buf.lock().clear();
let mut is_first = true;
while let Ok(frame) = mp3dec.next_frame() {
buf.lock().extend_from_slice(&frame.data);
// Get settings only from the first frame
if is_first {
is_first = false;
let mut channels = channels.lock();
*channels = frame.channels;
let mut sample_rate = sample_rate.lock();
*sample_rate = frame.sample_rate as usize;
}
}
}
} // while buf empty
thread::sleep(Duration::from_millis(50));
})
};
// The annotations made thus far, kept in order of start index
// The timespans covered must be disjoint
let mut annotations: Vec<Annotation> = Vec::new();
/// Check that the annotations fully cover the file
fn annotation_complete(annotations: &Vec<Annotation>, buf_len: usize) -> bool {
if annotations.is_empty() {
return false;
}
if annotations[0].start != 0 {
return false;
}
for (i, a) in annotations.iter().enumerate().skip(1) {
if a.start != annotations[i - 1].end {
return false;
}
}
if annotations[annotations.len() - 1].end != buf_len {
return false;
}
true
}
execute!(
stdout,
Clear(ClearType::All),
MoveTo(0, 0),
PrintStyledContent("🥔audiotater".underline_magenta()),
MoveToNextLine(1),
)
.unwrap();
loop {
// Block main thread and react to commands received
match input_rx.recv().unwrap() {
Command::Rewind => {
*pending_movement.lock() -= 1;
}
Command::FastForward => {
*pending_movement.lock() += 1;
}
Command::AnnotateSince(class) => {
let end = *idx.lock();
execute!(
stdout,
MoveToNextLine(1),
MoveToColumn(0),
Print(format!("Annotated {:?}", class))
)
.unwrap();
let containing_annotation = annotations.iter_mut().find(|a| a.contains(end));
if let Some(containing_annotation) = containing_annotation {
// If we're within an existing annotation, just change its class
containing_annotation.class = class;
} else {
let last_annotation = annotations.last();
let start = last_annotation.map_or(0, |a| a.end);
{
let path_guard = path.lock();
let path = path_guard.as_ref().unwrap();
annotations.push(Annotation {
filename: String::from(path.file_name().unwrap().to_str().unwrap()),
start,
end,
class,
});
if annotation_complete(&annotations, buf.lock().len()) {
let annotation_json = serde_json::to_string(&annotations).unwrap();
xattr::set(
path.as_path(),
"user.ad-annotation",
annotation_json.as_bytes(),
)
.unwrap();
}
}
}
}
Command::Play => {
stream.play().unwrap();
paused = false;
}
Command::Pause => {
stream.pause().unwrap();
paused = true;
}
Command::PlayPause => {
if paused {
stream.play().unwrap();
paused = false;
} else {
stream.pause().unwrap();
paused = true;
}
}
Command::Quit => {
disable_raw_mode().unwrap();
execute!(stdout, MoveToNextLine(2), MoveToColumn(0), Show).unwrap();
exit(0);
}
Command::DisplayHelp => {
execute!(
stdout,
MoveToNextLine(1),
Print(". Fast Forward"),
MoveToNextLine(1),
Print("> Next File"),
MoveToNextLine(1),
Print("a Annotate Since: Ad"),
MoveToNextLine(1),
Print("m Annotate Since: Music"),
MoveToNextLine(1),
Print("s Annotate Since: Speech"),
MoveToNextLine(1),
Print("M Annotate File: Music"),
MoveToNextLine(1),
Print("S Annotate File: Speech"),
MoveToNextLine(1),
Print("Space Play/Pause"),
MoveToNextLine(1),
Print("? Display Help"),
MoveToNextLine(1),
)
.unwrap();
}
Command::Undo => {
if annotations.len() > 0 {
let old = annotations.pop().unwrap();
*idx.lock() = old.start;
}
}
_ => {}
}
queue!(
stdout,
MoveToNextLine(1),
MoveToColumn(0),
Print("Current annotations:")
)
.unwrap();
for a in &annotations[..] {
queue!(stdout, MoveToNextLine(1), Print(a)).unwrap();
}
stdout.flush().unwrap();
} // end loop
}