Skip to main content

mp3rgain/
lib.rs

1//! # mp3rgain
2//!
3//! Lossless MP3 volume adjustment library - a modern mp3gain replacement.
4//!
5//! This library provides lossless MP3 volume adjustment by modifying
6//! the `global_gain` field in each frame's side information.
7//!
8//! ## Features
9//!
10//! - **Lossless**: No re-encoding, preserves audio quality
11//! - **Fast**: Direct binary manipulation, no audio decoding
12//! - **Compatible**: Works with all MP3 files (MPEG1/2/2.5 Layer III)
13//! - **Reversible**: Changes can be undone by applying negative gain
14//!
15//! ## Optional Features
16//!
17//! - **replaygain**: Enable ReplayGain analysis (requires symphonia)
18//!   - Track gain calculation (`-r` flag)
19//!   - Album gain calculation (`-a` flag)
20//!
21//! ## Example
22//!
23//! ```no_run
24//! use mp3rgain::{apply_gain, apply_gain_db, analyze};
25//! use std::path::Path;
26//!
27//! // Apply +2 gain steps (+3.0 dB)
28//! let frames = apply_gain(Path::new("song.mp3"), 2).unwrap();
29//! println!("Modified {} frames", frames);
30//!
31//! // Or specify gain in dB directly
32//! let frames = apply_gain_db(Path::new("song.mp3"), 4.5).unwrap();
33//! ```
34//!
35//! ## Technical Details
36//!
37//! Each gain step equals 1.5 dB (fixed by MP3 specification).
38//! The global_gain field is 8 bits, allowing values 0-255.
39
40pub mod mp4meta;
41pub mod replaygain;
42
43use anyhow::{Context, Result};
44use std::fs;
45use std::path::Path;
46
47/// MP3 gain step size in dB (fixed by format specification)
48pub const GAIN_STEP_DB: f64 = 1.5;
49
50/// Maximum global_gain value
51pub const MAX_GAIN: u8 = 255;
52
53/// Minimum global_gain value
54pub const MIN_GAIN: u8 = 0;
55
56/// Result of MP3 file analysis
57#[derive(Debug, Clone)]
58pub struct Mp3Analysis {
59    /// Number of audio frames in the file
60    pub frame_count: usize,
61    /// MPEG version detected (1, 2, or 2.5)
62    pub mpeg_version: String,
63    /// Channel mode (Stereo, Joint Stereo, Dual Channel, Mono)
64    pub channel_mode: String,
65    /// Minimum global_gain value found across all granules
66    pub min_gain: u8,
67    /// Maximum global_gain value found across all granules
68    pub max_gain: u8,
69    /// Average global_gain value
70    pub avg_gain: f64,
71    /// Maximum safe positive adjustment in steps (before clipping)
72    pub headroom_steps: i32,
73    /// Maximum safe positive adjustment in dB
74    pub headroom_db: f64,
75}
76
77/// MPEG version
78#[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/// Channel mode
96#[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/// Parsed MP3 frame header
123#[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
152/// Bitrate table for MPEG1 Layer III
153const BITRATE_TABLE_MPEG1_L3: [u32; 15] = [
154    0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
155];
156
157/// Bitrate table for MPEG2/2.5 Layer III
158const BITRATE_TABLE_MPEG2_L3: [u32; 15] =
159    [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
160
161/// Sample rate table
162const SAMPLE_RATE_TABLE: [[u32; 3]; 3] = [
163    [44100, 48000, 32000], // MPEG1
164    [22050, 24000, 16000], // MPEG2
165    [11025, 12000, 8000],  // MPEG2.5
166];
167
168/// Parse a 4-byte frame header
169fn parse_header(header: &[u8]) -> Option<FrameHeader> {
170    if header.len() < 4 {
171        return None;
172    }
173
174    // Check sync word (11 bits: 0xFF + upper 3 bits of second byte)
175    if header[0] != 0xFF || (header[1] & 0xE0) != 0xE0 {
176        return None;
177    }
178
179    // MPEG version (bits 4-3 of byte 1)
180    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    // Layer (bits 2-1 of byte 1) - only Layer III supported
189    let layer_bits = (header[1] >> 1) & 0x03;
190    if layer_bits != 0b01 {
191        return None;
192    }
193
194    // Protection bit (bit 0 of byte 1) - 0 means CRC present
195    let has_crc = (header[1] & 0x01) == 0;
196
197    // Bitrate index (bits 7-4 of byte 2)
198    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    // Sample rate index (bits 3-2 of byte 2)
209    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    // Padding (bit 1 of byte 2)
222    let padding = (header[2] & 0x02) != 0;
223
224    // Channel mode (bits 7-6 of byte 3)
225    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    // Calculate frame size
235    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/// Location of a global_gain field within the file
255#[derive(Debug, Clone)]
256struct GainLocation {
257    byte_offset: usize,
258    bit_offset: u8,
259}
260
261/// Calculate global_gain locations within a frame's side information
262fn 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
300/// Read 8-bit value at bit-unaligned position
301fn 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
319/// Write 8-bit value at bit-unaligned position
320fn 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
342/// Skip ID3v2 tag at beginning of data
343fn 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
356/// Check if a frame contains a Xing or Info VBR header
357/// These frames should be skipped when applying gain adjustments
358/// to match the behavior of the original mp3gain
359fn is_xing_frame(data: &[u8], frame_offset: usize, header: &FrameHeader) -> bool {
360    // Calculate where the Xing/Info header would be located
361    // It appears after the side information
362    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    // Check if we have enough data
372    if xing_offset + 4 > data.len() {
373        return false;
374    }
375
376    // Check for "Xing" (VBR) or "Info" (CBR with LAME header) markers
377    let marker = &data[xing_offset..xing_offset + 4];
378    marker == b"Xing" || marker == b"Info"
379}
380
381/// Internal function to iterate over frames
382fn 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
421/// Analyze an MP3 file and return gain statistics
422///
423/// # Arguments
424/// * `file_path` - Path to MP3 file
425///
426/// # Returns
427/// * Analysis results including frame count, gain range, and headroom
428pub 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
474/// Apply gain adjustment to MP3 file (lossless)
475///
476/// # Arguments
477/// * `file_path` - Path to MP3 file
478/// * `gain_steps` - Number of 1.5dB steps to apply (positive = louder)
479///
480/// # Returns
481/// * Number of frames modified
482pub 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        // Skip Xing/Info header frames (VBR metadata)
516        // This matches the behavior of the original mp3gain
517        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
544/// Apply gain adjustment in dB (converted to nearest step)
545///
546/// # Arguments
547/// * `file_path` - Path to MP3 file
548/// * `gain_db` - Gain in decibels (positive = louder)
549///
550/// # Returns
551/// * Number of frames modified
552pub 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
557/// Convert dB gain to MP3 gain steps
558pub fn db_to_steps(db: f64) -> i32 {
559    (db / GAIN_STEP_DB).round() as i32
560}
561
562/// Convert MP3 gain steps to dB
563pub fn steps_to_db(steps: i32) -> f64 {
564    steps as f64 * GAIN_STEP_DB
565}
566
567/// Channel selection for independent gain adjustment
568#[derive(Debug, Clone, Copy, PartialEq)]
569pub enum Channel {
570    /// Left channel (channel 0)
571    Left,
572    /// Right channel (channel 1)
573    Right,
574}
575
576impl Channel {
577    /// Get channel index (0 for left, 1 for right)
578    pub fn index(&self) -> usize {
579        match self {
580            Channel::Left => 0,
581            Channel::Right => 1,
582        }
583    }
584
585    /// Create from index (0 = left, 1 = right)
586    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
595/// Check if an MP3 file is mono
596pub fn is_mono(file_path: &Path) -> Result<bool> {
597    let analysis = analyze(file_path)?;
598    Ok(analysis.channel_mode == "Mono")
599}
600
601/// Apply gain adjustment to a specific channel only (lossless)
602///
603/// # Arguments
604/// * `file_path` - Path to MP3 file
605/// * `channel` - Which channel to adjust (Left or Right)
606/// * `gain_steps` - Number of 1.5dB steps to apply (positive = louder)
607///
608/// # Returns
609/// * Number of frames modified
610///
611/// # Errors
612/// * Returns error if file is mono (no separate channels)
613pub 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    // Check if file is mono
619    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        // Skip Xing/Info header frames (VBR metadata)
654        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        // Apply gain only to the target channel
664        // Locations are ordered: [gr0_ch0, gr0_ch1, gr1_ch0, gr1_ch1] for stereo MPEG1
665        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
689/// Apply channel-specific gain and store undo information in APEv2 tag
690pub 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    // Check if file is mono before doing anything
700    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    // Read existing APE tag or create new one
706    let mut tag = read_ape_tag_from_file(file_path)?.unwrap_or_else(ApeTag::new);
707
708    // Get existing undo values (left, right)
709    let (existing_left, existing_right) = parse_undo_values(tag.get(TAG_MP3GAIN_UNDO));
710
711    // Update the appropriate channel
712    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    // Store original min/max if not already stored
720    if tag.get(TAG_MP3GAIN_MINMAX).is_none() {
721        tag.set_minmax(analysis.min_gain, analysis.max_gain);
722    }
723
724    // Apply the gain
725    let frames = apply_gain_channel(file_path, channel, gain_steps)?;
726
727    // Write APE tag
728    write_ape_tag(file_path, &tag)?;
729
730    Ok(frames)
731}
732
733/// Parse MP3GAIN_UNDO tag value into (left_gain, right_gain)
734fn 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
752// =============================================================================
753// APEv2 Tag Support
754// =============================================================================
755
756/// APEv2 tag preamble
757const APE_PREAMBLE: &[u8; 8] = b"APETAGEX";
758
759/// APEv2 tag version
760const APE_VERSION: u32 = 2000;
761
762/// APEv2 tag flags
763const APE_FLAG_HEADER_PRESENT: u32 = 1 << 31;
764const APE_FLAG_IS_HEADER: u32 = 1 << 29;
765
766/// MP3Gain specific tag keys
767pub 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
771/// ReplayGain tag keys
772pub 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/// APEv2 tag item
778#[derive(Debug, Clone)]
779pub struct ApeItem {
780    pub key: String,
781    pub value: String,
782}
783
784/// APEv2 tag collection
785#[derive(Debug, Clone, Default)]
786pub struct ApeTag {
787    items: Vec<ApeItem>,
788}
789
790impl ApeTag {
791    /// Create a new empty APE tag
792    pub fn new() -> Self {
793        Self { items: Vec::new() }
794    }
795
796    /// Get a tag value by key (case-insensitive)
797    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    /// Set a tag value (replaces existing if present)
806    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    /// Remove a tag by key
823    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    /// Check if tag is empty
830    pub fn is_empty(&self) -> bool {
831        self.items.is_empty()
832    }
833
834    /// Get MP3GAIN_UNDO value as gain steps
835    pub fn get_undo_gain(&self) -> Option<i32> {
836        self.get(TAG_MP3GAIN_UNDO).and_then(|v| {
837            // Format: "+002,+002,N" or similar
838            // First field is the left channel adjustment, second is right
839            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    /// Set MP3GAIN_UNDO value
849    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    /// Set MP3GAIN_MINMAX value
856    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
862/// Find APEv2 tag footer position in file data
863fn find_ape_footer(data: &[u8]) -> Option<usize> {
864    if data.len() < 32 {
865        return None;
866    }
867
868    // Check for APE tag at end of file
869    let footer_start = data.len() - 32;
870    if &data[footer_start..footer_start + 8] == APE_PREAMBLE {
871        return Some(footer_start);
872    }
873
874    // Check if there's an ID3v1 tag (128 bytes) before APE footer
875    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
887/// Read u32 little-endian from slice
888fn read_u32_le(data: &[u8]) -> u32 {
889    u32::from_le_bytes([data[0], data[1], data[2], data[3]])
890}
891
892/// Read APEv2 tag from file data
893pub fn read_ape_tag(data: &[u8]) -> Option<ApeTag> {
894    let footer_start = find_ape_footer(data)?;
895
896    // Parse footer
897    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    // Calculate items start (tag_size includes items + footer, not header)
906    if footer_start + 32 < tag_size {
907        return None;
908    }
909    let items_start = footer_start + 32 - tag_size;
910
911    // Parse items
912    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; // skip value_size + flags
922
923        // Find null-terminated key
924        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; // skip null terminator
934
935        // Read value
936        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
948/// Read APEv2 tag from file
949pub 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
955/// Serialize APE tag to bytes
956fn 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    // Serialize items
964    for item in &tag.items {
965        let value_bytes = item.value.as_bytes();
966        let key_bytes = item.key.as_bytes();
967
968        // Value size (4 bytes)
969        items_data.extend_from_slice(&(value_bytes.len() as u32).to_le_bytes());
970        // Item flags (4 bytes) - 0 for UTF-8 text
971        items_data.extend_from_slice(&0u32.to_le_bytes());
972        // Key (null-terminated)
973        items_data.extend_from_slice(key_bytes);
974        items_data.push(0);
975        // Value
976        items_data.extend_from_slice(value_bytes);
977    }
978
979    let tag_size = items_data.len() + 32; // items + footer
980    let item_count = tag.items.len() as u32;
981
982    let mut result = Vec::new();
983
984    // Header
985    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]); // reserved
991
992    // Items
993    result.extend_from_slice(&items_data);
994
995    // Footer
996    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]); // reserved
1002
1003    result
1004}
1005
1006/// Remove existing APE tag from file data, returning the audio data portion
1007fn 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    // Get tag size from footer
1014    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    // Calculate where audio ends
1020    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    // Check for ID3v1 after APE
1027    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        // Keep audio + ID3v1
1032        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
1040/// Write APEv2 tag to file
1041pub 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    // Remove existing APE tag
1046    let mut audio_data = remove_ape_tag(&data);
1047
1048    // Check for ID3v1 at end
1049    let has_id3v1 = audio_data.len() >= 128
1050        && &audio_data[audio_data.len() - 128..audio_data.len() - 125] == b"TAG";
1051
1052    // Serialize new tag
1053    let tag_data = serialize_ape_tag(tag);
1054
1055    // Reconstruct file: audio + APE tag + ID3v1 (if present)
1056    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
1071/// Delete APEv2 tag from file
1072pub 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
1084/// Find maximum amplitude in an MP3 file
1085/// Returns (max_amplitude, max_global_gain, min_global_gain)
1086pub 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    // Calculate max amplitude based on max_gain
1106    // Higher global_gain = louder audio
1107    // The relationship is: each step of global_gain is 1.5 dB
1108    // Max amplitude is reached when global_gain is at maximum
1109    // We estimate amplitude as a value between 0 and 1 based on headroom
1110    let headroom_steps = (MAX_GAIN - max_gain) as i32;
1111    let headroom_db = headroom_steps as f64 * GAIN_STEP_DB;
1112
1113    // Convert headroom to amplitude (inverse logarithmic relationship)
1114    // If headroom is 0, max amplitude is 1.0 (clipping threshold)
1115    // For each 1.5 dB of headroom, amplitude decreases by ~16%
1116    let max_amplitude = 10.0_f64.powf(-headroom_db / 20.0);
1117
1118    Ok((max_amplitude, max_gain, min_gain))
1119}
1120
1121/// Apply gain with wrapping (values wrap around instead of clamping)
1122pub 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        // Skip Xing/Info header frames (VBR metadata)
1156        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            // Wrap around instead of clamping
1166            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
1180/// Apply gain with wrapping and store undo information in APEv2 tag
1181pub 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    // First, get current min/max before modification
1187    let analysis = analyze(file_path)?;
1188
1189    // Read existing APE tag or create new one
1190    let mut tag = read_ape_tag_from_file(file_path)?.unwrap_or_else(ApeTag::new);
1191
1192    // Store or update undo information
1193    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); // true = wrap mode
1196
1197    // Store original min/max if not already stored
1198    if tag.get(TAG_MP3GAIN_MINMAX).is_none() {
1199        tag.set_minmax(analysis.min_gain, analysis.max_gain);
1200    }
1201
1202    // Apply the gain with wrapping
1203    let frames = apply_gain_wrap(file_path, gain_steps)?;
1204
1205    // Write APE tag
1206    write_ape_tag(file_path, &tag)?;
1207
1208    Ok(frames)
1209}
1210
1211/// Apply gain and store undo information in APEv2 tag
1212pub 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    // First, get current min/max before modification
1218    let analysis = analyze(file_path)?;
1219
1220    // Read existing APE tag or create new one
1221    let mut tag = read_ape_tag_from_file(file_path)?.unwrap_or_else(ApeTag::new);
1222
1223    // Store or update undo information
1224    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    // Store original min/max if not already stored
1229    if tag.get(TAG_MP3GAIN_MINMAX).is_none() {
1230        tag.set_minmax(analysis.min_gain, analysis.max_gain);
1231    }
1232
1233    // Apply the gain
1234    let frames = apply_gain(file_path, gain_steps)?;
1235
1236    // Write APE tag
1237    write_ape_tag(file_path, &tag)?;
1238
1239    Ok(frames)
1240}
1241
1242/// Undo gain changes based on APEv2 tag information
1243pub 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    // Apply inverse gain
1256    let frames = apply_gain(file_path, -undo_gain)?;
1257
1258    // Update or remove undo tag
1259    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        // Create a minimal frame with Xing header for MPEG1 stereo
1346        // Frame header (4 bytes) + side info (32 bytes for stereo) + "Xing"
1347        let mut data = vec![0u8; 100];
1348        data[0] = 0xFF;
1349        data[1] = 0xFB; // MPEG1, Layer III, no CRC
1350        data[2] = 0x90; // 128kbps, 44100Hz
1351        data[3] = 0x00; // Stereo
1352
1353        // Place "Xing" at offset 4 (header) + 32 (side info for MPEG1 stereo) = 36
1354        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        // Test "Info" marker (used by LAME for CBR files)
1363        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        // Test non-Xing frame
1370        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}