Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/Http/Controllers/Api/MediaController.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ public function details(MediaItem $item): JsonResponse
// "not measured", and inventing "HD" from a filename would be a
// guess presented as a fact.
'capabilities' => $item->probe?->capabilities() ?? [],
// Every audio track, so a client can offer the choice. A rip
// routinely carries the original language, a dub or two and a
// commentary; the probe has recorded them all along and nothing
// was ever sent, so every client played whichever came first.
'audio_tracks' => $item->probe?->audioTracks() ?? [],

// The facts a detail page is built from. Flat and null-free, so a
// client renders what is present and skips what is not rather than
Expand Down
24 changes: 24 additions & 0 deletions app/Http/Controllers/HlsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,16 @@ public function playlist(Request $request, MediaItem $item): Response
$from = max(0.0, (float) $request->float('from'));
$signed = $this->signsSegments($request);

// Which audio track to encode. Clamped to what the file actually
// has: a request for track 9 of a two-track file would otherwise map
// nothing and produce a silent stream.
$audioTrack = $this->audioTrackFor($request, $item);

$session = $this->segmenter->start(
$item,
$decision->maxHeight ?? (int) config('transcode.video.max_height', 1080),
$from,
$audioTrack,
);

abort_if($session === null, 503, 'Could not start the stream.');
Expand Down Expand Up @@ -136,6 +142,24 @@ public function playlist(Request $request, MediaItem $item): Response
* Keyed on the request reaching an API route rather than on the guard, so
* the web player is unaffected by anything that changes about tokens.
*/
/**
* The requested audio track, clamped to what the file has.
*
* A request for track 9 of a two-track file maps nothing and produces a
* **silent** stream -- which looks like a broken encode rather than a bad
* parameter, so it is clamped rather than refused.
*/
private function audioTrackFor(Request $request, MediaItem $item): int
{
$requested = max(0, $request->integer('audio', 0));

$item->loadMissing('probe');

$available = count($item->probe?->audioTracks() ?? []);

return $available > 0 ? min($requested, $available - 1) : 0;
}

private function signsSegments(Request $request): bool
{
return $request->is('api/*');
Expand Down
104 changes: 104 additions & 0 deletions app/Models/MediaProbe.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,110 @@ public function playsDirectly(): ?bool
return $audio->contains(fn (string $codec): bool => in_array($codec, self::PLAYABLE_AUDIO, true));
}

/**
* The audio tracks, as something a client can put in a menu.
*
* A rip of a film routinely carries half a dozen: the original language,
* a dub or two, a commentary, a descriptive track. The probe has recorded
* all of them all along and nothing was ever sent, so every client played
* whichever one happened to be first and offered no way to change it.
*
* The index is what matters functionally -- it is what `-map 0:a:N` and
* `AVMediaSelectionOption` both key on -- and the label is what makes the
* menu readable.
*
* @return array<int, array{index: int, label: string, language: string|null, channels: int|null, codec: string|null, default: bool}>
*/
public function audioTracks(): array
{
$tracks = [];

foreach (array_values((array) ($this->audio_streams ?? [])) as $index => $stream) {
$stream = (array) $stream;

$tracks[] = [
'index' => $index,
'label' => $this->audioTrackLabel($stream, $index),
'language' => $stream['language'] ?? null,
'channels' => isset($stream['channels']) ? (int) $stream['channels'] : null,
'codec' => $stream['codec'] ?? null,
'default' => (bool) ($stream['default'] ?? false),
];
}

return $tracks;
}

/**
* What to call one track in a menu.
*
* Language first, because that is what somebody is choosing between, and
* the channel layout after it -- "English 5.1" distinguishes the surround
* mix from the stereo one, which is the other reason to have this menu.
*
* A track with no language at all falls back to its position, because
* "Track 2" is still a choice somebody can make and an empty row is not.
*/
private function audioTrackLabel(array $stream, int $index): string
{
$parts = [];

$language = is_string($stream['language'] ?? null) ? trim($stream['language']) : '';

if ($language !== '' && strtolower($language) !== 'und') {
$parts[] = self::LANGUAGE_NAMES[strtolower($language)] ?? strtoupper($language);
}

$channels = isset($stream['channels']) ? (int) $stream['channels'] : 0;

$layout = match (true) {
$channels >= 8 => '7.1',
$channels >= 6 => '5.1',
$channels === 2 => 'Stereo',
$channels === 1 => 'Mono',
default => null,
};

if ($layout !== null) {
$parts[] = $layout;
}

return $parts === [] ? 'Track '.($index + 1) : implode(' ', $parts);
}

/**
* The handful of languages worth spelling out.
*
* Deliberately short: a three-letter code is readable enough for the
* unusual ones, and a complete ISO table is a lot of rows to carry for a
* menu that mostly says "English".
*/
private const LANGUAGE_NAMES = [
'eng' => 'English',
'en' => 'English',
'spa' => 'Spanish',
'es' => 'Spanish',
'fra' => 'French',
'fre' => 'French',
'fr' => 'French',
'deu' => 'German',
'ger' => 'German',
'de' => 'German',
'ita' => 'Italian',
'it' => 'Italian',
'jpn' => 'Japanese',
'ja' => 'Japanese',
'kor' => 'Korean',
'ko' => 'Korean',
'por' => 'Portuguese',
'pt' => 'Portuguese',
'rus' => 'Russian',
'ru' => 'Russian',
'zho' => 'Chinese',
'chi' => 'Chinese',
'zh' => 'Chinese',
];

public function audioLabel(): ?string
{
$channels = collect($this->audio_streams ?? [])
Expand Down
29 changes: 22 additions & 7 deletions app/Services/Streaming/HlsSegmenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ class HlsSegmenter
* Returns null when ffmpeg could not be started at all, which the caller
* turns into a direct-play fallback rather than a broken player.
*/
public function start(MediaItem $item, int $maxHeight, float $from = 0.0): ?string
public function start(MediaItem $item, int $maxHeight, float $from = 0.0, int $audioTrack = 0): ?string
{
$source = $item->playbackPath();

if ($source === null) {
return null;
}

$session = $this->sessionId($item, $maxHeight, $from);
$session = $this->sessionId($item, $maxHeight, $from, $audioTrack);
$directory = $this->directoryFor($session);

// Already running or finished for this exact request: reuse it. A
Expand All @@ -71,7 +71,7 @@ public function start(MediaItem $item, int $maxHeight, float $from = 0.0): ?stri
return null;
}

$command = $this->command($source, $directory, $maxHeight, $from);
$command = $this->command($source, $directory, $maxHeight, $from, $audioTrack);

// Started detached: this runs for as long as the film does, and the
// request that asked for it must return as soon as the first segment
Expand Down Expand Up @@ -325,13 +325,17 @@ public function fileIn(string $session, string $name): ?string
* backwards past what has been produced starts a new one rather than
* waiting for a segment that will never come.
*/
private function sessionId(MediaItem $item, int $maxHeight, float $from): string
private function sessionId(MediaItem $item, int $maxHeight, float $from, int $audioTrack = 0): string
{
return substr(hash('sha256', implode(':', [
$item->id,
$item->updated_at?->timestamp ?? 0,
$maxHeight,
(int) round($from),
// The track is part of the identity: switching language has to
// produce a different stream, not reuse the one already encoded
// with the old audio.
$audioTrack,
])), 0, 32);
}

Expand Down Expand Up @@ -372,8 +376,13 @@ private function frameRate(string $source): float
}

/** @return array<int, string> */
private function command(string $source, string $directory, int $maxHeight, float $from): array
{
private function command(
string $source,
string $directory,
int $maxHeight,
float $from,
int $audioTrack = 0,
): array {
$seconds = max(2, (int) config('transcode.hls.segment_seconds', 6));

// Keyframes at segment boundaries, in *frames* — so the frame rate has
Expand All @@ -394,7 +403,13 @@ private function command(string $source, string $directory, int $maxHeight, floa
// One video and one audio stream: many rips carry a dozen audio
// tracks, and HLS players pick badly among them.
'-map', '0:v:0',
'-map', '0:a:0?',
// The chosen track, not always the first.
//
// This was hardcoded to `0:a:0`, so a rip carrying an original
// language, a dub and a commentary always played whichever
// happened to be first -- and no amount of choosing in the app
// could change it, because the stream only ever contained one.
'-map', '0:a:'.$audioTrack.'?',
'-c:v', 'libx264',
// **The output profile has to be pinned.**
//
Expand Down
52 changes: 52 additions & 0 deletions changelog/306-audio-track-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Choosing which audio track to hear

A rip routinely carries the original language, a dub or two and a commentary.
The probe has recorded all of them since it was written — codec, channels,
**language**, the default flag — and **none of it was ever sent to a client**.
So every player played whichever track happened to be first, and offered no
way to change it.

The transcode made that permanent. `-map 0:a:0` was hardcoded, so even a
client that *could* choose had nothing to choose between: the stream contained
exactly one track.

## What changed

- **`audio_tracks` on the detail endpoint** — index, label, language, channels,
codec and the default flag.
- **`?audio=N` on the HLS playlist**, threaded through to `-map 0:a:N`.
- **The track is part of the session id**, so switching language produces a
different stream rather than reusing the one already encoded with the old
audio.

## Labels

"English 5.1" and "English Stereo" in the same menu is the other reason this
exists — picking the surround mix over the downmix. So the label is language
plus layout, not language alone.

A track with no language falls back to "Track 2", because an empty row is not
a choice and that is.

## Clamping rather than refusing

A request for track 9 of a two-track file maps nothing and produces a
**silent** stream — which reads as a broken encode rather than a bad
parameter. It is clamped to what the file has.

## Verified against a real file

Built a two-track MKV (English at 300Hz, Japanese at 900Hz) and ran the
server's own command for each: track 0 produced the 300Hz tone, track 1 the
900Hz one. The selection genuinely changes the audio, not just the arguments.

## Testing

9 new tests, **394 pass**. Three mutations confirmed to fail: the hardcoded
`0:a:0`, the track left out of the session id, and the tracks never reaching
the client.

## Next

The iOS side — a track menu in the player — is the follow-up, and is the first
piece of the custom player work (#517).
Loading
Loading