Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Seek #43

Closed
wants to merge 26 commits into from
Closed

Seek #43

wants to merge 26 commits into from

Conversation

cswank
Copy link
Contributor

@cswank cswank commented Jan 17, 2021

WIP!!!

I got something working with seek table and fixed block sized flac. I'm making a PR now just to see if you think I'm on the right track before going too far.

If the Flac file doesn't have a seek table in its metadata then this builds one. I still need to make it work for FixedBlockSize=false Flac files (gotta find a file like this first).

@karlek
Copy link
Contributor

karlek commented Jan 17, 2021

Hi cswank!

Glad to see someone giving seeking some well-earned love. I'll check the PR tomorrow 👍

TODO: handle cases where sample is invalid:

whence == io.SeekEnd && sample is not negative
whence == io.SeekStart && sample is negative
whence == io.SeekCurrent and sample + current sample < 0 or > stream.Info.NSamples
@cswank
Copy link
Contributor Author

cswank commented Jan 18, 2021

The last commit supports io.SeekCurrent and io.SeekEnd

return 0, nil
// Seek to offset where offset represents the sample number in the flac file
//
// TODO: return an error if sample is invalid? Do nothing?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you think invalid input to Seek should be handled?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your comment about when the sample is invalid is perfect. I think the best would be to create a new error type for those cases. For example:

ErrInvalidSeek = errors.New("stream.Seek: out of stream seek")

We have custom error types in meta.go which are public so that library end-users may check against them.

flac.go Outdated
return err
}

