Skip to content

Commit ff469f9

Browse files
m_igashim_igashi
authored andcommitted
feat: add -l option for left/right channel independent gain adjustment
Implements Issue #12: - Add -l <channel> <gain> option (0=left, 1=right) - Add apply_gain_channel and apply_gain_channel_with_undo functions - Error handling for mono files (channel-specific gain not supported) - Update help text and README documentation Implements Issue #11: - Add comprehensive integration test suite (21 tests) - Test coverage for gain application, undo, channel-specific gain - Test coverage for different MP3 formats (stereo, mono, VBR, joint stereo) - Add CI workflow for automated testing on all platforms - Tests generate fixtures using ffmpeg in CI
1 parent 0b2300c commit ff469f9

File tree

6 files changed

+971
-4
lines changed

6 files changed

+971
-4
lines changed

.github/workflows/ci.yml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master, main]
6+
pull_request:
7+
branches: [master, main]
8+
9+
env:
10+
CARGO_TERM_COLOR: always
11+
12+
jobs:
13+
test:
14+
name: Test on ${{ matrix.os }}
15+
runs-on: ${{ matrix.os }}
16+
strategy:
17+
matrix:
18+
os: [ubuntu-latest, macos-latest, windows-latest]
19+
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- name: Install Rust
24+
uses: dtolnay/rust-toolchain@stable
25+
26+
- name: Cache cargo registry
27+
uses: actions/cache@v4
28+
with:
29+
path: |
30+
~/.cargo/registry
31+
~/.cargo/git
32+
target
33+
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
34+
35+
- name: Install ffmpeg (Ubuntu)
36+
if: runner.os == 'Linux'
37+
run: sudo apt-get update && sudo apt-get install -y ffmpeg
38+
39+
- name: Install ffmpeg (macOS)
40+
if: runner.os == 'macOS'
41+
run: brew install ffmpeg
42+
43+
- name: Install ffmpeg (Windows)
44+
if: runner.os == 'Windows'
45+
run: choco install ffmpeg
46+
47+
- name: Generate test fixtures
48+
run: |
49+
mkdir -p tests/fixtures
50+
ffmpeg -y -f lavfi -i "sine=frequency=440:duration=1" -ac 2 -ar 44100 -b:a 128k -f mp3 tests/fixtures/test_stereo.mp3
51+
ffmpeg -y -f lavfi -i "sine=frequency=440:duration=1" -ac 1 -ar 44100 -b:a 64k -f mp3 tests/fixtures/test_mono.mp3
52+
ffmpeg -y -f lavfi -i "sine=frequency=440:duration=1" -ac 2 -ar 44100 -b:a 128k -joint_stereo 1 -f mp3 tests/fixtures/test_joint_stereo.mp3
53+
ffmpeg -y -f lavfi -i "sine=frequency=440:duration=1" -ac 2 -ar 44100 -q:a 2 -f mp3 tests/fixtures/test_vbr.mp3
54+
55+
- name: Build
56+
run: cargo build --verbose
57+
58+
- name: Run unit tests
59+
run: cargo test --lib --verbose
60+
61+
- name: Run integration tests
62+
run: cargo test --test integration_tests --verbose
63+
64+
- name: Build with replaygain feature
65+
run: cargo build --features replaygain --verbose
66+
67+
clippy:
68+
name: Clippy
69+
runs-on: ubuntu-latest
70+
steps:
71+
- uses: actions/checkout@v4
72+
73+
- name: Install Rust
74+
uses: dtolnay/rust-toolchain@stable
75+
with:
76+
components: clippy
77+
78+
- name: Run clippy
79+
run: cargo clippy -- -D warnings
80+
81+
fmt:
82+
name: Format
83+
runs-on: ubuntu-latest
84+
steps:
85+
- uses: actions/checkout@v4
86+
87+
- name: Install Rust
88+
uses: dtolnay/rust-toolchain@stable
89+
with:
90+
components: rustfmt
91+
92+
- name: Check formatting
93+
run: cargo fmt -- --check

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mp3rgain"
3-
version = "0.6.0"
3+
version = "0.7.0"
44
edition = "2021"
55
authors = ["Masanari Higashi <M-Igashi@users.noreply.github.com>"]
66
description = "Lossless MP3 volume adjustment - a modern mp3gain replacement written in Rust"

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ mp3rgain -g 2 -R /path/to/music
116116
mp3rgain -r -R /path/to/album
117117
```
118118

119+
### Channel-specific gain (stereo balance)
120+
121+
```bash
122+
# Apply +3 steps to left channel only
123+
mp3rgain -l 0 3 song.mp3
124+
125+
# Apply -2 steps to right channel only
126+
mp3rgain -l 1 -2 song.mp3
127+
```
128+
119129
### Dry-run mode
120130

121131
```bash
@@ -159,6 +169,7 @@ Example JSON output:
159169
|--------|-------------|
160170
| `-g <i>` | Apply gain of i steps (each step = 1.5 dB) |
161171
| `-d <n>` | Apply gain of n dB (rounded to nearest step) |
172+
| `-l <c> <g>` | Apply gain to left (0) or right (1) channel only |
162173
| `-r` | Apply Track gain (ReplayGain analysis) |
163174
| `-a` | Apply Album gain (ReplayGain analysis) |
164175
| `-u` | Undo gain changes (restore from APEv2 tag) |

