mirror of
https://github.com/ivabus/covergen
synced 2024-12-03 18:15:07 +03:00
Initial commit
Signed-off-by: Ivan Bushchik <ivabus@ivabus.dev>
This commit is contained in:
commit
fb51283f5c
11 changed files with 397 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
.DS_Store
|
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
@ -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 <ivabus@ivabus.dev>" ]
|
||||
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
|
20
LICENSE
Normal file
20
LICENSE
Normal file
|
@ -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.
|
19
README.md
Normal file
19
README.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Covergen
|
||||
|
||||
![Example](./imgs/ex1.png)
|
||||
|
||||
## Usage
|
||||
|
||||
```shell
|
||||
cargo run -r -- [-n] <path/to/audio/track>
|
||||
```
|
||||
|
||||
`-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).
|
BIN
imgs/ex1 no bg.png
Normal file
BIN
imgs/ex1 no bg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 283 KiB |
BIN
imgs/ex1.png
Normal file
BIN
imgs/ex1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 953 KiB |
BIN
imgs/ex2.png
Normal file
BIN
imgs/ex2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 608 KiB |
BIN
imgs/ex3.png
Normal file
BIN
imgs/ex3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 627 KiB |
BIN
imgs/ex4.png
Normal file
BIN
imgs/ex4.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 931 KiB |
67
src/decode.rs
Normal file
67
src/decode.rs
Normal file
|
@ -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<f32>) {
|
||||
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::<f32>::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)
|
||||
}
|
268
src/main.rs
Normal file
268
src/main.rs
Normal file
|
@ -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>) -> f32 {
|
||||
let mut m = f32::MAX;
|
||||
for i in v {
|
||||
if *i < m {
|
||||
m = *i
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
fn max(v: &Vec<f32>) -> 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::<Vec<f32>>();
|
||||
eprintln!(
|
||||
"Merged channels in {}s",
|
||||
(Instant::now() - start_merge).as_secs_f32()
|
||||
);
|
||||
|
||||
let start_fft = Instant::now();
|
||||
let mut planner = realfft::RealFftPlanner::<f32>::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::<Vec<f32>>();
|
||||
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::<Vec<f32>>();
|
||||
let mut lines = (0..10)
|
||||
.map(|x| mid(&good_data[2000 * x..500 * (4 * x + 1)]))
|
||||
.collect::<Vec<f32>>();
|
||||
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
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue