cridecoder 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cridecoder-0.2.0 → cridecoder-0.2.1}/Cargo.lock +1 -1
- {cridecoder-0.2.0 → cridecoder-0.2.1}/Cargo.toml +1 -1
- {cridecoder-0.2.0 → cridecoder-0.2.1}/PKG-INFO +1 -1
- cridecoder-0.2.1/cridecoder.pyi +114 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/pyproject.toml +2 -1
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/acb/extractor.rs +64 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/acb.rs +1 -1
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/lib.rs +4 -2
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/python.rs +79 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/usm/extractor.rs +65 -6
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/usm.rs +3 -1
- {cridecoder-0.2.0 → cridecoder-0.2.1}/tests/integration_tests.rs +36 -0
- cridecoder-0.2.1/tests/test_python.py +332 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/.github/copilot-instructions.md +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/.github/dependabot.yml +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/.github/workflows/ci.yml +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/.github/workflows/release-crate.yml +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/.github/workflows/release-python.yml +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/.gitignore +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/AGENTS.md +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/CLAUDE.md +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/LICENSE +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/README.md +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/examples/debug_acb.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/examples/test_acb.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/examples/test_hca.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/examples/test_usm.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/acb/afs.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/acb/builder.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/acb/consts.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/acb/track.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/acb/utf.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca/ath.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca/bitreader.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca/cipher.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca/decoder.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca/encoder.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca/hca_file.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca/imdct.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca/tables.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/hca.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/reader.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/usm/builder.rs +0 -0
- {cridecoder-0.2.0 → cridecoder-0.2.1}/src/usm/metadata.rs +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExtractedTrack(TypedDict):
|
|
7
|
+
name: str
|
|
8
|
+
extension: str
|
|
9
|
+
filename: str
|
|
10
|
+
data: bytes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HcaInfo(TypedDict):
|
|
14
|
+
sample_rate: int
|
|
15
|
+
channels: int
|
|
16
|
+
block_count: int
|
|
17
|
+
block_size: int
|
|
18
|
+
encoder_delay: int
|
|
19
|
+
samples_per_block: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EncodeInfo(TypedDict):
|
|
23
|
+
size: int
|
|
24
|
+
bitrate: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_acb(acb_path: str, output_dir: str) -> Optional[list[str]]: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_acb_bytes(acb_data: bytes, acb_path: Optional[str] = None) -> list[ExtractedTrack]: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_acb(tracks: list[tuple[str, int, bytes]], output_path: str) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_acb_bytes(tracks: list[tuple[str, int, bytes]]) -> bytes: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_music_acb_bytes(
|
|
40
|
+
name: str,
|
|
41
|
+
hca_data: bytes,
|
|
42
|
+
cue_id: int,
|
|
43
|
+
virtual_cue_suffix: Optional[str],
|
|
44
|
+
memory_awb_id: int,
|
|
45
|
+
reference_num_samples: int,
|
|
46
|
+
reference_length_ms: int,
|
|
47
|
+
acb_version: int,
|
|
48
|
+
acf_md5_hash: bytes,
|
|
49
|
+
acb_guid: bytes,
|
|
50
|
+
version_string: str,
|
|
51
|
+
acb_volume: float,
|
|
52
|
+
category_extension: int,
|
|
53
|
+
cue_priority_type: int,
|
|
54
|
+
acf_category_name: str,
|
|
55
|
+
acf_category_id: int,
|
|
56
|
+
acf_bus_names: list[str],
|
|
57
|
+
) -> bytes: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode_hca(hca_path: str, wav_path: str) -> HcaInfo: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_hca_bytes(hca_data: bytes) -> bytes: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def encode_hca(
|
|
67
|
+
wav_path: str,
|
|
68
|
+
hca_path: str,
|
|
69
|
+
bitrate: int = 256000,
|
|
70
|
+
encryption_key: Optional[int] = None,
|
|
71
|
+
) -> EncodeInfo: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def encode_hca_bytes(
|
|
75
|
+
wav_data: bytes,
|
|
76
|
+
sample_rate: Optional[int] = None,
|
|
77
|
+
channels: Optional[int] = None,
|
|
78
|
+
bitrate: int = 256000,
|
|
79
|
+
encryption_key: Optional[int] = None,
|
|
80
|
+
) -> bytes: ...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_usm(
|
|
84
|
+
usm_path: str,
|
|
85
|
+
output_dir: str,
|
|
86
|
+
key: Optional[int] = None,
|
|
87
|
+
export_audio: bool = False,
|
|
88
|
+
) -> list[str]: ...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def extract_usm_bytes(
|
|
92
|
+
usm_data: bytes,
|
|
93
|
+
fallback_name: str = "input.usm",
|
|
94
|
+
key: Optional[int] = None,
|
|
95
|
+
export_audio: bool = False,
|
|
96
|
+
) -> list[ExtractedTrack]: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_usm(
|
|
100
|
+
name: str,
|
|
101
|
+
video_data: bytes,
|
|
102
|
+
output_path: str,
|
|
103
|
+
encryption_key: Optional[int] = None,
|
|
104
|
+
) -> None: ...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_usm_bytes(
|
|
108
|
+
name: str,
|
|
109
|
+
video_data: bytes,
|
|
110
|
+
encryption_key: Optional[int] = None,
|
|
111
|
+
) -> bytes: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_usm_metadata(usm_path: str) -> str: ...
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cridecoder"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "CRI codec library for ACB/AWB, HCA audio, and USM video extraction"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -23,3 +23,4 @@ free-threaded = true
|
|
|
23
23
|
|
|
24
24
|
[tool.maturin]
|
|
25
25
|
features = ["python"]
|
|
26
|
+
include = [{ path = "cridecoder.pyi", format = "wheel" }]
|
|
@@ -23,6 +23,14 @@ pub enum ExtractError {
|
|
|
23
23
|
InvalidAcb,
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/// Audio track data extracted from an ACB/AWB container.
|
|
27
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
28
|
+
pub struct ExtractedAcbTrack {
|
|
29
|
+
pub name: String,
|
|
30
|
+
pub extension: String,
|
|
31
|
+
pub data: Vec<u8>,
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
/// Extract all audio files from an ACB file
|
|
27
35
|
pub fn extract_acb<R: Read + Seek>(
|
|
28
36
|
acb_file: R,
|
|
@@ -44,6 +52,21 @@ pub fn extract_acb<R: Read + Seek>(
|
|
|
44
52
|
)
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
/// Extract all audio tracks from an ACB reader into memory.
|
|
56
|
+
pub fn extract_acb_to_memory<R: Read + Seek>(
|
|
57
|
+
acb_file: R,
|
|
58
|
+
acb_file_path: Option<&Path>,
|
|
59
|
+
) -> Result<Vec<ExtractedAcbTrack>, ExtractError> {
|
|
60
|
+
let utf = UtfTable::new(acb_file)?;
|
|
61
|
+
|
|
62
|
+
let track_list = TrackList::new(&utf)?;
|
|
63
|
+
|
|
64
|
+
let mut embedded_awb = load_embedded_awb(&utf.rows[0]);
|
|
65
|
+
let mut external_awbs = load_external_awbs(&utf.rows[0], acb_file_path);
|
|
66
|
+
|
|
67
|
+
extract_all_tracks_to_memory(&track_list, &mut embedded_awb, &mut external_awbs)
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
fn load_embedded_awb(
|
|
48
71
|
row: &std::collections::HashMap<String, crate::acb::utf::Value>,
|
|
49
72
|
) -> Option<AfsArchive<Cursor<Vec<u8>>>> {
|
|
@@ -120,6 +143,22 @@ fn extract_all_tracks(
|
|
|
120
143
|
Ok(outputs)
|
|
121
144
|
}
|
|
122
145
|
|
|
146
|
+
fn extract_all_tracks_to_memory(
|
|
147
|
+
track_list: &TrackList,
|
|
148
|
+
embedded_awb: &mut Option<AfsArchive<Cursor<Vec<u8>>>>,
|
|
149
|
+
external_awbs: &mut [AfsArchive<Cursor<Vec<u8>>>],
|
|
150
|
+
) -> Result<Vec<ExtractedAcbTrack>, ExtractError> {
|
|
151
|
+
let mut outputs = Vec::new();
|
|
152
|
+
|
|
153
|
+
for track in &track_list.tracks {
|
|
154
|
+
if let Some(output) = extract_single_track_to_memory(track, embedded_awb, external_awbs)? {
|
|
155
|
+
outputs.push(output);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
Ok(outputs)
|
|
160
|
+
}
|
|
161
|
+
|
|
123
162
|
fn extract_single_track(
|
|
124
163
|
track: &Track,
|
|
125
164
|
target_dir: &Path,
|
|
@@ -146,6 +185,31 @@ fn extract_single_track(
|
|
|
146
185
|
Ok(Some(output_path.to_string_lossy().into_owned()))
|
|
147
186
|
}
|
|
148
187
|
|
|
188
|
+
fn extract_single_track_to_memory(
|
|
189
|
+
track: &Track,
|
|
190
|
+
embedded_awb: &mut Option<AfsArchive<Cursor<Vec<u8>>>>,
|
|
191
|
+
external_awbs: &mut [AfsArchive<Cursor<Vec<u8>>>],
|
|
192
|
+
) -> Result<Option<ExtractedAcbTrack>, ExtractError> {
|
|
193
|
+
let ext = wave_type_extension(track.enc_type);
|
|
194
|
+
let extension = if ext.is_empty() {
|
|
195
|
+
track.enc_type.to_string()
|
|
196
|
+
} else {
|
|
197
|
+
ext.trim_start_matches('.').to_string()
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
let data = get_track_data(track, embedded_awb, external_awbs)?;
|
|
201
|
+
let data = match data {
|
|
202
|
+
Some(d) => d,
|
|
203
|
+
None => return Ok(None),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
Ok(Some(ExtractedAcbTrack {
|
|
207
|
+
name: track.name.clone(),
|
|
208
|
+
extension,
|
|
209
|
+
data,
|
|
210
|
+
}))
|
|
211
|
+
}
|
|
212
|
+
|
|
149
213
|
fn get_track_data(
|
|
150
214
|
track: &Track,
|
|
151
215
|
embedded_awb: &mut Option<AfsArchive<Cursor<Vec<u8>>>>,
|
|
@@ -12,6 +12,6 @@ pub use builder::{
|
|
|
12
12
|
AcbBuilder, AfsArchiveBuilder, BuilderError, ColumnDef, TrackInput, UtfTableBuilder,
|
|
13
13
|
};
|
|
14
14
|
pub use consts::*;
|
|
15
|
-
pub use extractor::{extract_acb, extract_acb_from_file};
|
|
15
|
+
pub use extractor::{extract_acb, extract_acb_from_file, extract_acb_to_memory, ExtractedAcbTrack};
|
|
16
16
|
pub use track::{Track, TrackList};
|
|
17
17
|
pub use utf::{UtfHeader, UtfTable, Value};
|
|
@@ -14,7 +14,7 @@ pub mod usm;
|
|
|
14
14
|
mod python;
|
|
15
15
|
|
|
16
16
|
// ACB/AWB exports
|
|
17
|
-
pub use acb::{extract_acb, extract_acb_from_file};
|
|
17
|
+
pub use acb::{extract_acb, extract_acb_from_file, extract_acb_to_memory, ExtractedAcbTrack};
|
|
18
18
|
pub use acb::{AcbBuilder, AfsArchiveBuilder, BuilderError, TrackInput, UtfTableBuilder};
|
|
19
19
|
|
|
20
20
|
// HCA exports
|
|
@@ -22,7 +22,9 @@ pub use hca::{encode_wav_to_hca, HcaEncoder, HcaEncoderConfig, HcaEncoderError};
|
|
|
22
22
|
pub use hca::{HcaDecoder, HcaDecoderError, HcaInfo};
|
|
23
23
|
|
|
24
24
|
// USM exports
|
|
25
|
-
pub use usm::{
|
|
25
|
+
pub use usm::{
|
|
26
|
+
extract_usm, extract_usm_file, extract_usm_to_memory, ExtractedUsmStream, Metadata, UsmError,
|
|
27
|
+
};
|
|
26
28
|
pub use usm::{UsmBuilder, UsmBuilderError};
|
|
27
29
|
|
|
28
30
|
#[cfg(feature = "python")]
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
use pyo3::exceptions::PyRuntimeError;
|
|
9
9
|
use pyo3::prelude::*;
|
|
10
|
+
use pyo3::types::{PyBytes, PyDict, PyList};
|
|
10
11
|
|
|
11
12
|
use std::fs;
|
|
12
13
|
use std::io::Cursor;
|
|
@@ -37,6 +38,38 @@ fn extract_acb(acb_path: &str, output_dir: &str) -> PyResult<Option<Vec<String>>
|
|
|
37
38
|
.map_err(|e| PyRuntimeError::new_err(format!("ACB extraction failed: {}", e)))
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/// Extract audio tracks from ACB data into memory.
|
|
42
|
+
///
|
|
43
|
+
/// Args:
|
|
44
|
+
/// acb_data: Raw ACB file data as bytes
|
|
45
|
+
/// acb_path: Optional ACB path, used to resolve external AWB files
|
|
46
|
+
///
|
|
47
|
+
/// Returns:
|
|
48
|
+
/// List of dicts with name, extension, filename, and data bytes
|
|
49
|
+
#[pyfunction]
|
|
50
|
+
#[pyo3(signature = (acb_data, acb_path=None))]
|
|
51
|
+
fn extract_acb_bytes<'py>(
|
|
52
|
+
py: Python<'py>,
|
|
53
|
+
acb_data: &[u8],
|
|
54
|
+
acb_path: Option<String>,
|
|
55
|
+
) -> PyResult<Bound<'py, PyList>> {
|
|
56
|
+
let acb_path = acb_path.as_deref().map(Path::new);
|
|
57
|
+
let tracks = acb::extract_acb_to_memory(Cursor::new(acb_data.to_vec()), acb_path)
|
|
58
|
+
.map_err(|e| PyRuntimeError::new_err(format!("ACB extraction failed: {}", e)))?;
|
|
59
|
+
|
|
60
|
+
let list = PyList::empty(py);
|
|
61
|
+
for track in tracks {
|
|
62
|
+
let dict = PyDict::new(py);
|
|
63
|
+
let filename = format!("{}.{}", track.name, track.extension);
|
|
64
|
+
dict.set_item("name", track.name)?;
|
|
65
|
+
dict.set_item("extension", track.extension)?;
|
|
66
|
+
dict.set_item("filename", filename)?;
|
|
67
|
+
dict.set_item("data", PyBytes::new(py, &track.data))?;
|
|
68
|
+
list.append(dict)?;
|
|
69
|
+
}
|
|
70
|
+
Ok(list)
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
/// Build an ACB file from track data.
|
|
41
74
|
///
|
|
42
75
|
/// Args:
|
|
@@ -419,6 +452,50 @@ fn extract_usm(
|
|
|
419
452
|
.collect())
|
|
420
453
|
}
|
|
421
454
|
|
|
455
|
+
/// Extract video/audio streams from USM data into memory.
|
|
456
|
+
///
|
|
457
|
+
/// Args:
|
|
458
|
+
/// usm_data: Raw USM file data as bytes
|
|
459
|
+
/// fallback_name: Name used when the USM metadata has no filename
|
|
460
|
+
/// key: Optional decryption key (u64)
|
|
461
|
+
/// export_audio: Whether to export audio streams (default: false)
|
|
462
|
+
///
|
|
463
|
+
/// Returns:
|
|
464
|
+
/// List of dicts with name, extension, filename, and data bytes
|
|
465
|
+
#[pyfunction]
|
|
466
|
+
#[pyo3(signature = (usm_data, fallback_name="input.usm", key=None, export_audio=false))]
|
|
467
|
+
fn extract_usm_bytes<'py>(
|
|
468
|
+
py: Python<'py>,
|
|
469
|
+
usm_data: &[u8],
|
|
470
|
+
fallback_name: &str,
|
|
471
|
+
key: Option<u64>,
|
|
472
|
+
export_audio: bool,
|
|
473
|
+
) -> PyResult<Bound<'py, PyList>> {
|
|
474
|
+
let streams = usm::extract_usm_to_memory(
|
|
475
|
+
Cursor::new(usm_data.to_vec()),
|
|
476
|
+
fallback_name.as_bytes(),
|
|
477
|
+
key,
|
|
478
|
+
export_audio,
|
|
479
|
+
)
|
|
480
|
+
.map_err(|e| PyRuntimeError::new_err(format!("USM extraction failed: {}", e)))?;
|
|
481
|
+
|
|
482
|
+
let list = PyList::empty(py);
|
|
483
|
+
for stream in streams {
|
|
484
|
+
let dict = PyDict::new(py);
|
|
485
|
+
let name = Path::new(&stream.filename)
|
|
486
|
+
.file_stem()
|
|
487
|
+
.and_then(|stem| stem.to_str())
|
|
488
|
+
.unwrap_or(&stream.filename)
|
|
489
|
+
.to_string();
|
|
490
|
+
dict.set_item("name", name)?;
|
|
491
|
+
dict.set_item("extension", stream.extension)?;
|
|
492
|
+
dict.set_item("filename", stream.filename)?;
|
|
493
|
+
dict.set_item("data", PyBytes::new(py, &stream.data))?;
|
|
494
|
+
list.append(dict)?;
|
|
495
|
+
}
|
|
496
|
+
Ok(list)
|
|
497
|
+
}
|
|
498
|
+
|
|
422
499
|
/// Build a USM file from video data.
|
|
423
500
|
///
|
|
424
501
|
/// Args:
|
|
@@ -504,6 +581,7 @@ fn read_usm_metadata(usm_path: &str) -> PyResult<String> {
|
|
|
504
581
|
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
505
582
|
// ACB functions
|
|
506
583
|
m.add_function(wrap_pyfunction!(extract_acb, m)?)?;
|
|
584
|
+
m.add_function(wrap_pyfunction!(extract_acb_bytes, m)?)?;
|
|
507
585
|
m.add_function(wrap_pyfunction!(build_acb, m)?)?;
|
|
508
586
|
m.add_function(wrap_pyfunction!(build_acb_bytes, m)?)?;
|
|
509
587
|
m.add_function(wrap_pyfunction!(build_music_acb_bytes, m)?)?;
|
|
@@ -516,6 +594,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
516
594
|
|
|
517
595
|
// USM functions
|
|
518
596
|
m.add_function(wrap_pyfunction!(extract_usm, m)?)?;
|
|
597
|
+
m.add_function(wrap_pyfunction!(extract_usm_bytes, m)?)?;
|
|
519
598
|
m.add_function(wrap_pyfunction!(build_usm, m)?)?;
|
|
520
599
|
m.add_function(wrap_pyfunction!(build_usm_bytes, m)?)?;
|
|
521
600
|
m.add_function(wrap_pyfunction!(read_usm_metadata, m)?)?;
|
|
@@ -66,6 +66,14 @@ pub type UtfRow = std::collections::HashMap<String, UtfValue>;
|
|
|
66
66
|
/// A UTF table
|
|
67
67
|
pub type UtfTable = Vec<UtfRow>;
|
|
68
68
|
|
|
69
|
+
/// A stream extracted from a USM container.
|
|
70
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
71
|
+
pub struct ExtractedUsmStream {
|
|
72
|
+
pub filename: String,
|
|
73
|
+
pub extension: String,
|
|
74
|
+
pub data: Vec<u8>,
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
/// Read column data from a UTF table
|
|
70
78
|
fn read_column_data<R: Read + Seek>(
|
|
71
79
|
reader: &mut Reader<R>,
|
|
@@ -352,6 +360,57 @@ pub fn extract_usm<R: Read + Seek>(
|
|
|
352
360
|
Ok(output_files)
|
|
353
361
|
}
|
|
354
362
|
|
|
363
|
+
/// Extract USM video/audio streams into memory.
|
|
364
|
+
pub fn extract_usm_to_memory<R: Read + Seek>(
|
|
365
|
+
usm: R,
|
|
366
|
+
fallback_name: &[u8],
|
|
367
|
+
key: Option<u64>,
|
|
368
|
+
export_audio: bool,
|
|
369
|
+
) -> Result<Vec<ExtractedUsmStream>, UsmError> {
|
|
370
|
+
let mut reader = Reader::new(usm);
|
|
371
|
+
|
|
372
|
+
let (vmask, amask) = key.map(get_mask).unzip();
|
|
373
|
+
|
|
374
|
+
let (filename, has_audio) = parse_usm_header(&mut reader, fallback_name)?;
|
|
375
|
+
let decoded_filename = decode_shift_jis(&filename);
|
|
376
|
+
|
|
377
|
+
let base_name = Path::new(&decoded_filename)
|
|
378
|
+
.file_stem()
|
|
379
|
+
.and_then(|s| s.to_str())
|
|
380
|
+
.unwrap_or(&decoded_filename)
|
|
381
|
+
.to_string();
|
|
382
|
+
|
|
383
|
+
let mut video = Vec::new();
|
|
384
|
+
let mut audio = if has_audio && export_audio {
|
|
385
|
+
Some(Vec::new())
|
|
386
|
+
} else {
|
|
387
|
+
None
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
extract_usm_chunks(
|
|
391
|
+
&mut reader,
|
|
392
|
+
&mut video,
|
|
393
|
+
audio.as_mut(),
|
|
394
|
+
vmask.as_ref(),
|
|
395
|
+
amask.as_ref(),
|
|
396
|
+
)?;
|
|
397
|
+
|
|
398
|
+
let mut streams = vec![ExtractedUsmStream {
|
|
399
|
+
filename: format!("{base_name}.m2v"),
|
|
400
|
+
extension: "m2v".to_string(),
|
|
401
|
+
data: video,
|
|
402
|
+
}];
|
|
403
|
+
if let Some(audio) = audio {
|
|
404
|
+
streams.push(ExtractedUsmStream {
|
|
405
|
+
filename: format!("{base_name}.adx"),
|
|
406
|
+
extension: "adx".to_string(),
|
|
407
|
+
data: audio,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
Ok(streams)
|
|
412
|
+
}
|
|
413
|
+
|
|
355
414
|
/// Parse USM header
|
|
356
415
|
fn parse_usm_header<R: Read + Seek>(
|
|
357
416
|
reader: &mut Reader<R>,
|
|
@@ -511,10 +570,10 @@ fn create_output_files(
|
|
|
511
570
|
}
|
|
512
571
|
|
|
513
572
|
/// Extract USM chunks
|
|
514
|
-
fn extract_usm_chunks<R: Read + Seek>(
|
|
573
|
+
fn extract_usm_chunks<R: Read + Seek, V: Write, A: Write>(
|
|
515
574
|
reader: &mut Reader<R>,
|
|
516
|
-
video_file: &mut
|
|
517
|
-
mut audio_file: Option<&mut
|
|
575
|
+
video_file: &mut V,
|
|
576
|
+
mut audio_file: Option<&mut A>,
|
|
518
577
|
vmask: Option<&Vec<Vec<u8>>>,
|
|
519
578
|
amask: Option<&Vec<u8>>,
|
|
520
579
|
) -> Result<(), UsmError> {
|
|
@@ -558,13 +617,13 @@ fn extract_usm_chunks<R: Read + Seek>(
|
|
|
558
617
|
|
|
559
618
|
/// Process a chunk
|
|
560
619
|
#[allow(clippy::too_many_arguments)]
|
|
561
|
-
fn process_chunk<R: Read + Seek>(
|
|
620
|
+
fn process_chunk<R: Read + Seek, V: Write, A: Write>(
|
|
562
621
|
reader: &mut Reader<R>,
|
|
563
622
|
sig: &[u8],
|
|
564
623
|
read_data_len: usize,
|
|
565
624
|
data_type: u8,
|
|
566
|
-
video_file: &mut
|
|
567
|
-
audio_file: &mut Option<&mut
|
|
625
|
+
video_file: &mut V,
|
|
626
|
+
audio_file: &mut Option<&mut A>,
|
|
568
627
|
vmask: Option<&Vec<Vec<u8>>>,
|
|
569
628
|
amask: Option<&Vec<u8>>,
|
|
570
629
|
) -> Result<(), UsmError> {
|
|
@@ -8,7 +8,9 @@ mod extractor;
|
|
|
8
8
|
mod metadata;
|
|
9
9
|
|
|
10
10
|
pub use builder::{StreamInput, StreamType, UsmBuilder, UsmBuilderError};
|
|
11
|
-
pub use extractor::{
|
|
11
|
+
pub use extractor::{
|
|
12
|
+
extract_usm, extract_usm_file, extract_usm_to_memory, ExtractedUsmStream, UsmError,
|
|
13
|
+
};
|
|
12
14
|
pub use metadata::{
|
|
13
15
|
export_metadata_file, read_metadata, read_metadata_file, Metadata, MetadataSection,
|
|
14
16
|
};
|
|
@@ -387,6 +387,23 @@ fn test_acb_from_memory() {
|
|
|
387
387
|
assert_eq!(tracks.len(), 4, "Should extract 4 tracks from memory");
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
/// Test extract_acb_to_memory returns track bytes without writing files
|
|
391
|
+
#[test]
|
|
392
|
+
fn test_acb_to_memory() {
|
|
393
|
+
let acb_path = Path::new("se_0126_01.acb");
|
|
394
|
+
if !acb_path.exists() {
|
|
395
|
+
eprintln!("Skipping test: se_0126_01.acb not found");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let data = fs::read(acb_path).unwrap();
|
|
400
|
+
let tracks = cridecoder::extract_acb_to_memory(Cursor::new(data), None)
|
|
401
|
+
.expect("Should extract ACB tracks into memory");
|
|
402
|
+
assert_eq!(tracks.len(), 4, "Should extract 4 tracks into memory");
|
|
403
|
+
assert!(tracks.iter().all(|track| track.extension == "hca"));
|
|
404
|
+
assert!(tracks.iter().all(|track| !track.data.is_empty()));
|
|
405
|
+
}
|
|
406
|
+
|
|
390
407
|
/// Test extract_usm with in-memory data
|
|
391
408
|
#[test]
|
|
392
409
|
fn test_usm_from_memory() {
|
|
@@ -404,6 +421,25 @@ fn test_usm_from_memory() {
|
|
|
404
421
|
assert!(!files.is_empty(), "Should extract at least one file");
|
|
405
422
|
}
|
|
406
423
|
|
|
424
|
+
/// Test extract_usm_to_memory returns stream bytes without writing files
|
|
425
|
+
#[test]
|
|
426
|
+
fn test_usm_to_memory() {
|
|
427
|
+
let usm_path = Path::new("0703.usm");
|
|
428
|
+
if !usm_path.exists() {
|
|
429
|
+
eprintln!("Skipping test: 0703.usm not found");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let data = fs::read(usm_path).unwrap();
|
|
434
|
+
let streams =
|
|
435
|
+
cridecoder::usm::extract_usm_to_memory(Cursor::new(data), b"0703.usm", None, false)
|
|
436
|
+
.expect("Should extract USM streams into memory");
|
|
437
|
+
assert_eq!(streams.len(), 1);
|
|
438
|
+
assert_eq!(streams[0].filename, "0703.m2v");
|
|
439
|
+
assert_eq!(streams[0].extension, "m2v");
|
|
440
|
+
assert!(!streams[0].data.is_empty());
|
|
441
|
+
}
|
|
442
|
+
|
|
407
443
|
// =============================================================================
|
|
408
444
|
// Encoder Tests
|
|
409
445
|
// =============================================================================
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python integration tests for cridecoder.
|
|
3
|
+
|
|
4
|
+
Tests encoding and decoding functionality:
|
|
5
|
+
- HCA encoding/decoding round-trip
|
|
6
|
+
- ACB/USM container building (structure verification)
|
|
7
|
+
- Real file extraction and HCA encoding
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
# Skip if cridecoder not available
|
|
15
|
+
pytest.importorskip("cridecoder")
|
|
16
|
+
|
|
17
|
+
import cridecoder
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestHcaRoundtrip:
|
|
21
|
+
"""Test HCA encoding and decoding round-trip."""
|
|
22
|
+
|
|
23
|
+
def test_encode_decode_roundtrip(self):
|
|
24
|
+
"""Test encoding WAV to HCA and decoding back."""
|
|
25
|
+
# Create a simple sine wave WAV
|
|
26
|
+
wav_data = create_test_wav(sample_rate=44100, channels=2, duration_sec=0.5)
|
|
27
|
+
|
|
28
|
+
# Encode to HCA
|
|
29
|
+
hca_data = cridecoder.encode_hca_bytes(wav_data, bitrate=256000)
|
|
30
|
+
|
|
31
|
+
# Verify HCA magic
|
|
32
|
+
assert hca_data[:4] == b"HCA\x00", "Should have HCA magic"
|
|
33
|
+
|
|
34
|
+
# Decode back to WAV
|
|
35
|
+
decoded_wav = cridecoder.decode_hca_bytes(hca_data)
|
|
36
|
+
|
|
37
|
+
# Verify WAV magic
|
|
38
|
+
assert decoded_wav[:4] == b"RIFF", "Should have RIFF magic"
|
|
39
|
+
assert decoded_wav[8:12] == b"WAVE", "Should have WAVE format"
|
|
40
|
+
|
|
41
|
+
def test_encode_with_encryption(self):
|
|
42
|
+
"""Test HCA encoding with encryption key."""
|
|
43
|
+
wav_data = create_test_wav(sample_rate=44100, channels=1, duration_sec=0.2)
|
|
44
|
+
|
|
45
|
+
# Encode with encryption
|
|
46
|
+
hca_data = cridecoder.encode_hca_bytes(
|
|
47
|
+
wav_data,
|
|
48
|
+
bitrate=128000,
|
|
49
|
+
encryption_key=0x1234567890ABCDEF
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Verify it's encrypted (header will be masked)
|
|
53
|
+
# Encrypted HCA has masked magic: 0xC8C3C100 instead of 0x48434100
|
|
54
|
+
assert hca_data[0] == 0xC8, "Should have masked HCA magic"
|
|
55
|
+
|
|
56
|
+
def test_encode_mono(self):
|
|
57
|
+
"""Test encoding mono audio."""
|
|
58
|
+
wav_data = create_test_wav(sample_rate=22050, channels=1, duration_sec=0.3)
|
|
59
|
+
hca_data = cridecoder.encode_hca_bytes(wav_data, bitrate=128000)
|
|
60
|
+
assert hca_data[:4] == b"HCA\x00"
|
|
61
|
+
|
|
62
|
+
# Verify can decode
|
|
63
|
+
decoded = cridecoder.decode_hca_bytes(hca_data)
|
|
64
|
+
assert decoded[:4] == b"RIFF"
|
|
65
|
+
|
|
66
|
+
def test_encode_stereo(self):
|
|
67
|
+
"""Test encoding stereo audio."""
|
|
68
|
+
wav_data = create_test_wav(sample_rate=48000, channels=2, duration_sec=0.3)
|
|
69
|
+
hca_data = cridecoder.encode_hca_bytes(wav_data, bitrate=320000)
|
|
70
|
+
assert hca_data[:4] == b"HCA\x00"
|
|
71
|
+
|
|
72
|
+
decoded = cridecoder.decode_hca_bytes(hca_data)
|
|
73
|
+
assert decoded[:4] == b"RIFF"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestContainerBuilding:
|
|
77
|
+
"""Test container building (structure verification only)."""
|
|
78
|
+
|
|
79
|
+
def test_build_acb_structure(self):
|
|
80
|
+
"""Test ACB building produces valid UTF structure."""
|
|
81
|
+
wav_data = create_test_wav(sample_rate=44100, channels=2, duration_sec=0.1)
|
|
82
|
+
hca_data = cridecoder.encode_hca_bytes(wav_data, bitrate=256000)
|
|
83
|
+
|
|
84
|
+
tracks = [("test_track", 0, hca_data)]
|
|
85
|
+
acb_data = cridecoder.build_acb_bytes(tracks)
|
|
86
|
+
|
|
87
|
+
# Verify ACB magic (@UTF)
|
|
88
|
+
assert acb_data[:4] == b"@UTF", "Should have UTF magic"
|
|
89
|
+
assert len(acb_data) > 100, "Should have substantial content"
|
|
90
|
+
|
|
91
|
+
def test_extract_acb_bytes(self):
|
|
92
|
+
"""Test extracting ACB tracks without writing files."""
|
|
93
|
+
wav_data = create_test_wav(sample_rate=44100, channels=1, duration_sec=0.1)
|
|
94
|
+
hca_data = cridecoder.encode_hca_bytes(wav_data, bitrate=128000)
|
|
95
|
+
acb_data = cridecoder.build_acb_bytes([("memory_track", 0, hca_data)])
|
|
96
|
+
|
|
97
|
+
tracks = cridecoder.extract_acb_bytes(acb_data)
|
|
98
|
+
|
|
99
|
+
assert len(tracks) == 1
|
|
100
|
+
assert tracks[0]["name"] == "memory_track"
|
|
101
|
+
assert tracks[0]["extension"] == "hca"
|
|
102
|
+
assert tracks[0]["filename"] == "memory_track.hca"
|
|
103
|
+
assert tracks[0]["data"][:4] == b"HCA\x00"
|
|
104
|
+
|
|
105
|
+
def test_build_acb_multiple_tracks(self):
|
|
106
|
+
"""Test ACB building with multiple tracks."""
|
|
107
|
+
tracks = []
|
|
108
|
+
for i in range(3):
|
|
109
|
+
wav_data = create_test_wav(sample_rate=44100, channels=1, duration_sec=0.1)
|
|
110
|
+
hca_data = cridecoder.encode_hca_bytes(wav_data, bitrate=128000)
|
|
111
|
+
tracks.append((f"track_{i}", i, hca_data))
|
|
112
|
+
|
|
113
|
+
acb_data = cridecoder.build_acb_bytes(tracks)
|
|
114
|
+
assert acb_data[:4] == b"@UTF"
|
|
115
|
+
|
|
116
|
+
def test_build_usm_structure(self):
|
|
117
|
+
"""Test USM building produces valid CRID structure."""
|
|
118
|
+
# Create minimal M2V header
|
|
119
|
+
video_data = bytes([
|
|
120
|
+
0x00, 0x00, 0x01, 0xB3, # sequence_header_code
|
|
121
|
+
0x14, 0x00, 0xF0, 0x24, # picture size
|
|
122
|
+
0xFF, 0xFF, 0xE0, 0x00, # bit rate
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
usm_data = cridecoder.build_usm_bytes("test_video", video_data)
|
|
126
|
+
|
|
127
|
+
# Verify CRID magic
|
|
128
|
+
assert usm_data[:4] == b"CRID", "Should have CRID magic"
|
|
129
|
+
assert len(usm_data) > 100, "Should have substantial content"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestRealFileOperations:
|
|
133
|
+
"""Test operations with real fixture files."""
|
|
134
|
+
|
|
135
|
+
@pytest.fixture
|
|
136
|
+
def acb_path(self):
|
|
137
|
+
"""Path to test ACB file."""
|
|
138
|
+
path = "se_0126_01.acb"
|
|
139
|
+
if not os.path.exists(path):
|
|
140
|
+
pytest.skip("Test fixture se_0126_01.acb not found")
|
|
141
|
+
return path
|
|
142
|
+
|
|
143
|
+
@pytest.fixture
|
|
144
|
+
def usm_path(self):
|
|
145
|
+
"""Path to test USM file."""
|
|
146
|
+
path = "0703.usm"
|
|
147
|
+
if not os.path.exists(path):
|
|
148
|
+
pytest.skip("Test fixture 0703.usm not found")
|
|
149
|
+
return path
|
|
150
|
+
|
|
151
|
+
def test_extract_acb(self, acb_path):
|
|
152
|
+
"""Test extracting real ACB file."""
|
|
153
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
154
|
+
extracted = cridecoder.extract_acb(acb_path, tmpdir)
|
|
155
|
+
assert extracted is not None
|
|
156
|
+
assert len(extracted) > 0, "Should extract tracks"
|
|
157
|
+
|
|
158
|
+
# Verify extracted files exist
|
|
159
|
+
for path in extracted:
|
|
160
|
+
assert os.path.exists(path), f"Extracted file should exist: {path}"
|
|
161
|
+
|
|
162
|
+
def test_extract_usm(self, usm_path):
|
|
163
|
+
"""Test extracting real USM file."""
|
|
164
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
165
|
+
extracted = cridecoder.extract_usm(usm_path, tmpdir)
|
|
166
|
+
assert len(extracted) > 0, "Should extract files"
|
|
167
|
+
|
|
168
|
+
# Verify M2V extracted
|
|
169
|
+
m2v_files = [p for p in extracted if p.endswith(".m2v")]
|
|
170
|
+
assert len(m2v_files) > 0, "Should extract M2V video"
|
|
171
|
+
|
|
172
|
+
def test_extract_usm_bytes(self, usm_path):
|
|
173
|
+
"""Test extracting real USM streams without writing files."""
|
|
174
|
+
with open(usm_path, "rb") as f:
|
|
175
|
+
usm_data = f.read()
|
|
176
|
+
|
|
177
|
+
streams = cridecoder.extract_usm_bytes(usm_data, fallback_name=os.path.basename(usm_path))
|
|
178
|
+
|
|
179
|
+
assert len(streams) > 0
|
|
180
|
+
m2v_streams = [stream for stream in streams if stream["extension"] == "m2v"]
|
|
181
|
+
assert len(m2v_streams) > 0, "Should extract M2V video"
|
|
182
|
+
assert m2v_streams[0]["data"].startswith(b"\x00\x00\x01")
|
|
183
|
+
|
|
184
|
+
def test_hca_decode_and_reencode(self, acb_path):
|
|
185
|
+
"""Extract HCA, decode to WAV, and re-encode to HCA."""
|
|
186
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
187
|
+
# Extract ACB
|
|
188
|
+
extracted = cridecoder.extract_acb(acb_path, tmpdir)
|
|
189
|
+
assert extracted is not None and len(extracted) > 0
|
|
190
|
+
|
|
191
|
+
# Decode first HCA to WAV
|
|
192
|
+
hca_path = extracted[0]
|
|
193
|
+
wav_path = os.path.join(tmpdir, "decoded.wav")
|
|
194
|
+
info = cridecoder.decode_hca(hca_path, wav_path)
|
|
195
|
+
|
|
196
|
+
assert info["sample_rate"] > 0
|
|
197
|
+
assert info["channels"] > 0
|
|
198
|
+
|
|
199
|
+
# Read WAV and re-encode
|
|
200
|
+
with open(wav_path, "rb") as f:
|
|
201
|
+
wav_data = f.read()
|
|
202
|
+
|
|
203
|
+
new_hca_data = cridecoder.encode_hca_bytes(
|
|
204
|
+
wav_data,
|
|
205
|
+
sample_rate=info["sample_rate"],
|
|
206
|
+
channels=info["channels"],
|
|
207
|
+
bitrate=256000
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
assert new_hca_data[:4] == b"HCA\x00", "Should produce valid HCA"
|
|
211
|
+
|
|
212
|
+
# Verify the new HCA can be decoded
|
|
213
|
+
decoded_again = cridecoder.decode_hca_bytes(new_hca_data)
|
|
214
|
+
assert decoded_again[:4] == b"RIFF", "Re-encoded HCA should decode"
|
|
215
|
+
|
|
216
|
+
def test_usm_extract_and_rebuild_video(self, usm_path):
|
|
217
|
+
"""Extract M2V from USM and build new USM with it."""
|
|
218
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
219
|
+
# Extract
|
|
220
|
+
extracted = cridecoder.extract_usm(usm_path, tmpdir)
|
|
221
|
+
|
|
222
|
+
# Find M2V
|
|
223
|
+
m2v_path = None
|
|
224
|
+
for path in extracted:
|
|
225
|
+
if path.endswith(".m2v"):
|
|
226
|
+
m2v_path = path
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
assert m2v_path is not None, "Should have M2V"
|
|
230
|
+
|
|
231
|
+
# Read M2V and build new USM
|
|
232
|
+
with open(m2v_path, "rb") as f:
|
|
233
|
+
m2v_data = f.read()
|
|
234
|
+
|
|
235
|
+
rebuilt_path = os.path.join(tmpdir, "rebuilt.usm")
|
|
236
|
+
cridecoder.build_usm("rebuilt", m2v_data, rebuilt_path)
|
|
237
|
+
|
|
238
|
+
# Verify file created and has CRID header
|
|
239
|
+
assert os.path.exists(rebuilt_path)
|
|
240
|
+
with open(rebuilt_path, "rb") as f:
|
|
241
|
+
header = f.read(4)
|
|
242
|
+
assert header == b"CRID", "Rebuilt USM should have CRID magic"
|
|
243
|
+
|
|
244
|
+
def test_full_acb_pipeline(self, acb_path):
|
|
245
|
+
"""Full pipeline: extract ACB -> decode HCA -> encode HCA -> build ACB."""
|
|
246
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
247
|
+
# 1. Extract original ACB
|
|
248
|
+
extract_dir = os.path.join(tmpdir, "extract")
|
|
249
|
+
os.makedirs(extract_dir)
|
|
250
|
+
extracted = cridecoder.extract_acb(acb_path, extract_dir)
|
|
251
|
+
assert extracted and len(extracted) > 0
|
|
252
|
+
|
|
253
|
+
# 2. Process each track: decode and re-encode
|
|
254
|
+
new_tracks = []
|
|
255
|
+
for i, hca_path in enumerate(extracted[:2]): # First 2 tracks
|
|
256
|
+
# Decode
|
|
257
|
+
wav_path = os.path.join(tmpdir, f"track{i}.wav")
|
|
258
|
+
info = cridecoder.decode_hca(hca_path, wav_path)
|
|
259
|
+
|
|
260
|
+
# Re-encode
|
|
261
|
+
with open(wav_path, "rb") as f:
|
|
262
|
+
wav_data = f.read()
|
|
263
|
+
|
|
264
|
+
new_hca = cridecoder.encode_hca_bytes(
|
|
265
|
+
wav_data,
|
|
266
|
+
sample_rate=info["sample_rate"],
|
|
267
|
+
channels=info["channels"],
|
|
268
|
+
bitrate=256000
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
new_tracks.append((f"track_{i}", i, new_hca))
|
|
272
|
+
|
|
273
|
+
# 3. Build new ACB
|
|
274
|
+
rebuilt_path = os.path.join(tmpdir, "rebuilt.acb")
|
|
275
|
+
cridecoder.build_acb(new_tracks, rebuilt_path)
|
|
276
|
+
|
|
277
|
+
# Verify ACB created
|
|
278
|
+
assert os.path.exists(rebuilt_path)
|
|
279
|
+
with open(rebuilt_path, "rb") as f:
|
|
280
|
+
header = f.read(4)
|
|
281
|
+
assert header == b"@UTF", "Rebuilt ACB should have UTF magic"
|
|
282
|
+
|
|
283
|
+
# Verify substantial size
|
|
284
|
+
size = os.path.getsize(rebuilt_path)
|
|
285
|
+
assert size > 1000, f"ACB should have substantial size, got {size}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def create_test_wav(sample_rate: int, channels: int, duration_sec: float) -> bytes:
|
|
289
|
+
"""Create a simple test WAV file with a sine wave."""
|
|
290
|
+
import struct
|
|
291
|
+
import math
|
|
292
|
+
|
|
293
|
+
num_samples = int(sample_rate * duration_sec)
|
|
294
|
+
bits_per_sample = 16
|
|
295
|
+
byte_rate = sample_rate * channels * bits_per_sample // 8
|
|
296
|
+
block_align = channels * bits_per_sample // 8
|
|
297
|
+
data_size = num_samples * channels * bits_per_sample // 8
|
|
298
|
+
|
|
299
|
+
# Generate sine wave samples
|
|
300
|
+
frequency = 440.0 # A4 note
|
|
301
|
+
samples = []
|
|
302
|
+
for i in range(num_samples):
|
|
303
|
+
t = i / sample_rate
|
|
304
|
+
value = int(32767 * 0.5 * math.sin(2 * math.pi * frequency * t))
|
|
305
|
+
for _ in range(channels):
|
|
306
|
+
samples.append(value)
|
|
307
|
+
|
|
308
|
+
# Build WAV header
|
|
309
|
+
wav = bytearray()
|
|
310
|
+
wav.extend(b"RIFF")
|
|
311
|
+
wav.extend(struct.pack("<I", 36 + data_size))
|
|
312
|
+
wav.extend(b"WAVE")
|
|
313
|
+
wav.extend(b"fmt ")
|
|
314
|
+
wav.extend(struct.pack("<I", 16)) # fmt chunk size
|
|
315
|
+
wav.extend(struct.pack("<H", 1)) # PCM format
|
|
316
|
+
wav.extend(struct.pack("<H", channels))
|
|
317
|
+
wav.extend(struct.pack("<I", sample_rate))
|
|
318
|
+
wav.extend(struct.pack("<I", byte_rate))
|
|
319
|
+
wav.extend(struct.pack("<H", block_align))
|
|
320
|
+
wav.extend(struct.pack("<H", bits_per_sample))
|
|
321
|
+
wav.extend(b"data")
|
|
322
|
+
wav.extend(struct.pack("<I", data_size))
|
|
323
|
+
|
|
324
|
+
# Add sample data
|
|
325
|
+
for sample in samples:
|
|
326
|
+
wav.extend(struct.pack("<h", sample))
|
|
327
|
+
|
|
328
|
+
return bytes(wav)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if __name__ == "__main__":
|
|
332
|
+
pytest.main([__file__, "-v"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|