if i%10 == 0 {
Copy link
Contributor Author

@cswank cswank Jan 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the assumption that the number of seek points can be reduced in order to save memory and increase seek performance (don't have to search through so many points in the seek table) at the cost of the seeks having lower resolution. Does this seem appropriate?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's good!

I don't have a good gut feeling what the perfect resolution is, since this library could possibly handle short sound segments (<2s) to long sound segments (>10h+). This could possibly be a configure option, but let's just go with %10 for now.

Or do you have any opinions @mewmew ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use a sane default (e.g. 10% as you suggested), and then at a later point introduce a public API for initializing a custom seek table at arbitrary resolution (that API point would essentially set the unexported seekTable field).

flac.go Show resolved Hide resolved
flac.go Outdated
}

if seeker && stream.seekTable == nil {
return stream, stream.makeSeekTable()
Copy link
Contributor Author

@cswank cswank Jan 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps makeSeekTable should be called when Seek is called for the first time?

For the flac file I've been playing makeSeekTable takes 1.1 seconds, which is kind of a lot.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good suggestion. Seek should probably be an opt-in feature, and we want to keep simple parsing as streamlined as possible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's easy to check if makeSeekTable has been called by checking if stream.seekTable == nil or not.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps makeSeekTable should be called when Seek is called for the first time?

This sounds like a good approach. And then if we introduce an API such as InitSeekTable, then the user could ensure that the seek table is already initialized should they wish before the first call to Seek.

Copy link
Contributor

@karlek karlek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really like your implementation, just a few kinks we need to straighten out before we merge this. Thank you so much for contributing to our repo 🌷

Also, would it be possible to write a test for Seek testing SeekStart, SeekCurrent and SeekEnd with any of the flac files in this repo?

Note: I use the conventional comments format when I review, if you're confused just ask a question and I'll respond as soon as I can or read more about it here: https://conventionalcomments.org/

flac.go Outdated
@@ -48,6 +48,11 @@ type Stream struct {
// Zero or more metadata blocks.
Blocks []*meta.Block
// Underlying io.Reader.

seekTable *meta.SeekTable
// offset where the music data begins since SeekTable.Offset seems to be relative to this position
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Capital letter and punctuation for comments.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: rename start to dataStart or something more descriptive.

flac.go Outdated
br := bufio.NewReader(r)
stream = &Stream{r: br}
isLast, err := stream.parseStreamInfo()
//br := bufio.NewReader(r)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): This is a breaking change, since parsing would no longer be buffered. This can cause performance and stability issues for users of this library.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverting stream.r back to bufio.Reader means losing access to the Seeker (stream.r.(io.ReadSeeker) will fail). I'm trying to think of a way around this, so stay tuned.

@@ -94,41 +121,41 @@ var id3Signature = []byte("ID3")
// stream, and parses the StreamInfo metadata block. It returns a boolean value
// which specifies if the StreamInfo block was the last metadata block of the
// FLAC stream.
func (stream *Stream) parseStreamInfo() (isLast bool, err error) {
func (stream *Stream) parseStreamInfo() (block *meta.Block, err error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Nice refactor, block.IsLast reads even better in New and exposes SeekTable 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: When I think about it, stream's parseStreamInfo has a name conflict with block's parseStreamInfo, maybe we should rename one of them. What do you think @mewmew?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: When I think about it, stream's parseStreamInfo has a name conflict with block's parseStreamInfo, maybe we should rename one of them. What do you think @mewmew?

Both are unexported so we can rename without changing exported API. Personally, I think it is fine as is, but would also be fine with changing the name of one to avoid confusion. The are distinct by one being a function, the other a method, so the call sites will be different. If we do change the name, e.g. of the method parseStreamInfo the names of other methods parsing blocks should be updated for consistency.

flac.go Outdated
br := bufio.NewReader(r)
stream = &Stream{r: br}
isLast, err := stream.parseStreamInfo()
stream = &Stream{r: r}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): This is a breaking change, since parsing would no longer be buffered. This can cause performance and stability issues for users of this library.

flac.go Outdated
@@ -157,18 +181,17 @@ func (stream *Stream) skipID3v2() error {
//
// Call Stream.Next to parse the frame header of the next audio frame, and call
// Stream.ParseNext to parse the entire next frame including audio samples.
func Parse(r io.Reader) (stream *Stream, err error) {
func Parse(r io.ReadSeeker) (stream *Stream, err error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): Calls to flac.Parse won't implement seeking. Since the metadata block will be parsed, but the SeekTable won't be created. The logic from New is missing here to correctly implement seeking in both places.

End-users of this library, should be able to seek regardless if they initialized with New or Parse

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: The names New and Parse don't accurately reflect what they do. At least the functionality is well explained in the documentation. What did we think here @mewmew haha?

flac.go Outdated
return 0, nil
}

r := stream.r.(io.ReadSeeker)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: This shadows r, use rs as you did in New.

flac.go Outdated
}

func (stream *Stream) makeSeekTable() (err error) {
r := stream.r.(io.ReadSeeker)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: This shadows r, use rs as you did in New.

flac.go Outdated
return err
}

if i%10 == 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's good!

I don't have a good gut feeling what the perfect resolution is, since this library could possibly handle short sound segments (<2s) to long sound segments (>10h+). This could possibly be a configure option, but let's just go with %10 for now.

Or do you have any opinions @mewmew ?

flac.go Outdated
}

if i%10 == 0 {
o, err := r.Seek(0, io.SeekCurrent)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Second time I see this pattern, wondering if it would be better to create a function called something like currentOffset?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, no problem if you really want this. However wouldn't that func have to do another stream.r.(io.ReadSeeker) assertion? So it'd either look like:

func (stream *Stream) currentOffset() (int64, error) { rs, ok := stream.r.(io.ReadSeeker) if !ok { return 0, ErrNoSeeker } return rs.Seek(0, io.SeekCurrent) }

Or

func (stream *Stream) currentOffset(rs io.ReadSeeker) (int64, error) { return rs.Seek(0, io.SeekCurrent) }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of like the way it is compared to either of those funcs, but am happy to make the change if you want.

Copy link
Contributor

@karlek karlek Jan 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it doesn't look that much better. Keep it as it is! Thanks for writing it out :)

flac.go Show resolved Hide resolved
flac.go Outdated
if seeker {
stream.r = r
} else {
stream.r = bufio.NewReader(r)
Copy link
Contributor Author

@cswank cswank Jan 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about this change?

Here is the reasoning:

  1. if stream.r is *bufio.Reader then it is no longer possible to assert for io.ReadSeeker and so Seek is no longer possible.
  2. if r is io.ReadSeeker then r must be either *os.File or an in-memory buffer of some kind.
  3. if r is not io.ReadSeeker then it is probably a network stream, like from http.Request.Body???

My guess is that bufio.Reader is really only needed when the reader is streaming over a network. If reader is *os.File or it's an in memory buffer then bufio.Reader really isn't necessary (but you'll have a way better feel for this than I)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about this change?

The change looks good to me, and the reasoning valid.

My guess is that bufio.Reader is really only needed when the reader is streaming over a network. If reader is *os.File or it's an in memory buffer then bufio.Reader really isn't necessary.

I think we did some preliminary tests using *os.File without buffered reading and it did (at least for an older version of mewkiz/flac) impact the performance. This was the primary reason we used buffered reading as default.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If *os.File did cause problems then it seems like explicitly opting in for the ability to Seek is the thing to do.

something like?

`type Option func(stream *Stream) (err error)

func New(r io.Reader, opts ...Option) (stream *Stream, err error) {
...
}

func EnableSeek(stream *Stream) (err error) {
// don't make stream.r a *bufio.Reader and look for seekTable in metadata
...
}
`

flac.go Outdated
@@ -48,6 +49,11 @@ type Stream struct {
// Zero or more metadata blocks.
Blocks []*meta.Block
// Underlying io.Reader.

seekTable *meta.SeekTable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
seekTable *meta.SeekTable
// seekTable contains one or more pre-calculated audio frame seek points of the stream; nil if uninitialized.
seekTable *meta.SeekTable

flac.go Outdated
@@ -48,6 +49,11 @@ type Stream struct {
// Zero or more metadata blocks.
Blocks []*meta.Block
// Underlying io.Reader.

seekTable *meta.SeekTable
// Offset where the music data begins since SeekTable.Offset seems to be relative to this position.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since SeekTable.Offset seems to be relative to this position.

We should try to verify this against the official spec and then update the comment accordingly (e.g. remove "seems").

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been testing with a single Flac file that has a seekTable and its first data point is meta.SeekPoint{SampleNum:0, Offset:0}.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Offset (in bytes) from the first byte of the first frame header to the first byte of the target frame's header."

from https://xiph.org/flac/format.html#metadata_block_seektable, so I removed 'seems'

flac.go Outdated
}

return stream, nil
}

// flacSignature marks the beginning of a FLAC stream.
var flacSignature = []byte("fLaC")
// func EnsureSeekTable(stream *Stream) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final PR should probably remove this commented-out lines?

return p
}
}
return p
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wasn't immediately clear to me that this line returned the zero value. For clarity, consider returning meta.SeekPoint{} which stands out as a zero value, and thus not the "intended" code path.

Then, the function would also be updated to not used named returned values. While this function is not exported it doesn't matter too much. For exported API however, one suggestion is to only used named returns when they help support documentation, e.g. when a function returns two integers (x, y int).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would return the last SeekPoint. I've been playing with ReadSeeker a bit and returning the last point actually seems to mimic the behavior seen here:

https://play.golang.org/p/WStOyCU2wU9

In other words, seeking before the start isn't an error seeking past the end isn't either.

@cswank
Copy link
Contributor Author

cswank commented Jan 19, 2021

@mewmew and @karlek What do you think about?0457be3

As I said in the commit message, I won't be offended if you don't want this.

This commit changes the way Stream is initialized quite a bit
and maybe it is less readable?  I personally think it's more readable
because I really like the variadic argument pattern so I've used it
quite a bit.

I think it addresses some of the concerns:

1:  Use *bufio.Reader as the default in order to match the current
    behaivor by calling

    stream, err = flac.New(r)

2:  Make Seek explicitly optional by calling:

    stream, err = flac.New(r, flac.EnableSeek)
@mewmew
Copy link
Member

mewmew commented Jan 21, 2021

Hi @cswank,

Just a quick heads up. My brother @karlek and I will look at this PR when we find the time to see each other (probably this weekend or next week). Now, we're both busy with work + Uni :)

Thanks a lot for working on the seek support for FLAC, it's really been the last missing feature of the low level API for quite some time. This would mark the perfect transition for discussing the v2.0 API (#33), to split the API into two parts; one high-level user facing, and one low-level API of FLAC.

After a brief discussion with Henry, I think we will try to limit the scope of this PR to functionality changes - in other words the core seek functionality - and then move extended API discussions (e.g. adding Option func(s *Stream, r io.Reader) error to an issue, where we can reach a consensus on a good and consistent approach to use for the API also going forward. This would tie well into the v2.0 API discussion linked above, and we would warmly welcome you to join the discussions Craig. It's always good to have multiple views and different background when designing an API.

Cheers,
Robin

@cswank
Copy link
Contributor Author

cswank commented Jan 21, 2021

Hi Robin,
That sounds like a really good idea.  The PR was starting to spread out into multiple areas. I almost feel like I should close this PR and start over with seek only and no api changes as you suggest. What do you think? The new PR could just add NewSeek (instead of modifying New) and leave the current functionality alone, which would reduce the chances of introducing a breaking change.

EDIT: I have a new branch that I haven't pushed up yet for a new PR.

Craig

@mewmew
Copy link
Member

mewmew commented Jan 21, 2021

That sounds like a really good idea. The PR was starting to spread out into multiple areas. I almost feel like I should close this PR and start over with seek only and no api changes as you suggest. What do you think? The new PR could just add NewSeek (instead of modifying New) and leave the current functionality alone, which would reduce the chances of introducing a breaking change.

Sounds great!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants