1pub mod mp4meta;
41pub mod replaygain;
42
43use anyhow::{Context, Result};
44use std::fs;
45use std::path::Path;
46
47pub const GAIN_STEP_DB: f64 = 1.5;
49
50pub const MAX_GAIN: u8 = 255;
52
53pub const MIN_GAIN: u8 = 0;
55
56#[derive(Debug, Clone)]
58pub struct Mp3Analysis {
59 pub frame_count: usize,
61 pub mpeg_version: String,
63 pub channel_mode: String,
65 pub min_gain: u8,
67 pub max_gain: u8,
69 pub avg_gain: f64,
71 pub headroom_steps: i32,
73 pub headroom_db: f64,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq)]
79enum MpegVersion {
80 Mpeg1,
81 Mpeg2,
82 Mpeg25,
83}
84
85impl MpegVersion {
86 fn as_str(&self) -> &'static str {
87 match self {
88 MpegVersion::Mpeg1 => "MPEG1",
89 MpegVersion::Mpeg2 => "MPEG2",
90 MpegVersion::Mpeg25 => "MPEG2.5",
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq)]
97enum ChannelMode {
98 Stereo,
99 JointStereo,
100 DualChannel,
101 Mono,
102}
103
104impl ChannelMode {
105 fn channel_count(&self) -> usize {
106 match self {
107 ChannelMode::Mono => 1,
108 _ => 2,
109 }
110 }
111
112 fn as_str(&self) -> &'static str {
113 match self {
114 ChannelMode::Stereo => "Stereo",
115 ChannelMode::JointStereo => "Joint Stereo",
116 ChannelMode::DualChannel => "Dual Channel",
117 ChannelMode::Mono => "Mono",
118 }
119 }
120}
121
122#[derive(Debug, Clone)]
124#[allow(dead_code)]
125struct FrameHeader {
126 version: MpegVersion,
127 has_crc: bool,
128 bitrate_kbps: u32,
129 sample_rate: u32,
130 padding: bool,
131 channel_mode: ChannelMode,
132 frame_size: usize,
133}
134
135impl FrameHeader {
136 fn granule_count(&self) -> usize {
137 match self.version {
138 MpegVersion::Mpeg1 => 2,
139 _ => 1,
140 }
141 }
142
143 fn side_info_offset(&self) -> usize {
144 if self.has_crc {
145 6
146 } else {
147 4
148 }
149 }
150}
151
152const BITRATE_TABLE_MPEG1_L3: [u32; 15] = [
154 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
155];
156
157const BITRATE_TABLE_MPEG2_L3: [u32; 15] =
159 [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
160
161const SAMPLE_RATE_TABLE: [[u32; 3]; 3] = [
163 [44100, 48000, 32000], [22050, 24000, 16000], [11025, 12000, 8000], ];
167
168fn parse_header(header: &[u8]) -> Option<FrameHeader> {
170 if header.len() < 4 {
171 return None;
172 }
173
174 if header[0] != 0xFF || (header[1] & 0xE0) != 0xE0 {
176 return None;
177 }
178
179 let version_bits = (header[1] >> 3) & 0x03;
181 let version = match version_bits {
182 0b00 => MpegVersion::Mpeg25,
183 0b10 => MpegVersion::Mpeg2,
184 0b11 => MpegVersion::Mpeg1,
185 _ => return None,
186 };
187
188 let layer_bits = (header[1] >> 1) & 0x03;
190 if layer_bits != 0b01 {
191 return None;
192 }
193
194 let has_crc = (header[1] & 0x01) == 0;
196
197 let bitrate_index = (header[2] >> 4) & 0x0F;
199 if bitrate_index == 0 || bitrate_index == 15 {
200 return None;
201 }
202
203 let bitrate_kbps = match version {
204 MpegVersion::Mpeg1 => BITRATE_TABLE_MPEG1_L3[bitrate_index as usize],
205 _ => BITRATE_TABLE_MPEG2_L3[bitrate_index as usize],
206 };
207
208 let sr_index = ((header[2] >> 2) & 0x03) as usize;
210 if sr_index == 3 {
211 return None;
212 }
213
214 let version_index = match version {
215 MpegVersion::Mpeg1 => 0,
216 MpegVersion::Mpeg2 => 1,
217 MpegVersion::Mpeg25 => 2,
218 };
219 let sample_rate = SAMPLE_RATE_TABLE[version_index][sr_index];
220
221 let padding = (header[2] & 0x02) != 0;
223
224 let channel_bits = (header[3] >> 6) & 0x03;
226 let channel_mode = match channel_bits {
227 0b00 => ChannelMode::Stereo,
228 0b01 => ChannelMode::JointStereo,
229 0b10 => ChannelMode::DualChannel,
230 0b11 => ChannelMode::Mono,
231 _ => unreachable!(),
232 };
233
234 let samples_per_frame = match version {
236 MpegVersion::Mpeg1 => 1152,
237 _ => 576,
238 };
239 let padding_size = if padding { 1 } else { 0 };
240 let frame_size =
241 (samples_per_frame * bitrate_kbps as usize * 125) / sample_rate as usize + padding_size;
242
243 Some(FrameHeader {
244 version,
245 has_crc,
246 bitrate_kbps,
247 sample_rate,
248 padding,
249 channel_mode,
250 frame_size,
251 })
252}
253
254#[derive(Debug, Clone)]
256struct GainLocation {
257 byte_offset: usize,
258 bit_offset: u8,
259}
260
261fn calculate_gain_locations(frame_offset: usize, header: &FrameHeader) -> Vec<GainLocation> {
263 let mut locations = Vec::new();
264 let side_info_start = frame_offset + header.side_info_offset();
265
266 let num_channels = header.channel_mode.channel_count();
267 let num_granules = header.granule_count();
268
269 let bits_before_granules = match (header.version, num_channels) {
270 (MpegVersion::Mpeg1, 1) => 18,
271 (MpegVersion::Mpeg1, _) => 20,
272 (_, 1) => 9,
273 (_, _) => 10,
274 };
275
276 let bits_per_granule_channel = match header.version {
277 MpegVersion::Mpeg1 => 59,
278 _ => 63,
279 };
280
281 for gr in 0..num_granules {
282 for ch in 0..num_channels {
283 let granule_start_bit =
284 bits_before_granules + (gr * num_channels + ch) * bits_per_granule_channel;
285 let global_gain_bit = granule_start_bit + 21;
286
287 let byte_offset = side_info_start + global_gain_bit / 8;
288 let bit_offset = (global_gain_bit % 8) as u8;
289
290 locations.push(GainLocation {
291 byte_offset,
292 bit_offset,
293 });
294 }
295 }
296
297 locations
298}
299
300fn read_gain_at(data: &[u8], loc: &GainLocation) -> u8 {
302 let idx = loc.byte_offset;
303 if idx >= data.len() {
304 return 0;
305 }
306
307 if loc.bit_offset == 0 {
308 data[idx]
309 } else if idx + 1 < data.len() {
310 let shift = loc.bit_offset;
311 let high = data[idx] << shift;
312 let low = data[idx + 1] >> (8 - shift);
313 high | low
314 } else {
315 data[idx] << loc.bit_offset
316 }
317}
318
319fn write_gain_at(data: &mut [u8], loc: &GainLocation, value: u8) {
321 let idx = loc.byte_offset;
322 if idx >= data.len() {
323 return;
324 }
325
326 if loc.bit_offset == 0 {
327 data[idx] = value;
328 } else if idx + 1 < data.len() {
329 let shift = loc.bit_offset;
330 let mask_high = 0xFFu8 << (8 - shift);
331 let mask_low = 0xFFu8 >> shift;
332
333 data[idx] = (data[idx] & mask_high) | (value >> shift);
334 data[idx + 1] = (data[idx + 1] & mask_low) | (value << (8 - shift));
335 } else {
336 let shift = loc.bit_offset;
337 let mask_high = 0xFFu8 << (8 - shift);
338 data[idx] = (data[idx] & mask_high) | (value >> shift);
339 }
340}
341
342fn skip_id3v2(data: &[u8]) -> usize {
344 if data.len() < 10 || &data[0..3] != b"ID3" {
345 return 0;
346 }
347
348 let size = ((data[6] as usize & 0x7F) << 21)
349 | ((data[7] as usize & 0x7F) << 14)
350 | ((data[8] as usize & 0x7F) << 7)
351 | (data[9] as usize & 0x7F);
352
353 10 + size
354}
355
356fn is_xing_frame(data: &[u8], frame_offset: usize, header: &FrameHeader) -> bool {
360 let side_info_len = match (header.version, header.channel_mode) {
363 (MpegVersion::Mpeg1, ChannelMode::Mono) => 17,
364 (MpegVersion::Mpeg1, _) => 32,
365 (_, ChannelMode::Mono) => 9,
366 (_, _) => 17,
367 };
368
369 let xing_offset = frame_offset + header.side_info_offset() + side_info_len;
370
371 if xing_offset + 4 > data.len() {
373 return false;
374 }
375
376 let marker = &data[xing_offset..xing_offset + 4];
378 marker == b"Xing" || marker == b"Info"
379}
380
381fn iterate_frames<F>(data: &[u8], mut callback: F) -> Result<usize>
383where
384 F: FnMut(usize, &FrameHeader, &[GainLocation]),
385{
386 let file_size = data.len();
387 let mut pos = skip_id3v2(data);
388 let mut frame_count = 0;
389
390 while pos + 4 <= file_size {
391 let header = match parse_header(&data[pos..]) {
392 Some(h) => h,
393 None => {
394 pos += 1;
395 continue;
396 }
397 };
398
399 let next_pos = pos + header.frame_size;
400 let valid_frame = if next_pos + 2 <= file_size {
401 data[next_pos] == 0xFF && (data[next_pos + 1] & 0xE0) == 0xE0
402 } else {
403 next_pos <= file_size
404 };
405
406 if !valid_frame {
407 pos += 1;
408 continue;
409 }
410
411 let locations = calculate_gain_locations(pos, &header);
412 callback(pos, &header, &locations);
413
414 frame_count += 1;
415 pos = next_pos;
416 }
417
418 Ok(frame_count)
419}
420
421pub fn analyze(file_path: &Path) -> Result<Mp3Analysis> {
429 let data =
430 fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
431
432 let mut min_gain = 255u8;
433 let mut max_gain = 0u8;
434 let mut total_gain: u64 = 0;
435 let mut gain_count: u64 = 0;
436 let mut first_version = None;
437 let mut first_channel_mode = None;
438
439 let frame_count = iterate_frames(&data, |_pos, header, locations| {
440 if first_version.is_none() {
441 first_version = Some(header.version);
442 first_channel_mode = Some(header.channel_mode);
443 }
444
445 for loc in locations {
446 let gain = read_gain_at(&data, loc);
447 min_gain = min_gain.min(gain);
448 max_gain = max_gain.max(gain);
449 total_gain += gain as u64;
450 gain_count += 1;
451 }
452 })?;
453
454 if frame_count == 0 {
455 anyhow::bail!("No valid MP3 frames found");
456 }
457
458 let avg_gain = total_gain as f64 / gain_count as f64;
459 let headroom_steps = (MAX_GAIN - max_gain) as i32;
460 let headroom_db = headroom_steps as f64 * GAIN_STEP_DB;
461
462 Ok(Mp3Analysis {
463 frame_count,
464 mpeg_version: first_version.unwrap().as_str().to_string(),
465 channel_mode: first_channel_mode.unwrap().as_str().to_string(),
466 min_gain,
467 max_gain,
468 avg_gain,
469 headroom_steps,
470 headroom_db,
471 })
472}
473
474pub fn apply_gain(file_path: &Path, gain_steps: i32) -> Result<usize> {
483 if gain_steps == 0 {
484 return Ok(0);
485 }
486
487 let mut data =
488 fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
489
490 let mut modified_frames = 0;
491 let file_size = data.len();
492 let mut pos = skip_id3v2(&data);
493
494 while pos + 4 <= file_size {
495 let header = match parse_header(&data[pos..]) {
496 Some(h) => h,
497 None => {
498 pos += 1;
499 continue;
500 }
501 };
502
503 let next_pos = pos + header.frame_size;
504 let valid_frame = if next_pos + 2 <= file_size {
505 data[next_pos] == 0xFF && (data[next_pos + 1] & 0xE0) == 0xE0
506 } else {
507 next_pos <= file_size
508 };
509
510 if !valid_frame {
511 pos += 1;
512 continue;
513 }
514
515 if is_xing_frame(&data, pos, &header) {
518 pos = next_pos;
519 continue;
520 }
521
522 let locations = calculate_gain_locations(pos, &header);
523
524 for loc in &locations {
525 let current_gain = read_gain_at(&data, loc);
526 let new_gain = if gain_steps > 0 {
527 current_gain.saturating_add(gain_steps.min(255) as u8)
528 } else {
529 current_gain.saturating_sub((-gain_steps).min(255) as u8)
530 };
531 write_gain_at(&mut data, loc, new_gain);
532 }
533
534 modified_frames += 1;
535 pos = next_pos;
536 }
537
538 fs::write(file_path, &data)
539 .with_context(|| format!("Failed to write: {}", file_path.display()))?;
540
541 Ok(modified_frames)
542}
543
544pub fn apply_gain_db(file_path: &Path, gain_db: f64) -> Result<usize> {
553 let steps = db_to_steps(gain_db);
554 apply_gain(file_path, steps)
555}
556
557pub fn db_to_steps(db: f64) -> i32 {
559 (db / GAIN_STEP_DB).round() as i32
560}
561
562pub fn steps_to_db(steps: i32) -> f64 {
564 steps as f64 * GAIN_STEP_DB
565}
566
567#[derive(Debug, Clone, Copy, PartialEq)]
569pub enum Channel {
570 Left,
572 Right,
574}
575
576impl Channel {
577 pub fn index(&self) -> usize {
579 match self {
580 Channel::Left => 0,
581 Channel::Right => 1,
582 }
583 }
584
585 pub fn from_index(index: usize) -> Option<Self> {
587 match index {
588 0 => Some(Channel::Left),
589 1 => Some(Channel::Right),
590 _ => None,
591 }
592 }
593}
594
595pub fn is_mono(file_path: &Path) -> Result<bool> {
597 let analysis = analyze(file_path)?;
598 Ok(analysis.channel_mode == "Mono")
599}
600
601pub fn apply_gain_channel(file_path: &Path, channel: Channel, gain_steps: i32) -> Result<usize> {
614 if gain_steps == 0 {
615 return Ok(0);
616 }
617
618 let analysis = analyze(file_path)?;
620 if analysis.channel_mode == "Mono" {
621 anyhow::bail!("Cannot apply channel-specific gain to mono file. Use -g for mono files.");
622 }
623
624 let mut data =
625 fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
626
627 let mut modified_frames = 0;
628 let file_size = data.len();
629 let mut pos = skip_id3v2(&data);
630 let target_channel = channel.index();
631
632 while pos + 4 <= file_size {
633 let header = match parse_header(&data[pos..]) {
634 Some(h) => h,
635 None => {
636 pos += 1;
637 continue;
638 }
639 };
640
641 let next_pos = pos + header.frame_size;
642 let valid_frame = if next_pos + 2 <= file_size {
643 data[next_pos] == 0xFF && (data[next_pos + 1] & 0xE0) == 0xE0
644 } else {
645 next_pos <= file_size
646 };
647
648 if !valid_frame {
649 pos += 1;
650 continue;
651 }
652
653 if is_xing_frame(&data, pos, &header) {
655 pos = next_pos;
656 continue;
657 }
658
659 let locations = calculate_gain_locations(pos, &header);
660 let num_channels = header.channel_mode.channel_count();
661 let num_granules = header.granule_count();
662
663 for gr in 0..num_granules {
666 let loc_index = gr * num_channels + target_channel;
667 if loc_index < locations.len() {
668 let loc = &locations[loc_index];
669 let current_gain = read_gain_at(&data, loc);
670 let new_gain = if gain_steps > 0 {
671 current_gain.saturating_add(gain_steps.min(255) as u8)
672 } else {
673 current_gain.saturating_sub((-gain_steps).min(255) as u8)
674 };
675 write_gain_at(&mut data, loc, new_gain);
676 }
677 }
678
679 modified_frames += 1;
680 pos = next_pos;
681 }
682
683 fs::write(file_path, &data)
684 .with_context(|| format!("Failed to write: {}", file_path.display()))?;
685
686 Ok(modified_frames)
687}
688
689pub fn apply_gain_channel_with_undo(
691 file_path: &Path,
692 channel: Channel,
693 gain_steps: i32,
694) -> Result<usize> {
695 if gain_steps == 0 {
696 return Ok(0);
697 }
698
699 let analysis = analyze(file_path)?;
701 if analysis.channel_mode == "Mono" {
702 anyhow::bail!("Cannot apply channel-specific gain to mono file. Use -g for mono files.");
703 }
704
705 let mut tag = read_ape_tag_from_file(file_path)?.unwrap_or_else(ApeTag::new);
707
708 let (existing_left, existing_right) = parse_undo_values(tag.get(TAG_MP3GAIN_UNDO));
710
711 let (new_left, new_right) = match channel {
713 Channel::Left => (existing_left + gain_steps, existing_right),
714 Channel::Right => (existing_left, existing_right + gain_steps),
715 };
716
717 tag.set_undo_gain(new_left, new_right, false);
718
719 if tag.get(TAG_MP3GAIN_MINMAX).is_none() {
721 tag.set_minmax(analysis.min_gain, analysis.max_gain);
722 }
723
724 let frames = apply_gain_channel(file_path, channel, gain_steps)?;
726
727 write_ape_tag(file_path, &tag)?;
729
730 Ok(frames)
731}
732
733fn parse_undo_values(undo_str: Option<&str>) -> (i32, i32) {
735 match undo_str {
736 Some(v) => {
737 let parts: Vec<&str> = v.split(',').collect();
738 let left = parts
739 .first()
740 .and_then(|s| s.trim().parse::<i32>().ok())
741 .unwrap_or(0);
742 let right = parts
743 .get(1)
744 .and_then(|s| s.trim().parse::<i32>().ok())
745 .unwrap_or(left);
746 (left, right)
747 }
748 None => (0, 0),
749 }
750}
751
752const APE_PREAMBLE: &[u8; 8] = b"APETAGEX";
758
759const APE_VERSION: u32 = 2000;
761
762const APE_FLAG_HEADER_PRESENT: u32 = 1 << 31;
764const APE_FLAG_IS_HEADER: u32 = 1 << 29;
765
766pub const TAG_MP3GAIN_UNDO: &str = "MP3GAIN_UNDO";
768pub const TAG_MP3GAIN_MINMAX: &str = "MP3GAIN_MINMAX";
769pub const TAG_MP3GAIN_ALBUM_MINMAX: &str = "MP3GAIN_ALBUM_MINMAX";
770
771pub const TAG_REPLAYGAIN_TRACK_GAIN: &str = "REPLAYGAIN_TRACK_GAIN";
773pub const TAG_REPLAYGAIN_TRACK_PEAK: &str = "REPLAYGAIN_TRACK_PEAK";
774pub const TAG_REPLAYGAIN_ALBUM_GAIN: &str = "REPLAYGAIN_ALBUM_GAIN";
775pub const TAG_REPLAYGAIN_ALBUM_PEAK: &str = "REPLAYGAIN_ALBUM_PEAK";
776
777#[derive(Debug, Clone)]
779pub struct ApeItem {
780 pub key: String,
781 pub value: String,
782}
783
784#[derive(Debug, Clone, Default)]
786pub struct ApeTag {
787 items: Vec<ApeItem>,
788}
789
790impl ApeTag {
791 pub fn new() -> Self {
793 Self { items: Vec::new() }
794 }
795
796 pub fn get(&self, key: &str) -> Option<&str> {
798 let key_upper = key.to_uppercase();
799 self.items
800 .iter()
801 .find(|item| item.key.to_uppercase() == key_upper)
802 .map(|item| item.value.as_str())
803 }
804
805 pub fn set(&mut self, key: &str, value: &str) {
807 let key_upper = key.to_uppercase();
808 if let Some(item) = self
809 .items
810 .iter_mut()
811 .find(|item| item.key.to_uppercase() == key_upper)
812 {
813 item.value = value.to_string();
814 } else {
815 self.items.push(ApeItem {
816 key: key_upper,
817 value: value.to_string(),
818 });
819 }
820 }
821
822 pub fn remove(&mut self, key: &str) {
824 let key_upper = key.to_uppercase();
825 self.items
826 .retain(|item| item.key.to_uppercase() != key_upper);
827 }
828
829 pub fn is_empty(&self) -> bool {
831 self.items.is_empty()
832 }
833
834 pub fn get_undo_gain(&self) -> Option<i32> {
836 self.get(TAG_MP3GAIN_UNDO).and_then(|v| {
837 let parts: Vec<&str> = v.split(',').collect();
840 if !parts.is_empty() {
841 parts[0].trim().parse::<i32>().ok()
842 } else {
843 None
844 }
845 })
846 }
847
848 pub fn set_undo_gain(&mut self, left_gain: i32, right_gain: i32, wrap: bool) {
850 let wrap_flag = if wrap { "W" } else { "N" };
851 let value = format!("{:+04},{:+04},{}", left_gain, right_gain, wrap_flag);
852 self.set(TAG_MP3GAIN_UNDO, &value);
853 }
854
855 pub fn set_minmax(&mut self, min: u8, max: u8) {
857 let value = format!("{},{}", min, max);
858 self.set(TAG_MP3GAIN_MINMAX, &value);
859 }
860}
861
862fn find_ape_footer(data: &[u8]) -> Option<usize> {
864 if data.len() < 32 {
865 return None;
866 }
867
868 let footer_start = data.len() - 32;
870 if &data[footer_start..footer_start + 8] == APE_PREAMBLE {
871 return Some(footer_start);
872 }
873
874 if data.len() >= 160 {
876 let footer_start = data.len() - 32 - 128;
877 if &data[footer_start..footer_start + 8] == APE_PREAMBLE
878 && &data[data.len() - 128..data.len() - 125] == b"TAG"
879 {
880 return Some(footer_start);
881 }
882 }
883
884 None
885}
886
887fn read_u32_le(data: &[u8]) -> u32 {
889 u32::from_le_bytes([data[0], data[1], data[2], data[3]])
890}
891
892pub fn read_ape_tag(data: &[u8]) -> Option<ApeTag> {
894 let footer_start = find_ape_footer(data)?;
895
896 let version = read_u32_le(&data[footer_start + 8..]);
898 if version != APE_VERSION {
899 return None;
900 }
901
902 let tag_size = read_u32_le(&data[footer_start + 12..]) as usize;
903 let item_count = read_u32_le(&data[footer_start + 16..]) as usize;
904
905 if footer_start + 32 < tag_size {
907 return None;
908 }
909 let items_start = footer_start + 32 - tag_size;
910
911 let mut tag = ApeTag::new();
913 let mut pos = items_start;
914
915 for _ in 0..item_count {
916 if pos + 8 > footer_start {
917 break;
918 }
919
920 let value_size = read_u32_le(&data[pos..]) as usize;
921 pos += 8; let key_start = pos;
925 while pos < footer_start && data[pos] != 0 {
926 pos += 1;
927 }
928 if pos >= footer_start {
929 break;
930 }
931
932 let key = String::from_utf8_lossy(&data[key_start..pos]).to_string();
933 pos += 1; if pos + value_size > footer_start {
937 break;
938 }
939 let value = String::from_utf8_lossy(&data[pos..pos + value_size]).to_string();
940 pos += value_size;
941
942 tag.items.push(ApeItem { key, value });
943 }
944
945 Some(tag)
946}
947
948pub fn read_ape_tag_from_file(file_path: &Path) -> Result<Option<ApeTag>> {
950 let data =
951 fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
952 Ok(read_ape_tag(&data))
953}
954
955fn serialize_ape_tag(tag: &ApeTag) -> Vec<u8> {
957 if tag.is_empty() {
958 return Vec::new();
959 }
960
961 let mut items_data = Vec::new();
962
963 for item in &tag.items {
965 let value_bytes = item.value.as_bytes();
966 let key_bytes = item.key.as_bytes();
967
968 items_data.extend_from_slice(&(value_bytes.len() as u32).to_le_bytes());
970 items_data.extend_from_slice(&0u32.to_le_bytes());
972 items_data.extend_from_slice(key_bytes);
974 items_data.push(0);
975 items_data.extend_from_slice(value_bytes);
977 }
978
979 let tag_size = items_data.len() + 32; let item_count = tag.items.len() as u32;
981
982 let mut result = Vec::new();
983
984 result.extend_from_slice(APE_PREAMBLE);
986 result.extend_from_slice(&APE_VERSION.to_le_bytes());
987 result.extend_from_slice(&(tag_size as u32).to_le_bytes());
988 result.extend_from_slice(&item_count.to_le_bytes());
989 result.extend_from_slice(&(APE_FLAG_HEADER_PRESENT | APE_FLAG_IS_HEADER).to_le_bytes());
990 result.extend_from_slice(&[0u8; 8]); result.extend_from_slice(&items_data);
994
995 result.extend_from_slice(APE_PREAMBLE);
997 result.extend_from_slice(&APE_VERSION.to_le_bytes());
998 result.extend_from_slice(&(tag_size as u32).to_le_bytes());
999 result.extend_from_slice(&item_count.to_le_bytes());
1000 result.extend_from_slice(&APE_FLAG_HEADER_PRESENT.to_le_bytes());
1001 result.extend_from_slice(&[0u8; 8]); result
1004}
1005
1006fn remove_ape_tag(data: &[u8]) -> Vec<u8> {
1008 let footer_start = match find_ape_footer(data) {
1009 Some(pos) => pos,
1010 None => return data.to_vec(),
1011 };
1012
1013 let tag_size = read_u32_le(&data[footer_start + 12..]) as usize;
1015 let flags = read_u32_le(&data[footer_start + 20..]);
1016 let has_header = (flags & APE_FLAG_HEADER_PRESENT) != 0;
1017 let header_size = if has_header { 32 } else { 0 };
1018
1019 let audio_end = if footer_start + 32 >= tag_size + header_size {
1021 footer_start + 32 - tag_size - header_size
1022 } else {
1023 0
1024 };
1025
1026 let id3v1_start = footer_start + 32;
1028 let has_id3v1 = data.len() > id3v1_start + 3 && &data[id3v1_start..id3v1_start + 3] == b"TAG";
1029
1030 if has_id3v1 {
1031 let mut result = data[..audio_end].to_vec();
1033 result.extend_from_slice(&data[id3v1_start..]);
1034 result
1035 } else {
1036 data[..audio_end].to_vec()
1037 }
1038}
1039
1040pub fn write_ape_tag(file_path: &Path, tag: &ApeTag) -> Result<()> {
1042 let data =
1043 fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
1044
1045 let mut audio_data = remove_ape_tag(&data);
1047
1048 let has_id3v1 = audio_data.len() >= 128
1050 && &audio_data[audio_data.len() - 128..audio_data.len() - 125] == b"TAG";
1051
1052 let tag_data = serialize_ape_tag(tag);
1054
1055 if has_id3v1 {
1057 let id3v1 = audio_data[audio_data.len() - 128..].to_vec();
1058 audio_data.truncate(audio_data.len() - 128);
1059 audio_data.extend_from_slice(&tag_data);
1060 audio_data.extend_from_slice(&id3v1);
1061 } else {
1062 audio_data.extend_from_slice(&tag_data);
1063 }
1064
1065 fs::write(file_path, &audio_data)
1066 .with_context(|| format!("Failed to write: {}", file_path.display()))?;
1067
1068 Ok(())
1069}
1070
1071pub fn delete_ape_tag(file_path: &Path) -> Result<()> {
1073 let data =
1074 fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
1075
1076 let audio_data = remove_ape_tag(&data);
1077
1078 fs::write(file_path, &audio_data)
1079 .with_context(|| format!("Failed to write: {}", file_path.display()))?;
1080
1081 Ok(())
1082}
1083
1084pub fn find_max_amplitude(file_path: &Path) -> Result<(f64, u8, u8)> {
1087 let data =
1088 fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
1089
1090 let mut min_gain = 255u8;
1091 let mut max_gain = 0u8;
1092
1093 let frame_count = iterate_frames(&data, |_pos, _header, locations| {
1094 for loc in locations {
1095 let gain = read_gain_at(&data, loc);
1096 min_gain = min_gain.min(gain);
1097 max_gain = max_gain.max(gain);
1098 }
1099 })?;
1100
1101 if frame_count == 0 {
1102 anyhow::bail!("No valid MP3 frames found");
1103 }
1104
1105 let headroom_steps = (MAX_GAIN - max_gain) as i32;
1111 let headroom_db = headroom_steps as f64 * GAIN_STEP_DB;
1112
1113 let max_amplitude = 10.0_f64.powf(-headroom_db / 20.0);
1117
1118 Ok((max_amplitude, max_gain, min_gain))
1119}
1120
1121pub fn apply_gain_wrap(file_path: &Path, gain_steps: i32) -> Result<usize> {
1123 if gain_steps == 0 {
1124 return Ok(0);
1125 }
1126
1127 let mut data =
1128 fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
1129
1130 let mut modified_frames = 0;
1131 let file_size = data.len();
1132 let mut pos = skip_id3v2(&data);
1133
1134 while pos + 4 <= file_size {
1135 let header = match parse_header(&data[pos..]) {
1136 Some(h) => h,
1137 None => {
1138 pos += 1;
1139 continue;
1140 }
1141 };
1142
1143 let next_pos = pos + header.frame_size;
1144 let valid_frame = if next_pos + 2 <= file_size {
1145 data[next_pos] == 0xFF && (data[next_pos + 1] & 0xE0) == 0xE0
1146 } else {
1147 next_pos <= file_size
1148 };
1149
1150 if !valid_frame {
1151 pos += 1;
1152 continue;
1153 }
1154
1155 if is_xing_frame(&data, pos, &header) {
1157 pos = next_pos;
1158 continue;
1159 }
1160
1161 let locations = calculate_gain_locations(pos, &header);
1162
1163 for loc in &locations {
1164 let current_gain = read_gain_at(&data, loc) as i32;
1165 let new_gain = ((current_gain + gain_steps) % 256 + 256) % 256;
1167 write_gain_at(&mut data, loc, new_gain as u8);
1168 }
1169
1170 modified_frames += 1;
1171 pos = next_pos;
1172 }
1173
1174 fs::write(file_path, &data)
1175 .with_context(|| format!("Failed to write: {}", file_path.display()))?;
1176
1177 Ok(modified_frames)
1178}
1179
1180pub fn apply_gain_with_undo_wrap(file_path: &Path, gain_steps: i32) -> Result<usize> {
1182 if gain_steps == 0 {
1183 return Ok(0);
1184 }
1185
1186 let analysis = analyze(file_path)?;
1188
1189 let mut tag = read_ape_tag_from_file(file_path)?.unwrap_or_else(ApeTag::new);
1191
1192 let existing_undo = tag.get_undo_gain().unwrap_or(0);
1194 let new_undo = existing_undo + gain_steps;
1195 tag.set_undo_gain(new_undo, new_undo, true); if tag.get(TAG_MP3GAIN_MINMAX).is_none() {
1199 tag.set_minmax(analysis.min_gain, analysis.max_gain);
1200 }
1201
1202 let frames = apply_gain_wrap(file_path, gain_steps)?;
1204
1205 write_ape_tag(file_path, &tag)?;
1207
1208 Ok(frames)
1209}
1210
1211pub fn apply_gain_with_undo(file_path: &Path, gain_steps: i32) -> Result<usize> {
1213 if gain_steps == 0 {
1214 return Ok(0);
1215 }
1216
1217 let analysis = analyze(file_path)?;
1219
1220 let mut tag = read_ape_tag_from_file(file_path)?.unwrap_or_else(ApeTag::new);
1222
1223 let existing_undo = tag.get_undo_gain().unwrap_or(0);
1225 let new_undo = existing_undo + gain_steps;
1226 tag.set_undo_gain(new_undo, new_undo, false);
1227
1228 if tag.get(TAG_MP3GAIN_MINMAX).is_none() {
1230 tag.set_minmax(analysis.min_gain, analysis.max_gain);
1231 }
1232
1233 let frames = apply_gain(file_path, gain_steps)?;
1235
1236 write_ape_tag(file_path, &tag)?;
1238
1239 Ok(frames)
1240}
1241
1242pub fn undo_gain(file_path: &Path) -> Result<usize> {
1244 let tag = read_ape_tag_from_file(file_path)?
1245 .ok_or_else(|| anyhow::anyhow!("No APE tag found - cannot undo"))?;
1246
1247 let undo_gain = tag
1248 .get_undo_gain()
1249 .ok_or_else(|| anyhow::anyhow!("No MP3GAIN_UNDO tag found - cannot undo"))?;
1250
1251 if undo_gain == 0 {
1252 return Ok(0);
1253 }
1254
1255 let frames = apply_gain(file_path, -undo_gain)?;
1257
1258 let mut new_tag = tag.clone();
1260 new_tag.remove(TAG_MP3GAIN_UNDO);
1261 new_tag.remove(TAG_MP3GAIN_MINMAX);
1262
1263 if new_tag.is_empty() {
1264 delete_ape_tag(file_path)?;
1265 } else {
1266 write_ape_tag(file_path, &new_tag)?;
1267 }
1268
1269 Ok(frames)
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275
1276 #[test]
1277 fn test_db_to_steps() {
1278 assert_eq!(db_to_steps(0.0), 0);
1279 assert_eq!(db_to_steps(1.5), 1);
1280 assert_eq!(db_to_steps(3.0), 2);
1281 assert_eq!(db_to_steps(-1.5), -1);
1282 assert_eq!(db_to_steps(2.25), 2);
1283 }
1284
1285 #[test]
1286 fn test_steps_to_db() {
1287 assert_eq!(steps_to_db(0), 0.0);
1288 assert_eq!(steps_to_db(1), 1.5);
1289 assert_eq!(steps_to_db(-2), -3.0);
1290 }
1291
1292 #[test]
1293 fn test_parse_valid_header() {
1294 let header = [0xFF, 0xFB, 0x90, 0x00];
1295 let parsed = parse_header(&header);
1296 assert!(parsed.is_some());
1297 let h = parsed.unwrap();
1298 assert_eq!(h.version, MpegVersion::Mpeg1);
1299 assert_eq!(h.bitrate_kbps, 128);
1300 assert_eq!(h.sample_rate, 44100);
1301 }
1302
1303 #[test]
1304 fn test_parse_invalid_header() {
1305 assert!(parse_header(&[0x00, 0x00, 0x00, 0x00]).is_none());
1306 assert!(parse_header(&[0xFF, 0xFF, 0x90, 0x00]).is_none());
1307 }
1308
1309 #[test]
1310 fn test_bit_operations() {
1311 let mut data = vec![0xAB, 0xCD, 0xEF, 0x12, 0x34];
1312
1313 let loc_aligned = GainLocation {
1314 byte_offset: 1,
1315 bit_offset: 0,
1316 };
1317 assert_eq!(read_gain_at(&data, &loc_aligned), 0xCD);
1318
1319 let loc_unaligned = GainLocation {
1320 byte_offset: 1,
1321 bit_offset: 4,
1322 };
1323 assert_eq!(read_gain_at(&data, &loc_unaligned), 0xDE);
1324
1325 write_gain_at(&mut data, &loc_aligned, 0x42);
1326 assert_eq!(data[1], 0x42);
1327
1328 data = vec![0xAB, 0xCD, 0xEF, 0x12, 0x34];
1329 write_gain_at(&mut data, &loc_unaligned, 0x99);
1330 assert_eq!(data[1], 0xC9);
1331 assert_eq!(data[2], 0x9F);
1332 }
1333
1334 #[test]
1335 fn test_skip_id3v2() {
1336 let data_no_tag = vec![0xFF, 0xFB, 0x90, 0x00];
1337 assert_eq!(skip_id3v2(&data_no_tag), 0);
1338
1339 let data_with_tag = vec![b'I', b'D', b'3', 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
1340 assert_eq!(skip_id3v2(&data_with_tag), 10);
1341 }
1342
1343 #[test]
1344 fn test_is_xing_frame() {
1345 let mut data = vec![0u8; 100];
1348 data[0] = 0xFF;
1349 data[1] = 0xFB; data[2] = 0x90; data[3] = 0x00; data[36] = b'X';
1355 data[37] = b'i';
1356 data[38] = b'n';
1357 data[39] = b'g';
1358
1359 let header = parse_header(&data).unwrap();
1360 assert!(is_xing_frame(&data, 0, &header));
1361
1362 data[36] = b'I';
1364 data[37] = b'n';
1365 data[38] = b'f';
1366 data[39] = b'o';
1367 assert!(is_xing_frame(&data, 0, &header));
1368
1369 data[36] = 0x00;
1371 data[37] = 0x00;
1372 data[38] = 0x00;
1373 data[39] = 0x00;
1374 assert!(!is_xing_frame(&data, 0, &header));
1375 }
1376}