Skip to content

Commit

Permalink
feat: audio waveform rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
MrExplode committed Nov 5, 2024
1 parent 18b8b91 commit 6cd27a3
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,25 @@
import me.sunstorm.showmanager.modules.http.routing.annotate.Get;
import me.sunstorm.showmanager.modules.http.routing.annotate.PathPrefix;
import me.sunstorm.showmanager.modules.http.routing.annotate.Post;
import me.sunstorm.showmanager.util.Exceptions;
import me.sunstorm.showmanager.util.JsonBuilder;
import me.sunstorm.showmanager.util.Timecode;
import me.sunstorm.showmanager.util.WaveformRunner;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

@PathPrefix("/audio")
public class AudioController {
private static final Logger log = LoggerFactory.getLogger(AudioController.class);

private final WaveformRunner waveformRunner;

private final EventBus eventBus;
private final AudioModule player;
private final WebSocketHandler wsHandler;
Expand All @@ -37,6 +44,8 @@ public AudioController(EventBus eventBus, AudioModule player, WebSocketHandler w
this.eventBus = eventBus;
this.player = player;
this.wsHandler = wsHandler;

this.waveformRunner = new WaveformRunner(Path.of(System.getenv("showmanager.audiowaveform")));
}

@Post("/volume")
Expand Down Expand Up @@ -114,6 +123,31 @@ public void deleteMarker(@NotNull Context ctx) {
new MarkerDeleteEvent().call(eventBus);
}

@Get("/samples")
public void getSamples(@NotNull Context ctx) {
if (player.getCurrent() == null) throw new BadRequestResponse("No tracks are loaded");
try (var out = ctx.outputStream()) {
out.write(waveformRunner.sample(player.getCurrent().getFile().toPath()));
} catch (IOException e) {
Exceptions.sneaky(e);
}
}

// actually not needed for peaks.js, HOWEVER I might do something later on the ui with the sound
// maybe a sound preview?
@Get("/raw")
public void getAudio(@NotNull Context ctx) {
if (player.getCurrent() == null) throw new BadRequestResponse("No tracks are loaded");
try (var out = ctx.outputStream()) {
var data = Files.readAllBytes(player.getCurrent().getFile().toPath());
ctx.header("Content-Type", "audio/wav");
ctx.header("Content-Length", data.length + "");
out.write(data);
} catch (IOException e) {
Exceptions.sneaky(e);
}
}

@NotNull
private JsonArray buildMarkers() {
JsonArray array = new JsonArray();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package me.sunstorm.showmanager.util;

import com.google.common.hash.Hashing;
import me.sunstorm.showmanager.Constants;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class WaveformRunner {
private static final File CACHE = new File(Constants.BASE_DIRECTORY, "wavecache");
private final Path executablePath;

public WaveformRunner(@NotNull Path executablePath) {
this.executablePath = executablePath;
}

public byte[] sample(@NotNull Path wav) throws IOException {
var cached = tryCaching(wav);
if (cached != null) return cached;

var process = new ProcessBuilder()
.command(executablePath.toString(), "-i", wav.toString(), "--output-format", "dat", "-q", "-b", "8")
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.start();
var buf = new ByteArrayOutputStream();
process.getInputStream().transferTo(buf);
try {
var result = process.waitFor();
if (result == 0) {
var data = buf.toByteArray();
saveCache(data, wav.toString());
return data;
} else {
throw new IOException("waveform process exited with non zero code");
}
} catch (InterruptedException e) {
Exceptions.sneaky(e);
return null;
}
}

@Nullable
private byte[] tryCaching(@NotNull Path wav) throws IOException {
var name = Hashing.sha256().hashString(wav.toString(), StandardCharsets.UTF_8).toString();
var cached = CACHE.toPath().resolve(name);
if (Files.exists(cached)) {
return Files.readAllBytes(cached);
}
return null;
}

private void saveCache(byte[] data, String file) throws IOException {
if (!CACHE.isDirectory())
CACHE.mkdirs();
var name = Hashing.sha256().hashString(file, StandardCharsets.UTF_8).toString();
Files.write(CACHE.toPath().resolve(name), data);
}
}
5 changes: 4 additions & 1 deletion webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"konva": "^9.3.16",
"lucide-svelte": "^0.454.0",
"peaks.js": "^3.4.2",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5",
Expand All @@ -41,6 +43,7 @@
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3",
"vitest": "^2.0.4"
"vitest": "^2.0.4",
"waveform-data": "^4.5.0"
}
}
37 changes: 37 additions & 0 deletions webapp/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions webapp/src/lib/components/audio/AudioPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import { loadedAudio, playing, volume, availableTracks } from '$lib/data/audio'
import DataTable from '@/DataTable.svelte'
import { columns } from './track_columns'
import { columns } from '@/audio/track_columns'
import AudioWaveform from '@/audio/AudioWaveform.svelte'
let loadedName = $derived($loadedAudio == '' ? 'No track loaded' : `Loaded: ${$loadedAudio}`)
</script>
Expand All @@ -23,7 +24,7 @@
<Disc3 class="h-7 animate-spin text-muted-foreground" />
{/if}
</Card.Header>
<Card.Content class="space-y-4">
<Card.Content class="flex-1 flex-col items-center space-y-4 text-center">
<div class="flex items-center space-x-6">
{#if $volume[0] < 25}
<Volume class="w-10" />
Expand All @@ -43,6 +44,7 @@
/>
<p class="text-bold text-xl">{$volume}</p>
</div>
<AudioWaveform />
<DataTable data={$availableTracks} {columns} />
</Card.Content>
</Card.Root>
45 changes: 45 additions & 0 deletions webapp/src/lib/components/audio/AudioWaveform.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts">
import Peaks, { type PeaksOptions } from 'peaks.js'
import { loadedAudio } from '$lib/data/audio'
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
import { player } from '@/audio/waveform'
let zoomview: HTMLElement | null = null
let overview: HTMLElement | null = null
const loadWaveData = async (): Promise<void> => {
const res = await fetch('http://localhost:7000/audio/samples')
if (res.status != 200) throw new Error('Request failed')
const data = await res.arrayBuffer()
const peaksOptions: PeaksOptions = {
zoomview: {
container: zoomview
},
overview: {
container: overview
},
player: player,
waveformData: {
arraybuffer: data
}
}
Peaks.init(peaksOptions, (e) => console.log('Peaks init failed:', e))
}
</script>

{#if $loadedAudio != ''}
<div class="space-y-2">
<div bind:this={zoomview} class="h-[100px] w-[1000px] border shadow-sm"></div>
<div bind:this={overview} class="h-[75px] w-[1000px] border shadow-sm"></div>
</div>
{#await loadWaveData()}
<div class="space-y-3">
<LoaderCircle class="h-10 w-10 animate-spin" />
<p>Loading...</p>
</div>
{:catch}
<p>Failed to load waveform render.</p>
{/await}
{/if}
25 changes: 25 additions & 0 deletions webapp/src/lib/components/audio/waveform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { EventEmitterForPlayerEvents, PlayerAdapter } from 'peaks.js'

let emitter: EventEmitterForPlayerEvents | null = null

export const player: PlayerAdapter = {
init: async (eventEmitter: EventEmitterForPlayerEvents) => {
emitter = eventEmitter
},
destroy: () => {},
play: async () => {},
pause: () => {},
seek: (time: number) => {},
isPlaying: () => {
return false
},
isSeeking: () => {
return false
},
getCurrentTime: () => {
return 0
},
getDuration: () => {
return 0
}
}

0 comments on commit 6cd27a3

Please sign in to comment.