audio file info added, fixed right click selecting.
This commit is contained in:
@@ -7,12 +7,15 @@ import (
|
||||
)
|
||||
|
||||
// getTrackDuration returns the duration of an audio file in
|
||||
// milliseconds. For MP3 files it uses a fast header-only parser
|
||||
// (Xing/VBRI/CBR); for other formats it falls back to a full
|
||||
// decode via beep which is already O(1) for FLAC, OGG, and WAV.
|
||||
// milliseconds together with its audio stream properties. For MP3
|
||||
// files it uses a fast header-only parser (Xing/VBRI/CBR); for FLAC
|
||||
// it reads the StreamInfo block; for other formats it falls back to
|
||||
// beep which is already O(1) for OGG and WAV.
|
||||
//
|
||||
// The file position is undefined after this call.
|
||||
func getTrackDuration(f *os.File) (int64, error) {
|
||||
func getTrackDuration(
|
||||
f *os.File,
|
||||
) (int64, *AudioProperties, error) {
|
||||
ext := filepath.Ext(f.Name())
|
||||
|
||||
switch ext {
|
||||
@@ -26,7 +29,9 @@ func getTrackDuration(f *os.File) (int64, error) {
|
||||
// (reads headers/metadata only, no full audio decode).
|
||||
streamer, format, err := DecodeFile(f)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error decoding file: %w", err)
|
||||
return 0, nil, fmt.Errorf(
|
||||
"error decoding file: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
lengthMillis := int64(
|
||||
@@ -35,5 +40,11 @@ func getTrackDuration(f *os.File) (int64, error) {
|
||||
)
|
||||
_ = streamer.Close()
|
||||
|
||||
return lengthMillis, nil
|
||||
props := &AudioProperties{
|
||||
SampleRate: int(format.SampleRate),
|
||||
BitDepth: format.Precision * 8,
|
||||
Channels: format.NumChannels,
|
||||
}
|
||||
|
||||
return lengthMillis, props, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ const streamInfoBlockType = 0
|
||||
|
||||
// getFlacDuration computes the duration of a FLAC file in
|
||||
// milliseconds by reading only the StreamInfo metadata block header.
|
||||
// It handles an optional prepended ID3v2 tag by seeking past it.
|
||||
// It also extracts sample rate, bit depth, and channel count from
|
||||
// the same header. It handles an optional prepended ID3v2 tag by
|
||||
// seeking past it.
|
||||
//
|
||||
// This replaces the previous beep/mewkiz-flac decode path which has
|
||||
// a bug in its ID3v2 skip logic (bufio over bufseekio causes a
|
||||
@@ -47,23 +49,25 @@ const streamInfoBlockType = 0
|
||||
// The file position is undefined after this call.
|
||||
//
|
||||
//nolint:mnd // byte offsets and bit shifts from the FLAC spec.
|
||||
func getFlacDuration(f *os.File) (int64, error) {
|
||||
func getFlacDuration(
|
||||
f *os.File,
|
||||
) (int64, *AudioProperties, error) {
|
||||
audioStart, err := skipID3v2(f)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("skipping ID3v2: %w", err)
|
||||
return 0, nil, fmt.Errorf("skipping ID3v2: %w", err)
|
||||
}
|
||||
|
||||
// Read the 4-byte FLAC signature.
|
||||
var sig [4]byte
|
||||
|
||||
if _, err := f.ReadAt(sig[:], audioStart); err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
return 0, nil, fmt.Errorf(
|
||||
"reading FLAC signature: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if sig != flacSignatureBytes {
|
||||
return 0, fmt.Errorf(
|
||||
return 0, nil, fmt.Errorf(
|
||||
"%w: expected %q, got %q",
|
||||
errInvalidFLACSignature, flacSignatureBytes, sig,
|
||||
)
|
||||
@@ -76,7 +80,7 @@ func getFlacDuration(f *os.File) (int64, error) {
|
||||
if _, err := f.ReadAt(
|
||||
mbh[:], audioStart+4,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
return 0, nil, fmt.Errorf(
|
||||
"reading metadata block header: %w", err,
|
||||
)
|
||||
}
|
||||
@@ -89,7 +93,7 @@ func getFlacDuration(f *os.File) (int64, error) {
|
||||
|
||||
if blockType != streamInfoBlockType ||
|
||||
blockLen != streamInfoLength {
|
||||
return 0, fmt.Errorf(
|
||||
return 0, nil, fmt.Errorf(
|
||||
"%w: type=%d, length=%d",
|
||||
errInvalidStreamInfo, blockType, blockLen,
|
||||
)
|
||||
@@ -101,41 +105,50 @@ func getFlacDuration(f *os.File) (int64, error) {
|
||||
if _, err := f.ReadAt(
|
||||
si[:], audioStart+8,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
return 0, nil, fmt.Errorf(
|
||||
"reading StreamInfo block: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
sampleRate, totalSamples := parseFlacStreamInfo(si)
|
||||
sampleRate, totalSamples, channels, bitDepth := parseFlacStreamInfo(si)
|
||||
|
||||
if sampleRate == 0 {
|
||||
return 0, errZeroSampleRate
|
||||
return 0, nil, errZeroSampleRate
|
||||
}
|
||||
|
||||
durationMS := int64(totalSamples) * 1000 /
|
||||
int64(sampleRate)
|
||||
|
||||
return durationMS, nil
|
||||
props := &AudioProperties{
|
||||
SampleRate: int(sampleRate),
|
||||
BitDepth: int(bitDepth),
|
||||
Channels: int(channels),
|
||||
}
|
||||
|
||||
return durationMS, props, nil
|
||||
}
|
||||
|
||||
// parseFlacStreamInfo extracts the sample rate (20 bits) and total
|
||||
// sample count (36 bits) from a 34-byte FLAC StreamInfo body.
|
||||
// parseFlacStreamInfo extracts key fields from a 34-byte FLAC
|
||||
// StreamInfo body.
|
||||
//
|
||||
// StreamInfo layout (bytes 10-17 contain the fields we need):
|
||||
//
|
||||
// bits 0-19: sample rate in Hz (20 bits)
|
||||
// bits 20-22: number of channels -1 (3 bits, unused here)
|
||||
// bits 23-27: bits per sample -1 (5 bits, unused here)
|
||||
// bits 20-22: number of channels -1 (3 bits)
|
||||
// bits 23-27: bits per sample -1 (5 bits)
|
||||
// bits 28-63: total samples (36 bits)
|
||||
//
|
||||
//nolint:mnd // bit offsets from the FLAC spec.
|
||||
func parseFlacStreamInfo(
|
||||
si [streamInfoLength]byte,
|
||||
) (sampleRate uint32, totalSamples uint64) {
|
||||
) (sampleRate uint32, totalSamples uint64, channels uint32, bitDepth uint32) {
|
||||
// Bytes 10-13 packed as big-endian uint32 contain sample rate
|
||||
// in the upper 20 bits.
|
||||
// in the upper 20 bits, channels in bits 9-11, and bits per
|
||||
// sample in bits 4-8.
|
||||
packed := binary.BigEndian.Uint32(si[10:14])
|
||||
sampleRate = packed >> 12
|
||||
channels = (packed>>9)&0x07 + 1
|
||||
bitDepth = (packed>>4)&0x1F + 1
|
||||
|
||||
// Total samples: 4 low bits of byte 13, then bytes 14-17.
|
||||
totalSamples = uint64(si[13]&0x0F)<<32 |
|
||||
@@ -144,5 +157,5 @@ func parseFlacStreamInfo(
|
||||
uint64(si[16])<<8 |
|
||||
uint64(si[17])
|
||||
|
||||
return sampleRate, totalSamples
|
||||
return sampleRate, totalSamples, channels, bitDepth
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestGetFlacDuration_BasicParsing(t *testing.T) {
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
ms, err := getFlacDuration(f)
|
||||
ms, props, err := getFlacDuration(f)
|
||||
if err != nil {
|
||||
t.Fatalf("getFlacDuration: %v", err)
|
||||
}
|
||||
@@ -63,7 +63,36 @@ func TestGetFlacDuration_BasicParsing(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
t.Logf("duration: %dms", ms)
|
||||
if props == nil {
|
||||
t.Fatal("expected non-nil AudioProperties")
|
||||
}
|
||||
|
||||
if props.SampleRate <= 0 {
|
||||
t.Errorf(
|
||||
"expected positive sample rate, got %d",
|
||||
props.SampleRate,
|
||||
)
|
||||
}
|
||||
|
||||
if props.BitDepth <= 0 {
|
||||
t.Errorf(
|
||||
"expected positive bit depth, got %d",
|
||||
props.BitDepth,
|
||||
)
|
||||
}
|
||||
|
||||
if props.Channels <= 0 {
|
||||
t.Errorf(
|
||||
"expected positive channels, got %d",
|
||||
props.Channels,
|
||||
)
|
||||
}
|
||||
|
||||
t.Logf(
|
||||
"duration: %dms rate: %dHz depth: %d ch: %d",
|
||||
ms, props.SampleRate, props.BitDepth,
|
||||
props.Channels,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,7 +115,7 @@ func TestGetFlacDuration_MatchesBeepDecode(t *testing.T) {
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
fastMS, err := getFlacDuration(f)
|
||||
fastMS, _, err := getFlacDuration(f)
|
||||
if err != nil {
|
||||
t.Fatalf("getFlacDuration: %v", err)
|
||||
}
|
||||
@@ -156,7 +185,7 @@ func TestGetFlacDuration_WithPrependedID3v2(t *testing.T) {
|
||||
|
||||
defer func() { _ = origF.Close() }()
|
||||
|
||||
origMS, err := getFlacDuration(origF)
|
||||
origMS, _, err := getFlacDuration(origF)
|
||||
if err != nil {
|
||||
t.Fatalf("getFlacDuration on original: %v", err)
|
||||
}
|
||||
@@ -169,7 +198,7 @@ func TestGetFlacDuration_WithPrependedID3v2(t *testing.T) {
|
||||
|
||||
defer func() { _ = tmpF.Close() }()
|
||||
|
||||
wrappedMS, err := getFlacDuration(tmpF)
|
||||
wrappedMS, _, err := getFlacDuration(tmpF)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"getFlacDuration on ID3v2-wrapped file: %v", err,
|
||||
@@ -222,12 +251,14 @@ func TestParseFlacStreamInfo(t *testing.T) {
|
||||
si[16] = 0x38
|
||||
si[17] = 0x9E
|
||||
|
||||
sr, total := parseFlacStreamInfo(si)
|
||||
sr, total, ch, bps := parseFlacStreamInfo(si)
|
||||
|
||||
//nolint:mnd // expected test values.
|
||||
const (
|
||||
wantSR = 44100
|
||||
wantTotal = 11614366
|
||||
wantSR = 44100
|
||||
wantTotal = 11614366
|
||||
wantChannels = 2
|
||||
wantBPS = 16
|
||||
)
|
||||
|
||||
if sr != wantSR {
|
||||
@@ -240,6 +271,18 @@ func TestParseFlacStreamInfo(t *testing.T) {
|
||||
total, wantTotal,
|
||||
)
|
||||
}
|
||||
|
||||
if ch != wantChannels {
|
||||
t.Errorf(
|
||||
"channels: got %d, want %d", ch, wantChannels,
|
||||
)
|
||||
}
|
||||
|
||||
if bps != wantBPS {
|
||||
t.Errorf(
|
||||
"bits per sample: got %d, want %d", bps, wantBPS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// buildID3v2Header creates a minimal 10-byte ID3v2.3 header with
|
||||
|
||||
@@ -14,6 +14,16 @@ type ExtractionTiming struct {
|
||||
DurationExtraction time.Duration
|
||||
}
|
||||
|
||||
// AudioProperties holds technical properties of an audio file that
|
||||
// are extracted from its stream headers during scanning.
|
||||
type AudioProperties struct {
|
||||
SampleRate int // Sample rate in Hz (e.g. 44100, 96000).
|
||||
BitDepth int // Bits per sample (e.g. 16, 24).
|
||||
Channels int // Number of audio channels (1=mono, 2=stereo).
|
||||
Bitrate int // Bitrate in kbps.
|
||||
FileSize int64 // File size in bytes.
|
||||
}
|
||||
|
||||
// AudioFileExtension represents a supported audio file extension.
|
||||
type AudioFileExtension string
|
||||
|
||||
@@ -60,25 +70,37 @@ func GetTrackLengthMillis(path string) (int64, error) {
|
||||
return lengthMillis, nil
|
||||
}
|
||||
|
||||
// ExtractAllMetadata opens the file once and extracts both tags and duration.
|
||||
// This avoids the overhead of opening the file twice when both are needed.
|
||||
// If skipDuration is true, only tags are extracted and lengthMillis is 0.
|
||||
// ExtractAllMetadata opens the file once and extracts tags, duration,
|
||||
// and audio properties (sample rate, bit depth, channels, bitrate,
|
||||
// file size). If skipDuration is true, only tags are extracted and
|
||||
// the remaining outputs are zero-valued.
|
||||
// The returned ExtractionTiming records how long each sub-operation took.
|
||||
func ExtractAllMetadata(
|
||||
path string,
|
||||
skipDuration bool,
|
||||
) (*TrackMetadata, int64, *ExtractionTiming, error) {
|
||||
) (*TrackMetadata, int64, *AudioProperties, *ExtractionTiming, error) {
|
||||
timing := &ExtractionTiming{}
|
||||
props := &AudioProperties{}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, timing, fmt.Errorf(
|
||||
return nil, 0, props, timing, fmt.Errorf(
|
||||
"could not open file: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
// Capture file size.
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, 0, props, timing, fmt.Errorf(
|
||||
"could not stat file: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
props.FileSize = fi.Size()
|
||||
|
||||
// Extract tags first (reads only headers, fast).
|
||||
tagStart := time.Now()
|
||||
|
||||
@@ -87,33 +109,45 @@ func ExtractAllMetadata(
|
||||
timing.TagExtraction = time.Since(tagStart)
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, timing, fmt.Errorf(
|
||||
return nil, 0, props, timing, fmt.Errorf(
|
||||
"could not extract tags from %s: %w", path, err,
|
||||
)
|
||||
}
|
||||
|
||||
if skipDuration {
|
||||
return tags, 0, timing, nil
|
||||
return tags, 0, props, timing, nil
|
||||
}
|
||||
|
||||
// Seek back to the beginning for duration extraction.
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return tags, 0, timing, fmt.Errorf(
|
||||
return tags, 0, props, timing, fmt.Errorf(
|
||||
"could not seek file for duration: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
durStart := time.Now()
|
||||
|
||||
lengthMillis, err := getTrackDuration(f)
|
||||
lengthMillis, audioProps, err := getTrackDuration(f)
|
||||
|
||||
timing.DurationExtraction = time.Since(durStart)
|
||||
|
||||
if err != nil {
|
||||
return tags, 0, timing, fmt.Errorf(
|
||||
return tags, 0, props, timing, fmt.Errorf(
|
||||
"error getting duration for %s: %w", path, err,
|
||||
)
|
||||
}
|
||||
|
||||
return tags, lengthMillis, timing, nil
|
||||
// Merge stream properties into the result, keeping the
|
||||
// file size we already captured.
|
||||
audioProps.FileSize = props.FileSize
|
||||
|
||||
// Compute bitrate from file size and duration when the
|
||||
// format parser did not provide one (lossless formats).
|
||||
if audioProps.Bitrate == 0 && lengthMillis > 0 {
|
||||
audioProps.Bitrate = int(
|
||||
props.FileSize * 8 / lengthMillis,
|
||||
)
|
||||
}
|
||||
|
||||
return tags, lengthMillis, audioProps, timing, nil
|
||||
}
|
||||
|
||||
@@ -63,23 +63,32 @@ func samplesPerFrame(version int) int {
|
||||
return 576 // MPEG2 / MPEG2.5
|
||||
}
|
||||
|
||||
// mp3BitDepth is the effective bit depth for decoded MP3 audio.
|
||||
// The MPEG standard decodes to 16-bit PCM.
|
||||
const mp3BitDepth = 16
|
||||
|
||||
// getMP3Duration computes the duration of an MP3 file in
|
||||
// milliseconds by reading only the first frame's header and any
|
||||
// Xing/VBRI VBR header it contains. For CBR files (no VBR header)
|
||||
// it falls back to fileSize / bitrate.
|
||||
// it falls back to fileSize / bitrate. It also returns audio
|
||||
// properties extracted from the frame header.
|
||||
//
|
||||
// The file position is undefined after this call.
|
||||
func getMP3Duration(f *os.File) (int64, error) {
|
||||
func getMP3Duration(
|
||||
f *os.File,
|
||||
) (int64, *AudioProperties, error) {
|
||||
// 1. Skip all leading ID3v2 tags. Some files have multiple
|
||||
// consecutive tags from different tagging tools.
|
||||
audioStart, err := skipID3v2(f)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("skipping ID3v2: %w", err)
|
||||
return 0, nil, fmt.Errorf(
|
||||
"skipping ID3v2: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
audioStart, err = skipAdditionalID3v2(f, audioStart)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
return 0, nil, fmt.Errorf(
|
||||
"skipping additional ID3v2 tags: %w", err,
|
||||
)
|
||||
}
|
||||
@@ -87,14 +96,29 @@ func getMP3Duration(f *os.File) (int64, error) {
|
||||
// 2. Find and parse the first MP3 frame header.
|
||||
hdr, frameOffset, err := findFrameHeader(f, audioStart)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// Build audio properties from the frame header.
|
||||
channels := 2
|
||||
if hdr.channelMode == 3 { //nolint:mnd // 3 = mono
|
||||
channels = 1
|
||||
}
|
||||
|
||||
props := &AudioProperties{
|
||||
SampleRate: hdr.sampleRate,
|
||||
BitDepth: mp3BitDepth,
|
||||
Channels: channels,
|
||||
Bitrate: hdr.bitrateKbps,
|
||||
}
|
||||
|
||||
// 3. Attempt to read a VBR header (Xing/Info or VBRI) from
|
||||
// inside the first frame.
|
||||
vbrFrames, found, err := readVBRHeader(f, hdr, frameOffset)
|
||||
vbrFrames, found, err := readVBRHeader(
|
||||
f, hdr, frameOffset,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
if found && vbrFrames > 0 {
|
||||
@@ -102,20 +126,22 @@ func getMP3Duration(f *os.File) (int64, error) {
|
||||
durationMS := int64(vbrFrames) *
|
||||
int64(spf) * 1000 / int64(hdr.sampleRate)
|
||||
|
||||
return durationMS, nil
|
||||
return durationMS, props, nil
|
||||
}
|
||||
|
||||
// 4. CBR fallback: duration = audioBytes * 8 / bitrate.
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("stat file for CBR duration: %w", err)
|
||||
return 0, nil, fmt.Errorf(
|
||||
"stat file for CBR duration: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
audioBytes := fi.Size() - audioStart
|
||||
durationMS := audioBytes * 8 * 1000 /
|
||||
(int64(hdr.bitrateKbps) * 1000)
|
||||
|
||||
return durationMS, nil
|
||||
return durationMS, props, nil
|
||||
}
|
||||
|
||||
// mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) {
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
fastMS, err := getMP3Duration(f)
|
||||
fastMS, _, err := getMP3Duration(f)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"getMP3Duration failed: %v", err,
|
||||
@@ -106,7 +106,7 @@ func TestGetMP3Duration_BasicParsing(t *testing.T) {
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
ms, err := getMP3Duration(f)
|
||||
ms, _, err := getMP3Duration(f)
|
||||
if err != nil {
|
||||
t.Fatalf("getMP3Duration: %v", err)
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) {
|
||||
|
||||
defer func() { _ = origF.Close() }()
|
||||
|
||||
origMS, err := getMP3Duration(origF)
|
||||
origMS, _, err := getMP3Duration(origF)
|
||||
if err != nil {
|
||||
t.Fatalf("getMP3Duration on original: %v", err)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) {
|
||||
|
||||
defer func() { _ = tmpF.Close() }()
|
||||
|
||||
wrappedMS, err := getMP3Duration(tmpF)
|
||||
wrappedMS, _, err := getMP3Duration(tmpF)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"getMP3Duration on multi-ID3v2 file: %v", err,
|
||||
|
||||
Reference in New Issue
Block a user