Merge extracted audiotater branch
This commit is contained in:
commit
232fb69de9
3 changed files with 483 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -13,4 +13,3 @@ Cargo.lock
|
|||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
|
|
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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
467
src/main.rs
Normal 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
|
||||
}
|
Loading…
Reference in a new issue