commit fb51283f5cd5cf37ea6e6d5475c946de79904816 Author: Ivan Bushchik Date: Thu Mar 21 20:26:48 2024 +0300 Initial commit Signed-off-by: Ivan Bushchik diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..212de44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.DS_Store \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7968cf6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "covergen" +version = "0.1.0" +description = "Generate images from music using FFT" +edition = "2021" +license = "MIT" +authors = [ "Ivan Bushchik " ] +repository = "https://github.com/ivabus/covergen" + + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tiny-skia = "0.11.4" +realfft = "3.3.0" +symphonia = { version = "0.5.4", features = ["all-codecs", "all-formats"] } +rand = "0.8.5" +samplerate = "0.2.4" + +[profile.release] +opt-level = 3 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6b2bf59 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024 Ivan Bushchik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5825ff1 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Covergen + +![Example](./imgs/ex1.png) + +## Usage + +```shell +cargo run -r -- [-n] +``` + +`-n` option is used to generate images without background. + +## Examples + +See [imgs](./imgs) directory. + +## License + +The project is licensed under the terms of th [MIT license](./LICENSE). diff --git a/imgs/ex1 no bg.png b/imgs/ex1 no bg.png new file mode 100644 index 0000000..570fbf1 Binary files /dev/null and b/imgs/ex1 no bg.png differ diff --git a/imgs/ex1.png b/imgs/ex1.png new file mode 100644 index 0000000..3777d7b Binary files /dev/null and b/imgs/ex1.png differ diff --git a/imgs/ex2.png b/imgs/ex2.png new file mode 100644 index 0000000..06bb116 Binary files /dev/null and b/imgs/ex2.png differ diff --git a/imgs/ex3.png b/imgs/ex3.png new file mode 100644 index 0000000..99f16b4 Binary files /dev/null and b/imgs/ex3.png differ diff --git a/imgs/ex4.png b/imgs/ex4.png new file mode 100644 index 0000000..1609e96 Binary files /dev/null and b/imgs/ex4.png differ diff --git a/src/decode.rs b/src/decode.rs new file mode 100644 index 0000000..3d7e78e --- /dev/null +++ b/src/decode.rs @@ -0,0 +1,67 @@ +use std::path::Path; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::CODEC_TYPE_NULL; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::probe::Hint; + +pub fn decode_file(file_path: &Path) -> (u32, usize, Vec) { + let file = Box::new(std::fs::File::open(file_path).unwrap()); + let mut hint = Hint::new(); + hint.with_extension(file_path.extension().unwrap().to_str().unwrap()); + + let probed = symphonia::default::get_probe() + .format( + &hint, + MediaSourceStream::new(file, Default::default()), + &Default::default(), + &Default::default(), + ) + .expect("unsupported format"); + + let mut format = probed.format; + + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .expect("no supported audio tracks"); + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &Default::default()) + .expect("unsupported codec"); + let track_id = track.id; + let mut channels = 2; + let mut sample_rate = 0; + let mut samples = vec![]; + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + _ => break, + }; + while !format.metadata().is_latest() { + format.metadata().pop(); + } + + if packet.track_id() != track_id { + continue; + } + + match decoder.decode(&packet) { + Ok(decoded) => { + channels = decoded.spec().channels.count(); + sample_rate = decoded.spec().rate; + let mut byte_buf = + SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()); + byte_buf.copy_interleaved_ref(decoded); + samples.append(&mut byte_buf.samples_mut().to_vec()); + continue; + } + _ => { + // Handling any error as track skip + continue; + } + } + } + (sample_rate, channels, samples) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..82ce8b4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,268 @@ +mod decode; + +use rand::Rng; +use std::path::{Path, PathBuf}; +use std::time::Instant; +use tiny_skia::*; + +use samplerate::{convert, ConverterType}; + +use decode::decode_file; + +const SMOOTH_FACTOR: usize = 640; +const OSIZE: usize = 2048; + +fn min(v: &Vec) -> f32 { + let mut m = f32::MAX; + for i in v { + if *i < m { + m = *i + } + } + m +} +fn max(v: &Vec) -> f32 { + let mut m = f32::MIN; + for i in v { + if *i > m { + m = *i + } + } + m +} + +fn mid(k: &[f32]) -> f32 { + let mut sum = 0f32; + for i in k { + sum += *i; + } + sum / k.len() as f32 +} + +fn main() { + let mut args = std::env::args(); + args.next(); + let mut no_background = false; + let fa = args.next().unwrap(); + let input_path; + if fa == "-n" { + no_background = true; + input_path = args.next().unwrap(); + } else { + input_path = fa; + } + + let start_decode = Instant::now(); + let (sample_rate, channels, samples) = decode_file(Path::new(&input_path)); + eprintln!( + "Decoded {} samples with {} samplerate in {}s", + samples.len(), + &sample_rate, + (Instant::now() - start_decode).as_secs_f32() + ); + + let resampled = if sample_rate != 48000 { + let start_resample = Instant::now(); + let res = convert( + sample_rate, + 48000, + channels, + ConverterType::Linear, + &samples, + ) + .unwrap(); + eprintln!( + "Resampled in {}s", + (Instant::now() - start_resample).as_secs_f32() + ); + res + } else { + samples + }; + + // Stereo thingy + let start_merge = Instant::now(); + let mut samples = resampled + .chunks_exact(channels) + .map(|x| { + let mut sum = 0.0; + for channel in x.iter().take(channels) { + sum += channel; + } + sum + }) + .collect::>(); + eprintln!( + "Merged channels in {}s", + (Instant::now() - start_merge).as_secs_f32() + ); + + let start_fft = Instant::now(); + let mut planner = realfft::RealFftPlanner::::new(); + let mut output = vec![realfft::num_complex::Complex32::new(0f32, 0f32); samples.len() / 2 + 1]; + let fft = planner.plan_fft_forward(samples.len()); + let res = fft.process(&mut samples, &mut output); + if let Err(e) = res { + eprintln!("FFT error: {}", e) + } + + let output = output[..=sample_rate as usize / 2 + SMOOTH_FACTOR] + .iter() + .map(|realfft::num_complex::Complex32 { re, im }| (re * re + im * im).powf(0.5)) + .collect::>(); + let mino = min(&output); + let maxo = max(&output) - mino; + let good_data = (0..=20000) + .map(|x| ((mid(&output[x..x + SMOOTH_FACTOR]).log10() - mino.log10()) / maxo.log10())) + .collect::>(); + let mut lines = (0..10) + .map(|x| mid(&good_data[2000 * x..500 * (4 * x + 1)])) + .collect::>(); + let min = min(&lines); + let max = max(&lines) - min; + lines.iter_mut().for_each(|x| *x = (*x - min) / max); + eprintln!( + "Analysis done in {}s", + (Instant::now() - start_fft).as_secs_f32() + ); + + let start_drawing = Instant::now(); + let hmargin = OSIZE / 31; + let hradius = OSIZE / 31 * 2; + + let mut pixmap = Pixmap::new(OSIZE as u32, OSIZE as u32).unwrap(); + if !no_background { + let mut rng = rand::thread_rng(); + let yes: bool = rng.gen(); + let paint = Paint::<'_> { + anti_alias: false, + shader: LinearGradient::new( + if yes { + Point::from_xy(0.0, 0.0) + } else { + Point::from_xy(OSIZE as f32, OSIZE as f32) + }, + if yes { + Point::from_xy(OSIZE as f32, OSIZE as f32) + } else { + Point::from_xy(0.0, 0.0) + }, + vec![ + GradientStop::new( + 0.0, + Color::from_rgba8( + rng.gen_range(0..235), + rng.gen_range(0..235), + rng.gen_range(0..235), + 255, + ), + ), + GradientStop::new( + 1.0, + Color::from_rgba8( + rng.gen_range(0..235), + rng.gen_range(0..235), + rng.gen_range(0..235), + 255, + ), + ), + ], + SpreadMode::Pad, + Transform::identity(), + ) + .unwrap(), + ..Default::default() + }; + + let mut pb = PathBuilder::new(); + pb.move_to(0.0, 0.0); + pb.line_to(0.0, OSIZE as f32); + pb.line_to(OSIZE as f32, OSIZE as f32); + pb.line_to(OSIZE as f32, 0.0); + pb.close(); + let path = pb.finish().unwrap(); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } + let stroke = tiny_skia::Stroke { + width: hradius as f32 + OSIZE as f32 / 64.0, + line_cap: LineCap::Round, + ..Default::default() + }; + let mut index = hmargin; + let mut paint = Paint::default(); + paint.set_color_rgba8(0, 0, 0, 16); + for line in &lines { + paint.anti_alias = true; + let path: tiny_skia::Path = { + let mut pb = PathBuilder::new(); + if *line == 0.0 { + pb.move_to(index as f32 + hradius as f32 / 2.0, OSIZE as f32 / 2.0); + pb.line_to(index as f32 + hradius as f32 / 2.0, OSIZE as f32 / 2.0); + } else { + pb.move_to( + index as f32 + hradius as f32 / 2.0, + OSIZE as f32 / 2.0 - (OSIZE / 2 - hmargin) as f32 * *line + hradius as f32, + ); + pb.line_to( + index as f32 + hradius as f32 / 2.0, + OSIZE as f32 / 2.0 + (OSIZE / 2 - hmargin) as f32 * *line - hradius as f32, + ); + } + pb.finish().unwrap() + }; + + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + index += hmargin + hradius; + } + let stroke = tiny_skia::Stroke { + width: hradius as f32, + line_cap: LineCap::Round, + ..Default::default() + }; + + paint.set_color_rgba8(255, 255, 255, 128); + let mut index = hmargin; + for line in lines { + paint.anti_alias = true; + let path: tiny_skia::Path = { + let mut pb = PathBuilder::new(); + if line == 0.0 { + pb.move_to(index as f32 + hradius as f32 / 2.0, OSIZE as f32 / 2.0); + pb.line_to(index as f32 + hradius as f32 / 2.0, OSIZE as f32 / 2.0); + } else { + pb.move_to( + index as f32 + hradius as f32 / 2.0, + OSIZE as f32 / 2.0 - (OSIZE / 2 - hmargin) as f32 * line + hradius as f32, + ); + pb.line_to( + index as f32 + hradius as f32 / 2.0, + OSIZE as f32 / 2.0 + (OSIZE / 2 - hmargin) as f32 * line - hradius as f32, + ); + } + pb.finish().unwrap() + }; + + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + index += hmargin + hradius; + } + + let mut input = PathBuf::from(input_path) + .file_stem() + .unwrap() + .to_str() + .unwrap() + .to_string(); + input.push_str(".png"); + pixmap.save_png(&input).unwrap(); + eprintln!( + "Finished drawing in {}s and wrote to '{}'", + (Instant::now() - start_drawing).as_secs_f32(), + input + ); +}