src/lib.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,185 @@ pub fn steps_to_db(steps: i32) -> f64 {
531531
steps as f64 * GAIN_STEP_DB
532532
}
533533

534+
/// Channel selection for independent gain adjustment
535+
#[derive(Debug, Clone, Copy, PartialEq)]
536+
pub enum Channel {
537+
/// Left channel (channel 0)
538+
Left,
539+
/// Right channel (channel 1)
540+
Right,
541+
}
542+
543+
impl Channel {
544+
/// Get channel index (0 for left, 1 for right)
545+
pub fn index(&self) -> usize {
546+
match self {
547+
Channel::Left => 0,
548+
Channel::Right => 1,
549+
}
550+
}
551+
552+
/// Create from index (0 = left, 1 = right)
553+
pub fn from_index(index: usize) -> Option<Self> {
554+
match index {
555+
0 => Some(Channel::Left),
556+
1 => Some(Channel::Right),
557+
_ => None,
558+
}
559+
}
560+
}
561+
562+
/// Check if an MP3 file is mono
563+
pub fn is_mono(file_path: &Path) -> Result<bool> {
564+
let analysis = analyze(file_path)?;
565+
Ok(analysis.channel_mode == "Mono")
566+
}
567+
568+
/// Apply gain adjustment to a specific channel only (lossless)
569+
///
570+
/// # Arguments
571+
/// * `file_path` - Path to MP3 file
572+
/// * `channel` - Which channel to adjust (Left or Right)
573+
/// * `gain_steps` - Number of 1.5dB steps to apply (positive = louder)
574+
///
575+
/// # Returns
576+
/// * Number of frames modified
577+
///
578+
/// # Errors
579+
/// * Returns error if file is mono (no separate channels)
580+
pub fn apply_gain_channel(file_path: &Path, channel: Channel, gain_steps: i32) -> Result<usize> {
581+
if gain_steps == 0 {
582+
return Ok(0);
583+
}
584+
585+
// Check if file is mono
586+
let analysis = analyze(file_path)?;
587+
if analysis.channel_mode == "Mono" {
588+
anyhow::bail!("Cannot apply channel-specific gain to mono file. Use -g for mono files.");
589+
}
590+
591+
let mut data =
592+
fs::read(file_path).with_context(|| format!("Failed to read: {}", file_path.display()))?;
593+
594+
let mut modified_frames = 0;
595+
let file_size = data.len();
596+
let mut pos = skip_id3v2(&data);
597+
let target_channel = channel.index();
598+
599+
while pos + 4 <= file_size {
600+
let header = match parse_header(&data[pos..]) {
601+
Some(h) => h,
602+
None => {
603+
pos += 1;
604+
continue;
605+
}
606+
};
607+
608+
let next_pos = pos + header.frame_size;
609+
let valid_frame = if next_pos + 2 <= file_size {
610+
data[next_pos] == 0xFF && (data[next_pos + 1] & 0xE0) == 0xE0
611+
} else {
612+
next_pos <= file_size
613+
};
614+
615+
if !valid_frame {
616+
pos += 1;
617+
continue;
618+
}
619+
620+
let locations = calculate_gain_locations(pos, &header);
621+
let num_channels = header.channel_mode.channel_count();
622+
let num_granules = header.granule_count();
623+
624+
// Apply gain only to the target channel
625+
// Locations are ordered: [gr0_ch0, gr0_ch1, gr1_ch0, gr1_ch1] for stereo MPEG1
626+
for gr in 0..num_granules {
627+
let loc_index = gr * num_channels + target_channel;
628+
if loc_index < locations.len() {
629+
let loc = &locations[loc_index];
630+
let current_gain = read_gain_at(&data, loc);
631+
let new_gain = if gain_steps > 0 {
632+
current_gain.saturating_add(gain_steps.min(255) as u8)
633+
} else {
634+
current_gain.saturating_sub((-gain_steps).min(255) as u8)
635+
};
636+
write_gain_at(&mut data, loc, new_gain);
637+
}
638+
}
639+
640+
modified_frames += 1;
641+
pos = next_pos;
642+
}
643+
644+
fs::write(file_path, &data)
645+
.with_context(|| format!("Failed to write: {}", file_path.display()))?;
646+
647+
Ok(modified_frames)
648+
}
649+
650+
/// Apply channel-specific gain and store undo information in APEv2 tag
651+
pub fn apply_gain_channel_with_undo(
652+
file_path: &Path,
653+
channel: Channel,
654+
gain_steps: i32,
655+
) -> Result<usize> {
656+
if gain_steps == 0 {
657+
return Ok(0);
658+
}
659+
660+
// Check if file is mono before doing anything
661+
let analysis = analyze(file_path)?;
662+
if analysis.channel_mode == "Mono" {
663+
anyhow::bail!("Cannot apply channel-specific gain to mono file. Use -g for mono files.");
664+
}
665+
666+
// Read existing APE tag or create new one
667+
let mut tag = read_ape_tag_from_file(file_path)?.unwrap_or_else(ApeTag::new);
668+
669+
// Get existing undo values (left, right)
670+
let (existing_left, existing_right) = parse_undo_values(tag.get(TAG_MP3GAIN_UNDO));
671+
672+
// Update the appropriate channel
673+
let (new_left, new_right) = match channel {
674+
Channel::Left => (existing_left + gain_steps, existing_right),
675+
Channel::Right => (existing_left, existing_right + gain_steps),
676+
};
677+
678+
tag.set_undo_gain(new_left, new_right, false);
679+
680+
// Store original min/max if not already stored
681+
if tag.get(TAG_MP3GAIN_MINMAX).is_none() {
682+
tag.set_minmax(analysis.min_gain, analysis.max_gain);
683+
}
684+
685+
// Apply the gain
686+
let frames = apply_gain_channel(file_path, channel, gain_steps)?;
687+
688+
// Write APE tag
689+
write_ape_tag(file_path, &tag)?;
690+
691+
Ok(frames)
692+
}
693+
694+
/// Parse MP3GAIN_UNDO tag value into (left_gain, right_gain)
695+
fn parse_undo_values(undo_str: Option<&str>) -> (i32, i32) {
696+
match undo_str {
697+
Some(v) => {
698+
let parts: Vec<&str> = v.split(',').collect();
699+
let left = parts
700+
.first()
701+
.and_then(|s| s.trim().parse::<i32>().ok())
702+
.unwrap_or(0);
703+
let right = parts
704+
.get(1)
705+
.and_then(|s| s.trim().parse::<i32>().ok())
706+
.unwrap_or(left);
707+
(left, right)
708+
}
709+
None => (0, 0),
710+
}
711+
}
712+
534713
// =============================================================================
535714
// APEv2 Tag Support
536715
// =============================================================================

0 commit comments

Comments
 (0)