refactor(split): rename DeepDrftWeb -> DeepDrftPublic and DeepDrftWeb.Client -> DeepDrftPublic.Client (Phase 4)

This commit is contained in:
Daniel Harvey
2026-05-19 23:06:16 -04:00
parent a981a99978
commit e5b4a79727
83 changed files with 116 additions and 116 deletions
+118
View File
@@ -0,0 +1,118 @@
# CLAUDE.md - DeepDrftPublic
Guidance for working in the DeepDrftPublic project (the Blazor Web App host).
See the root `CLAUDE.md` for full architecture overview. This file covers what is specific to this project.
## One-line purpose
The Blazor Web App host. Owns HTTP surface (one controller + render-mode wiring), MudBlazor theme prerender, TypeScript→JS audio interop, and the SQL-side `api/track/page` endpoint. **Domain logic lives in `DeepDrftData`.**
## What lives here now (only)
- `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, port binding.
- `Controllers/TrackController.cs`: Single controller. `GET api/track/page?pageNumber&pageSize&sortColumn&sortDescending` → service call → `ApiResultDto<PagedResult<TrackEntity>>`.
- `Services/DarkModeService.cs`: Server-side dark-mode prerender (reads `darkMode` cookie, seeds `DarkModeSettings.IsDarkMode` via `IHttpContextAccessor`, carries to WASM via `PersistentComponentState`).
- `Components/App.razor`: Root component with `@rendermode="InteractiveAuto"`. Calls `DarkModeService.InitializeAsync()` in `OnInitialized`.
- `Components/Pages/Error.razor`: Error fallback.
- `Interop/audio/`: TypeScript sources (one module per responsibility: `AudioContextManager.ts`, `StreamDecoder.ts`, `PlaybackScheduler.ts`, `SpectrumAnalyzer.ts`, `AudioPlayer.ts`, `index.ts`). Compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. `tsconfig.json` **must not** be copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging.
- `wwwroot/`: Static assets (compiled JS, CSS, fonts, images, favicons).
## What does NOT live here anymore
- `DeepDrftContext`, `TrackRepository`, `TrackService`, `Configurations/`, `Migrations/` — all moved to `DeepDrftData`. Do not add new repositories or EF code to this project.
- Any FileDatabase code — that lives in `DeepDrftContent.Services`.
## Blazor Web App render modes
Hybrid Blazor with `AddInteractiveServerComponents()` + `AddInteractiveWebAssemblyComponents()`.
- Root component is `<Routes @rendermode="InteractiveAuto" />` from `Components/App.razor`.
- WASM render-mode loads `DeepDrftPublic.Client._Imports` as an additional assembly.
- **New routable pages go in `DeepDrftPublic.Client/Pages`, not here** — the client project owns the interactive UI.
Server-side prerender happens before WASM kicks in. Dark mode, CORS, forwarded headers, and MudBlazor setup must all tolerate this split.
## Dark-mode prerender bridge
`DarkModeService` in this project reads the `darkMode` cookie via `IHttpContextAccessor` in `App.razor`'s `OnInitialized` and seeds `DarkModeSettings.IsDarkMode`. This setting is registered in `DeepDrftPublic.Client.Startup.ConfigureDomainServices`. The setting carries over to WASM via `PersistentComponentState` in `MainLayout.razor`.
The flow ensures the first paint uses the correct theme (no flash).
## TypeScript interop pipeline
Audio interop is TypeScript, not raw JS:
- Sources live in `Interop/audio/` with one module per responsibility.
- Compiled to `wwwroot/js/` via `Microsoft.TypeScript.MSBuild`.
- `index.ts` exposes all modules onto `window.DeepDrftAudio` for Blazor to invoke.
- `tsconfig.json` configured for ES module interop and must **not** be copied to output.
- In development, raw `.ts` is served from `/Interop/` for source-map debugging.
Blazor calls TypeScript via `AudioInteropService.ts` (a JS interop wrapper in `DeepDrftPublic.Client`), which manages `DotNetObjectReference` lifetimes for progress, end-of-playback, and spectrum callbacks.
## HTTP client wiring
Mostly in `DeepDrftPublic.Client.Startup`:
- Named clients `"DeepDrft.API"` (SQL metadata) and `"DeepDrft.Content"` (binary audio).
- Base addresses passed in from `appsettings.json` (`ApiUrls:ContentApi`, `ApiUrls:SqlApi`).
- `Startup.ConfigureApiHttpClient` and `Startup.ConfigureContentServices` are static methods called from **both** the server `Program.cs` and the WASM `Program.cs` so prerender and runtime see the same DI.
Server-side `Program.cs` adds:
- MudBlazor (`AddMudServices`)
- Controllers
- Render-mode components
- SignalR tuning (if needed)
- Forwarded headers
- Calls to `Startup.ConfigureApiHttpClient` / `ConfigureContentServices` / `ConfigureDomainServices`
## Reverse-proxy support
`UseForwardedHeaders()` runs first in the pipeline. HTTPS redirect is conditionally disabled via `ForwardedHeaders:DisableHttpsRedirection` so the app can sit behind nginx without forcing HTTPS at the host level.
## The one controller
`TrackController` is thin — it just deserializes query parameters, calls `DeepDrftData.TrackService.GetPaged`, and wraps the result:
```csharp
[HttpGet("api/track/page")]
public async Task<ActionResult<ApiResultDto<PagedResult<TrackEntity>>>> GetPage(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false)
```
If you're adding new SQL endpoints, this is the file. If you're adding new logic, that goes in `DeepDrftData/TrackService.cs`.
## Development commands
```bash
# Run the web host (includes WASM from DeepDrftPublic.Client)
dotnet run --project DeepDrftPublic
# Watch during development
dotnet watch run --project DeepDrftPublic
# Build
dotnet build DeepDrftPublic
# Add migration (run from solution root; creates in DeepDrftData)
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
```
## Configuration
- `appsettings.json`: `ApiUrls:*` (backend base addresses), `Logging:*`, `AllowedHosts`, `ForwardedHeaders`. Port binding via `Kestrel:Endpoints` or `ASPNETCORE_URLS`.
- `environment/apikey.json`: DeepDrftContent API key. Loaded via CredentialTools (not in repo).
- `environment/connections.json`: SQL `DefaultConnection` and Auth connection strings. Loaded via CredentialTools (not in repo).
- `environment/authblocks.json`: AuthBlocks settings. Loaded via CredentialTools (not in repo).
- MudBlazor theme (`MainLayout.razor` in client): bespoke light ("Charleston in the Day") and dark ("Lowcountry Summer Nights") palettes.
- No `wwwroot/` changes during normal development — TS → JS compilation is automatic.
## Important patterns
All service calls in the controller return `ResultContainer<T>` or `Result`. The controller doesn't catch — it checks `Success` and returns 200/4xx/5xx accordingly. See `DeepDrftData` for the contract.
When working with this project, focus on the host surface (controllers, middleware, config) and prerender coordination. New domain logic goes in `DeepDrftData`.
+41
View File
@@ -0,0 +1,41 @@
@using DeepDrftPublic.Services
@using DeepDrftShared.Client.Components
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<DeepDrftFontLinks />
<link href="_@Assets["content/MudBlazor.ThemeManager/MudBlazorThemeManager.css"]" rel="stylesheet" />
<link href=@Assets["_content/MudBlazor/MudBlazor.min.css"] rel="stylesheet" />
<link rel="stylesheet" href="@Assets["DeepDrftPublic.styles.css"]"/>
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/styles/deepdrft-tokens.css"]" />
<link rel="stylesheet" href="styles/deepdrft-styles.css" />
<ImportMap />
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
<script type="module">
import('./js/audio/index.js');
</script>
</body>
</html>
@code {
[Inject] public required DarkModeService DarkModeService { get; set; }
protected override void OnInitialized()
{
base.OnInitialized();
DarkModeService.CheckDarkMode();
}
}
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
+11
View File
@@ -0,0 +1,11 @@
<Router AppAssembly="typeof(App).Assembly"
AdditionalAssemblies="new[] { typeof(DeepDrftPublic.Client._Imports).Assembly }">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(DeepDrftPublic.Client.Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<p role="alert">Sorry, there's nothing at this address.</p>
</NotFound>
</Router>
+13
View File
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MudBlazor
@using MudBlazor.Services
@using DeepDrftPublic
@using DeepDrftPublic.Client
@using DeepDrftPublic.Components
@@ -0,0 +1,33 @@
using DeepDrftData;
using DeepDrftModels.Entities;
using Microsoft.AspNetCore.Mvc;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftPublic.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TrackController : ControllerBase
{
private readonly ITrackService _trackService;
public TrackController(ITrackService trackService)
{
_trackService = trackService;
}
[HttpGet("page")]
public async Task<ActionResult<ApiResultDto<PagedResult<TrackEntity>>>> GetPage(
[FromQuery] int pageNumber,
[FromQuery] int pageSize,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false)
{
var result = await _trackService.GetPaged(pageNumber, pageSize, sortColumn, sortDescending);
var apiResult = ApiResult<PagedResult<TrackEntity>>.From(result);
var dto = new ApiResultDto<PagedResult<TrackEntity>>(apiResult);
return result.Success ? Ok(dto) : StatusCode(500, dto);
}
}
+53
View File
@@ -0,0 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
<ProjectReference Include="..\DeepDrftPublic.Client\DeepDrftPublic.Client.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.9.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<Folder Include="wwwroot\js\" />
</ItemGroup>
<!-- TypeScript configuration following Microsoft recommendations -->
<PropertyGroup>
<TypeScriptCompileOnSaveEnabled>false</TypeScriptCompileOnSaveEnabled>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<TypeScriptESModuleInterop>true</TypeScriptESModuleInterop>
<TypeScriptAllowSyntheticDefaultImports>true</TypeScriptAllowSyntheticDefaultImports>
</PropertyGroup>
<!-- Prevent tsconfig.json from being copied to output directories -->
<ItemGroup>
<None Update="tsconfig.json">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,118 @@
/**
* AudioContextManager - Manages the Web Audio API AudioContext and GainNode.
*
* Single Responsibility: AudioContext lifecycle and audio routing.
*
* Audio chain: Source → GainNode → AnalyserNode → destination
*/
import { SpectrumAnalyzer } from './SpectrumAnalyzer.js';
export class AudioContextManager {
private audioContext: AudioContext | null = null;
private gainNode: GainNode | null = null;
private spectrumAnalyzer: SpectrumAnalyzer;
constructor() {
this.spectrumAnalyzer = new SpectrumAnalyzer();
}
async initialize(sampleRate: number = 44100): Promise<void> {
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContextClass) {
throw new Error('Web Audio API not supported');
}
this.audioContext = new AudioContextClass({ sampleRate });
this.gainNode = this.audioContext.createGain();
// Initialize spectrum analyzer and insert into chain
// Chain: Source → GainNode → AnalyserNode → destination
const analyserNode = this.spectrumAnalyzer.initialize(this.audioContext);
this.gainNode.connect(analyserNode);
analyserNode.connect(this.audioContext.destination);
console.log(`AudioContext initialized: sampleRate=${this.audioContext.sampleRate}Hz, state=${this.audioContext.state}`);
}
async ensureReady(): Promise<void> {
if (!this.audioContext) {
throw new Error('AudioContext not initialized');
}
if (this.audioContext.state === 'suspended') {
console.log('🔊 Resuming AudioContext');
await this.audioContext.resume();
console.log(`✅ AudioContext resumed: state=${this.audioContext.state}`);
}
}
async recreateWithSampleRate(sampleRate: number): Promise<void> {
if (!this.audioContext) {
throw new Error('AudioContext not initialized');
}
if (this.audioContext.sampleRate === sampleRate) {
return; // Already correct sample rate
}
console.log(`🔄 Recreating AudioContext: ${this.audioContext.sampleRate}Hz -> ${sampleRate}Hz`);
await this.audioContext.close();
await this.initialize(sampleRate);
}
getContext(): AudioContext {
if (!this.audioContext) {
throw new Error('AudioContext not initialized');
}
return this.audioContext;
}
getGainNode(): GainNode {
if (!this.gainNode) {
throw new Error('GainNode not initialized');
}
return this.gainNode;
}
get currentTime(): number {
return this.audioContext?.currentTime ?? 0;
}
get sampleRate(): number {
return this.audioContext?.sampleRate ?? 0;
}
get state(): AudioContextState | 'uninitialized' {
return this.audioContext?.state ?? 'uninitialized';
}
setVolume(volume: number): void {
if (!this.gainNode || !this.audioContext) return;
const clampedVolume = Math.max(0, Math.min(1, volume));
this.gainNode.gain.setValueAtTime(clampedVolume, this.audioContext.currentTime);
}
getVolume(): number {
return this.gainNode?.gain.value ?? 0;
}
async decodeAudioData(buffer: ArrayBuffer): Promise<AudioBuffer> {
if (!this.audioContext) {
throw new Error('AudioContext not initialized');
}
return this.audioContext.decodeAudioData(buffer);
}
getSpectrumAnalyzer(): SpectrumAnalyzer {
return this.spectrumAnalyzer;
}
dispose(): void {
this.spectrumAnalyzer.dispose();
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close();
}
this.audioContext = null;
this.gainNode = null;
}
}
+518
View File
@@ -0,0 +1,518 @@
/**
* AudioPlayer - Main orchestrator for audio playback.
*
* Composes specialized managers following Single Responsibility Principle:
* - AudioContextManager: Web Audio API context and routing
* - StreamDecoder: WAV parsing and decoding
* - PlaybackScheduler: Buffer storage and playback scheduling
*/
import { AudioContextManager } from './AudioContextManager.js';
import { StreamDecoder } from './StreamDecoder.js';
import { PlaybackScheduler } from './PlaybackScheduler.js';
export interface AudioResult {
success: boolean;
error?: string;
seekBeyondBuffer?: boolean;
byteOffset?: number;
}
export interface StreamingResult extends AudioResult {
canStartStreaming?: boolean;
headerParsed?: boolean;
bufferCount?: number;
duration?: number;
}
export interface AudioState {
isPlaying: boolean;
isPaused: boolean;
currentTime: number;
duration: number;
volume: number;
}
type ProgressCallback = (currentTime: number) => void;
type EndCallback = () => void;
export class AudioPlayer {
private contextManager: AudioContextManager;
private streamDecoder: StreamDecoder;
private scheduler: PlaybackScheduler;
// Playback state
private isPlaying: boolean = false;
private isPaused: boolean = false;
private pausePosition: number = 0;
private duration: number = 0;
// Streaming state
private isStreamingMode: boolean = false;
private streamingStarted: boolean = false;
private streamingCompleted: boolean = false;
private minBuffersForPlayback: number = 6;
// Callbacks
private onProgressCallback: ProgressCallback | null = null;
private onEndCallback: EndCallback | null = null;
private progressInterval: number | null = null;
constructor() {
this.contextManager = new AudioContextManager();
this.streamDecoder = new StreamDecoder(this.contextManager);
this.scheduler = new PlaybackScheduler(this.contextManager);
// Wire up scheduler callbacks
this.scheduler.onPlaybackEnded = () => this.handlePlaybackEnded();
}
// ==================== Initialization ====================
async initialize(): Promise<AudioResult> {
try {
await this.contextManager.initialize();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async ensureAudioContextReady(): Promise<AudioResult> {
try {
await this.contextManager.ensureReady();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Streaming ====================
initializeStreaming(totalStreamLength: number): AudioResult {
try {
// Full cleanup before starting new stream
this.stopProgressTracking();
this.scheduler.clear();
this.streamDecoder.reset();
this.resetState();
// Initialize new stream
this.isStreamingMode = true;
this.streamDecoder.initialize(totalStreamLength);
console.log(`Streaming initialized: ${totalStreamLength} bytes expected`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Signal to the decoder that the C# streaming loop has finished sending bytes.
* This sets streamComplete=true and flushes any remaining decoded tail segments.
* Must be called after the ReadAsync loop exits, regardless of whether
* Content-Length was known — without it the tail-decode path is dead when
* Content-Length is absent.
*/
async markStreamComplete(): Promise<StreamingResult> {
try {
const results = await this.streamDecoder.markStreamComplete();
if (results.length > 0) {
for (const result of results) {
this.scheduler.addBuffer(result.buffer);
}
if (this.streamingStarted && this.isPlaying) {
this.scheduler.scheduleNewBuffers();
}
}
this.streamingCompleted = true;
console.log('Stream marked complete by C# signal');
return { success: true, bufferCount: this.scheduler.getBufferCount() };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async processStreamingChunk(chunk: Uint8Array): Promise<StreamingResult> {
try {
const results = await this.streamDecoder.processChunk(chunk);
if (results.length > 0) {
for (const result of results) {
this.scheduler.addBuffer(result.buffer);
}
// Update duration estimate
const estimatedDuration = this.streamDecoder.getEstimatedDuration();
if (estimatedDuration) {
this.duration = estimatedDuration;
}
// Schedule new buffers if already playing
if (this.streamingStarted && this.isPlaying) {
this.scheduler.scheduleNewBuffers();
}
}
// Check if streaming is complete
if (this.streamDecoder.isComplete) {
this.streamingCompleted = true;
console.log('Stream complete');
}
const canStart = this.streamDecoder.headerParsed &&
this.scheduler.hasMinimumBuffers(this.minBuffersForPlayback);
return {
success: true,
canStartStreaming: canStart,
headerParsed: this.streamDecoder.headerParsed,
bufferCount: this.scheduler.getBufferCount(),
duration: this.duration
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async startStreamingPlayback(): Promise<AudioResult> {
if (!this.scheduler.hasBuffers()) {
return { success: false, error: 'No buffers available' };
}
try {
console.log('\n=== Starting streaming playback ===');
// A backgrounded tab leaves AudioContext suspended. createBufferSource/start
// against a suspended context produces no audio without throwing — the same
// failure mode that was fixed for play() (resume path). Awaiting ensureReady()
// here guarantees the context is running before playFromPosition schedules
// any AudioBufferSourceNodes.
await this.contextManager.ensureReady();
this.streamingStarted = true;
this.isPlaying = true;
this.isPaused = false;
this.pausePosition = 0;
this.scheduler.playFromPosition(0);
this.startProgressTracking();
console.log('✅ Streaming playback started');
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Playback Control ====================
async play(): Promise<AudioResult> {
if (!this.isStreamingMode) {
return { success: false, error: 'Not in streaming mode' };
}
if (!this.streamingStarted || !this.scheduler.hasBuffers()) {
return { success: false, error: 'Streaming not ready' };
}
// Don't restart if already playing
if (this.isPlaying) {
console.log('Already playing, ignoring play()');
return { success: true };
}
try {
// Must await: a backgrounded tab leaves AudioContext suspended, and
// createBufferSource/source.start against a suspended context produces
// no audio without throwing. Firing ensureReady() without await meant
// play() returned success but the user heard nothing.
await this.contextManager.ensureReady();
this.isPlaying = true;
this.isPaused = false;
// Resume from pause position
this.scheduler.playFromPosition(this.pausePosition);
this.startProgressTracking();
console.log(`▶️ Resumed from ${this.pausePosition.toFixed(3)}s`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
pause(): AudioResult {
if (!this.isPlaying) {
return { success: false, error: 'Not playing' };
}
try {
this.pausePosition = this.scheduler.pause();
this.isPlaying = false;
this.isPaused = true;
this.stopProgressTracking();
console.log(`⏸️ Paused at ${this.pausePosition.toFixed(3)}s`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
stop(): AudioResult {
try {
this.scheduler.clear();
this.streamDecoder.reset();
this.resetState();
this.stopProgressTracking();
console.log('⏹️ Stopped');
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
unload(): AudioResult {
return this.stop();
}
seek(position: number): AudioResult {
if (!this.isStreamingMode || position < 0 || position > this.duration) {
return { success: false, error: 'Invalid seek position' };
}
// Get buffered duration (accounting for playback offset)
const bufferedDuration = this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset();
// Check if seeking within buffered content
if (position <= bufferedDuration) {
return this.seekWithinBuffer(position);
} else {
// Seeking beyond buffer - signal C# to fetch new stream
return this.seekBeyondBuffer(position);
}
}
/**
* Seek within currently buffered content
*/
private seekWithinBuffer(position: number): AudioResult {
try {
const wasPlaying = this.isPlaying;
this.scheduler.stopAllSources();
// Adjust position relative to buffer start (subtract playback offset)
const bufferRelativePosition = position - this.scheduler.getPlaybackOffset();
this.pausePosition = position;
if (wasPlaying) {
this.scheduler.playFromPosition(Math.max(0, bufferRelativePosition));
}
console.log(`🔍 Seeked within buffer to ${position.toFixed(3)}s (buffer-relative: ${bufferRelativePosition.toFixed(3)}s)`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Seek beyond buffered content - calculate byte offset for server request
*/
private seekBeyondBuffer(position: number): AudioResult {
try {
const byteOffset = this.streamDecoder.calculateByteOffset(position);
// 0 is a valid offset (seek to start of audio data). Only a negative result
// indicates calculation failure — typically a missing/unparsed WAV header.
if (byteOffset < 0) {
return { success: false, error: 'Cannot calculate byte offset' };
}
console.log(`🔍 Seek beyond buffer to ${position.toFixed(3)}s requires byte offset ${byteOffset}`);
// Signal that C# needs to request new stream from offset
return {
success: true,
seekBeyondBuffer: true,
byteOffset: byteOffset
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Get the total buffered duration (for C# to check if seek is within buffer)
*/
getBufferedDuration(): number {
return this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset();
}
/**
* Calculate byte offset for a time position (for C# layer)
*/
calculateByteOffset(positionSeconds: number): number {
return this.streamDecoder.calculateByteOffset(positionSeconds);
}
/**
* Reinitialize for offset streaming after seek-beyond-buffer
* Called by C# after receiving new stream from server
*/
reinitializeFromOffset(totalStreamLength: number, seekPosition: number): AudioResult {
try {
console.log(`\n=== Reinitializing for offset stream ===`);
console.log(`Seek position: ${seekPosition.toFixed(3)}s, Stream length: ${totalStreamLength}`);
// Stop current playback
this.stopProgressTracking();
const wasPlaying = this.isPlaying;
this.isPlaying = false;
// Clear buffers and set new offset
this.scheduler.clearForSeek();
this.scheduler.setPlaybackOffset(seekPosition);
// Reinitialize decoder for new stream
this.streamDecoder.reinitializeForOffset(totalStreamLength);
// Update state
this.pausePosition = seekPosition;
this.streamingStarted = false; // Will restart when new buffers arrive
this.streamingCompleted = false;
console.log(`✅ Reinitialized for offset, was playing: ${wasPlaying}`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Volume ====================
setVolume(volume: number): AudioResult {
try {
this.contextManager.setVolume(volume);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== State ====================
getCurrentTime(): number {
if (this.isPlaying) {
return this.scheduler.getCurrentPosition();
}
return this.pausePosition;
}
getState(): AudioState {
return {
isPlaying: this.isPlaying,
isPaused: this.isPaused,
currentTime: this.getCurrentTime(),
duration: this.duration,
volume: this.contextManager.getVolume()
};
}
// ==================== Callbacks ====================
setOnProgressCallback(callback: ProgressCallback): void {
this.onProgressCallback = callback;
}
setOnEndCallback(callback: EndCallback): void {
this.onEndCallback = callback;
}
// ==================== Spectrum Analysis ====================
getSpectrumData(): number[] {
return this.contextManager.getSpectrumAnalyzer().getFrequencyData();
}
setSpectrumHighPass(freq: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setHighPass(freq);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
setSpectrumLowPass(freq: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setLowPass(freq);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
setSpectrumSlope(dbPerDecade: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setSlopeCorrection(dbPerDecade);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
startSpectrumAnimation(callbackId: string, callback: (data: number[]) => void): void {
this.contextManager.getSpectrumAnalyzer().addCallback(callbackId, callback);
}
stopSpectrumAnimation(callbackId: string): void {
this.contextManager.getSpectrumAnalyzer().removeCallback(callbackId);
}
// ==================== Private Methods ====================
private resetState(): void {
this.isPlaying = false;
this.isPaused = false;
this.pausePosition = 0;
this.duration = 0;
this.isStreamingMode = false;
this.streamingStarted = false;
this.streamingCompleted = false;
}
private handlePlaybackEnded(): void {
this.isPlaying = false;
this.isPaused = false;
this.pausePosition = 0;
this.stopProgressTracking();
this.onEndCallback?.();
}
private startProgressTracking(): void {
this.stopProgressTracking();
this.progressInterval = window.setInterval(() => {
if (this.onProgressCallback && this.isPlaying) {
this.onProgressCallback(this.getCurrentTime());
}
}, 100);
}
private stopProgressTracking(): void {
if (this.progressInterval) {
clearInterval(this.progressInterval);
this.progressInterval = null;
}
}
// ==================== Cleanup ====================
dispose(): void {
this.stop();
this.stopProgressTracking();
this.contextManager.dispose();
}
}
@@ -0,0 +1,331 @@
/**
* PlaybackScheduler - Manages AudioBuffer storage and playback scheduling.
*
* Single Responsibility: Store decoded buffers and schedule them for playback.
* Supports pause/resume/seek by retaining all buffers.
*/
import { AudioContextManager } from './AudioContextManager.js';
interface ScheduledSource {
source: AudioBufferSourceNode;
bufferIndex: number;
startTime: number;
endTime: number;
}
export class PlaybackScheduler {
private contextManager: AudioContextManager;
private buffers: AudioBuffer[] = [];
private scheduledSources: ScheduledSource[] = [];
// Playback timing
private playbackAnchorTime: number = 0; // AudioContext time when playback started/resumed
private playbackAnchorPosition: number = 0; // Position in audio when playback started/resumed
private nextBufferIndex: number = 0; // Next buffer to schedule during live streaming
private nextScheduleTime: number = 0; // AudioContext time for next buffer
private isActive_: boolean = false; // Prevents scheduling during pause/stop
// Offset for seek-beyond-buffer scenarios
// When seeking to position T beyond buffers, we clear buffers and set playbackOffset = T
// The new stream starts at T, so buffer positions are relative to T
private playbackOffset: number = 0;
// Callbacks
public onPlaybackEnded: (() => void) | null = null;
constructor(contextManager: AudioContextManager) {
this.contextManager = contextManager;
}
/**
* Add a decoded buffer to storage
*/
addBuffer(buffer: AudioBuffer): void {
this.buffers.push(buffer);
console.log(`📦 Buffer[${this.buffers.length - 1}] added: ${buffer.duration.toFixed(3)}s (total: ${this.getTotalDuration().toFixed(3)}s)`);
}
/**
* Get total duration of all stored buffers
*/
getTotalDuration(): number {
return this.buffers.reduce((sum, b) => sum + b.duration, 0);
}
/**
* Get number of stored buffers
*/
getBufferCount(): number {
return this.buffers.length;
}
/**
* Get current playback position in seconds (includes playbackOffset for seek-beyond-buffer)
*/
getCurrentPosition(): number {
// Use isActive_ as the sentinel for "playback is running", not playbackAnchorTime == 0.
// AudioContext.currentTime can legitimately be 0 at context creation, so comparing
// against 0 would incorrectly treat an active stream started at t=0 as paused.
if (!this.isActive_) {
return this.playbackAnchorPosition + this.playbackOffset;
}
const elapsed = this.contextManager.currentTime - this.playbackAnchorTime;
return Math.min(this.playbackAnchorPosition + this.playbackOffset + elapsed, this.getTotalDuration() + this.playbackOffset);
}
/**
* Set the playback offset for seek-beyond-buffer scenarios
* This represents the absolute time position where the current buffers start
*/
setPlaybackOffset(offset: number): void {
this.playbackOffset = offset;
console.log(`📍 Playback offset set to ${offset.toFixed(3)}s`);
}
/**
* Get the current playback offset
*/
getPlaybackOffset(): number {
return this.playbackOffset;
}
/**
* Start or resume playback from a specific position
*/
playFromPosition(position: number): void {
this.stopAllSources();
// Find which buffer contains this position
let accumulatedTime = 0;
let startBufferIndex = 0;
let offsetInBuffer = 0;
for (let i = 0; i < this.buffers.length; i++) {
const bufferDuration = this.buffers[i].duration;
if (accumulatedTime + bufferDuration > position) {
startBufferIndex = i;
offsetInBuffer = position - accumulatedTime;
break;
}
accumulatedTime += bufferDuration;
startBufferIndex = i + 1;
}
if (startBufferIndex >= this.buffers.length) {
// Position landed at or past the end of all buffers. Previously this
// returned silently, leaving the player stuck "playing" with no source
// scheduled — a pause near the end followed by play never recovered.
// Treat this as end-of-track so listeners (UI / end callback) fire.
console.log('Position at/beyond available buffers — ending playback');
this.isActive_ = false;
this.playbackAnchorTime = 0;
this.playbackAnchorPosition = 0;
this.onPlaybackEnded?.();
return;
}
console.log(`▶️ Playing from ${position.toFixed(3)}s: buffer[${startBufferIndex}] offset=${offsetInBuffer.toFixed(3)}s`);
// Set timing anchors
this.playbackAnchorPosition = position;
this.playbackAnchorTime = this.contextManager.currentTime;
this.nextScheduleTime = this.contextManager.currentTime + 0.01; // Small lookahead
this.nextBufferIndex = startBufferIndex;
this.isActive_ = true; // Enable scheduling
// Schedule buffers
this.scheduleBuffersFrom(startBufferIndex, offsetInBuffer);
}
/**
* Schedule newly decoded buffers during live streaming
*/
scheduleNewBuffers(): void {
if (this.nextBufferIndex >= this.buffers.length) {
return; // No new buffers
}
// Use isActive_ as the sentinel for "playback is running", not nextScheduleTime === 0.
// AudioContext.currentTime can legitimately be 0 at context creation, which would cause
// nextScheduleTime === 0 to incorrectly reset a value already set by playFromPosition.
if (!this.isActive_) {
return;
}
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
}
/**
* Internal: Schedule buffers starting from a specific index
*/
private scheduleBuffersFrom(startIndex: number, offsetInFirstBuffer: number): void {
const lookaheadTarget = 0.5; // Schedule up to 500ms ahead
const gainNode = this.contextManager.getGainNode();
for (let i = startIndex; i < this.buffers.length; i++) {
const buffer = this.buffers[i];
const isFirstBuffer = (i === startIndex && offsetInFirstBuffer > 0);
const offset = isFirstBuffer ? offsetInFirstBuffer : 0;
const duration = buffer.duration - offset;
// Create and configure source
const source = this.contextManager.getContext().createBufferSource();
source.buffer = buffer;
source.connect(gainNode);
const scheduleTime = this.nextScheduleTime;
const endTime = scheduleTime + duration;
// Track scheduled source
const scheduled: ScheduledSource = {
source,
bufferIndex: i,
startTime: scheduleTime,
endTime
};
this.scheduledSources.push(scheduled);
// Set up ended callback
source.onended = () => this.handleSourceEnded(scheduled);
// Schedule the source
source.start(scheduleTime, offset);
console.log(`🎵 Scheduled buffer[${i}]: ${scheduleTime.toFixed(3)}s -> ${endTime.toFixed(3)}s`);
// Update for next buffer
this.nextScheduleTime = endTime;
this.nextBufferIndex = i + 1;
// Check if we have enough lookahead
const lookahead = this.nextScheduleTime - this.contextManager.currentTime;
if (lookahead > lookaheadTarget) {
console.log(`📋 Lookahead: ${(lookahead * 1000).toFixed(0)}ms buffered`);
break;
}
}
}
/**
* Handle a source finishing playback
*/
private handleSourceEnded(scheduled: ScheduledSource): void {
// Ignore if we're paused/stopped (sources fire onended when stopped)
if (!this.isActive_) {
return;
}
// Remove from scheduled list
const index = this.scheduledSources.indexOf(scheduled);
if (index > -1) {
this.scheduledSources.splice(index, 1);
}
// Schedule more buffers if available
if (this.nextBufferIndex < this.buffers.length) {
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
}
// Check if all playback has finished
if (this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) {
console.log('✓ Playback complete');
this.isActive_ = false;
this.playbackAnchorTime = 0;
this.playbackAnchorPosition = 0;
this.onPlaybackEnded?.();
}
}
/**
* Pause playback - saves position and stops sources
*/
pause(): number {
const position = this.getCurrentPosition();
this.isActive_ = false; // Prevent handleSourceEnded from scheduling more
this.stopAllSources();
this.playbackAnchorPosition = position;
this.playbackAnchorTime = 0;
this.nextScheduleTime = 0;
console.log(`⏸️ Paused at ${position.toFixed(3)}s`);
return position;
}
/**
* Stop all scheduled sources
*/
stopAllSources(): void {
for (const scheduled of this.scheduledSources) {
try {
scheduled.source.stop();
} catch {
// Source may already be stopped
}
}
this.scheduledSources = [];
}
/**
* Reset to beginning (for stop)
*/
resetToStart(): void {
this.isActive_ = false;
this.stopAllSources();
this.playbackAnchorPosition = 0;
this.playbackAnchorTime = 0;
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
console.log('⏮️ Reset to start');
}
/**
* Full reset - clears all buffers and resets offset
*/
clear(): void {
this.isActive_ = false;
this.stopAllSources();
this.buffers = [];
this.playbackAnchorPosition = 0;
this.playbackAnchorTime = 0;
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
this.playbackOffset = 0;
console.log('🗑️ Scheduler cleared');
}
/**
* Clear buffers but keep offset - for seek-beyond-buffer scenarios
*/
clearForSeek(): void {
this.isActive_ = false;
this.stopAllSources();
this.buffers = [];
this.playbackAnchorPosition = 0;
this.playbackAnchorTime = 0;
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
// Note: playbackOffset is NOT reset - it will be set by the caller
console.log('🗑️ Scheduler cleared for seek (offset preserved)');
}
/**
* Check if we have buffers
*/
hasBuffers(): boolean {
return this.buffers.length > 0;
}
/**
* Check if we have minimum buffers for playback
*/
hasMinimumBuffers(minCount: number): boolean {
return this.buffers.length >= minCount;
}
/**
* Check if playback is active
*/
isActive(): boolean {
return this.isActive_;
}
}
@@ -0,0 +1,213 @@
/**
* SpectrumAnalyzer - Manages FFT analysis with filtering and slope correction.
*
* Single Responsibility: FFT analysis, frequency bucketing, and visual processing filters.
*/
export interface SpectrumConfig {
bucketCount: number;
highPassFreq: number; // Hz, 0 = disabled
lowPassFreq: number; // Hz
slopeDb: number; // dB/decade correction
}
export class SpectrumAnalyzer {
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
private fftSize: number = 2048;
private dataArray: Float32Array<ArrayBuffer> | null = null;
// Configuration
private bucketCount: number = 32;
private highPassFreq: number = 0;
private lowPassFreq: number = 20000;
private slopeDb: number = 0;
// Animation state - supports multiple callbacks per player
private animationId: number | null = null;
private callbacks = new Map<string, (data: number[]) => void>();
private lastFrameTime: number = 0;
private targetFrameInterval: number = 1000 / 30; // ~30fps for smooth visuals without excessive interop
initialize(context: AudioContext): AnalyserNode {
this.audioContext = context;
this.analyser = context.createAnalyser();
this.analyser.fftSize = this.fftSize;
this.analyser.smoothingTimeConstant = 0.8;
this.dataArray = new Float32Array(this.analyser.frequencyBinCount);
console.log(`SpectrumAnalyzer initialized: fftSize=${this.fftSize}, bins=${this.analyser.frequencyBinCount}`);
return this.analyser;
}
getAnalyserNode(): AnalyserNode | null {
return this.analyser;
}
setConfig(config: Partial<SpectrumConfig>): void {
if (config.bucketCount !== undefined) this.bucketCount = config.bucketCount;
if (config.highPassFreq !== undefined) this.highPassFreq = config.highPassFreq;
if (config.lowPassFreq !== undefined) this.lowPassFreq = config.lowPassFreq;
if (config.slopeDb !== undefined) this.slopeDb = config.slopeDb;
}
setHighPass(freq: number): void {
this.highPassFreq = Math.max(0, freq);
}
setLowPass(freq: number): void {
this.lowPassFreq = Math.max(20, freq);
}
setSlopeCorrection(dbPerDecade: number): void {
this.slopeDb = dbPerDecade;
}
/**
* Get frequency data as normalized values (0-1) for each bucket
*/
getFrequencyData(): number[] {
if (!this.analyser || !this.dataArray || !this.audioContext) {
return new Array(this.bucketCount).fill(0);
}
// Get raw FFT data (in dB, typically -100 to 0)
this.analyser.getFloatFrequencyData(this.dataArray);
const nyquist = this.audioContext.sampleRate / 2;
const binCount = this.dataArray.length;
const buckets: number[] = new Array(this.bucketCount).fill(0);
// Logarithmic frequency mapping for perceptual balance
// Map 20Hz - 20kHz to buckets using log scale
const minFreq = 20;
const maxFreq = Math.min(20000, nyquist);
const logMin = Math.log10(minFreq);
const logMax = Math.log10(maxFreq);
const logRange = logMax - logMin;
for (let bucket = 0; bucket < this.bucketCount; bucket++) {
// Calculate frequency range for this bucket
const logFreqLow = logMin + (bucket / this.bucketCount) * logRange;
const logFreqHigh = logMin + ((bucket + 1) / this.bucketCount) * logRange;
const freqLow = Math.pow(10, logFreqLow);
const freqHigh = Math.pow(10, logFreqHigh);
// Map frequencies to FFT bins
const binLow = Math.floor((freqLow / nyquist) * binCount);
const binHigh = Math.ceil((freqHigh / nyquist) * binCount);
// Average the bins in this range
let sum = 0;
let count = 0;
for (let bin = binLow; bin < binHigh && bin < binCount; bin++) {
const freq = (bin / binCount) * nyquist;
let value = this.dataArray[bin];
// Apply filters
value = this.applyFilters(value, freq);
sum += value;
count++;
}
const avgDb = count > 0 ? sum / count : -100;
// Normalize from dB (-100 to 0) to 0-1 range
// Clamp to reasonable range and scale
const normalizedDb = Math.max(-80, Math.min(0, avgDb));
buckets[bucket] = (normalizedDb + 80) / 80;
}
return buckets;
}
/**
* Apply high-pass, low-pass, and slope correction filters
*/
private applyFilters(valueDb: number, freq: number): number {
// Convert dB to linear for filter math
let linear = Math.pow(10, valueDb / 20);
// High-pass filter (6dB/octave)
if (this.highPassFreq > 0 && freq < this.highPassFreq && freq > 0) {
const octaves = Math.log2(this.highPassFreq / freq);
const attenuation = Math.pow(10, (-6 * octaves) / 20);
linear *= attenuation;
}
// Low-pass filter (6dB/octave)
if (freq > this.lowPassFreq && this.lowPassFreq > 0) {
const octaves = Math.log2(freq / this.lowPassFreq);
const attenuation = Math.pow(10, (-6 * octaves) / 20);
linear *= attenuation;
}
// Slope correction (dB/decade, referenced to 1kHz)
if (this.slopeDb !== 0 && freq > 0) {
const decades = Math.log10(freq / 1000);
const correction = Math.pow(10, (this.slopeDb * decades) / 20);
linear *= correction;
}
// Convert back to dB
return linear > 0 ? 20 * Math.log10(linear) : -100;
}
/**
* Add a callback for spectrum data. Starts animation loop on first subscriber.
*/
addCallback(id: string, callback: (data: number[]) => void): void {
const wasEmpty = this.callbacks.size === 0;
this.callbacks.set(id, callback);
if (wasEmpty) {
this.lastFrameTime = 0;
this.animationId = requestAnimationFrame(this.animate);
}
}
/**
* Remove a callback by ID. Stops animation loop when no subscribers remain.
*/
removeCallback(id: string): void {
this.callbacks.delete(id);
if (this.callbacks.size === 0) {
this.stopAnimation();
}
}
/**
* Stop animation loop
*/
stopAnimation(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
private animate = (timestamp: number): void => {
if (this.callbacks.size === 0) return;
// Throttle to target frame rate
const elapsed = timestamp - this.lastFrameTime;
if (elapsed >= this.targetFrameInterval) {
this.lastFrameTime = timestamp - (elapsed % this.targetFrameInterval);
const data = this.getFrequencyData();
// Broadcast to all callbacks
for (const cb of this.callbacks.values()) {
cb(data);
}
}
this.animationId = requestAnimationFrame(this.animate);
};
dispose(): void {
this.stopAnimation();
this.callbacks.clear();
this.analyser = null;
this.audioContext = null;
this.dataArray = null;
}
}
@@ -0,0 +1,476 @@
/**
* StreamDecoder - Handles WAV stream parsing and AudioBuffer decoding.
*
* Single Responsibility: Convert raw WAV stream data into decoded AudioBuffers.
*/
import { WavHeader, WavUtils } from '../wavutils.js';
import { AudioContextManager } from './AudioContextManager.js';
export interface DecodedChunkResult {
buffer: AudioBuffer;
duration: number;
}
/**
* Thrown when decodeAudioData exceeds the per-segment deadline. Distinct from
* DecodeError so callers (and operators reading logs) can tell a slow/throttled
* decoder from corrupt audio data — the previous "Decode timeout" string error
* was indistinguishable from any other Error and was silently swallowed.
*/
export class DecodeTimeoutError extends Error {
constructor(public readonly segmentOffset: number, public readonly byteCount: number) {
super(`Decode timeout at offset ${segmentOffset} (${byteCount} bytes)`);
this.name = 'DecodeTimeoutError';
}
}
/**
* Thrown when decodeAudioData rejects for non-timeout reasons (corrupt header,
* unsupported format, etc.). Carries the segment offset so callers can log
* which part of the stream failed.
*/
export class DecodeError extends Error {
constructor(
message: string,
public readonly segmentOffset: number,
public readonly byteCount: number,
public readonly cause?: Error
) {
super(message);
this.name = 'DecodeError';
}
}
export class StreamDecoder {
// Upper bound on pre-header accumulation. 256 KB is far beyond any sane WAV
// header (including extended LIST/INFO/JUNK chunks). If we have accumulated
// this many bytes without finding a valid header the stream is corrupt.
private static readonly MAX_HEADER_SEARCH_BYTES = 256 * 1024;
private contextManager: AudioContextManager;
private wavHeader: WavHeader | null = null;
private rawChunks: Uint8Array[] = [];
// totalRawBytes and processedBytes are JS number (IEEE 754 double), which can
// represent integers exactly up to 2^53 bytes (~8 PB). WAV files are bounded
// at 4 GB by the 32-bit RIFF size field, so overflow is not a practical concern.
private totalRawBytes: number = 0;
private processedBytes: number = 0;
private totalStreamLength: number = 0;
private streamComplete: boolean = false;
private headerError: string | null = null;
// Pre-header accumulator. WAV headers can span multiple network chunks
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
// the whole header lives in the first chunk.
private headerBytesReceived: number = 0;
private headerSearchChunks: Uint8Array[] = [];
constructor(contextManager: AudioContextManager) {
this.contextManager = contextManager;
}
/**
* Initialize for a new stream
*/
initialize(totalStreamLength: number): void {
this.wavHeader = null;
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
console.log(`StreamDecoder initialized: expecting ${totalStreamLength} bytes`);
}
/**
* Process incoming chunk and return all decoded AudioBuffers ready so far.
*
* Returns an array (possibly empty) rather than a single result because the
* final chunk may unlock the residual tail in addition to a full segment,
* and a single chunk that completes header parsing may also carry enough
* audio data to decode immediately.
*/
async processChunk(chunk: Uint8Array): Promise<DecodedChunkResult[]> {
// If the header search already failed (corrupt/non-WAV stream), stop processing.
if (this.headerError) {
throw new Error(this.headerError);
}
if (!this.wavHeader) {
await this.tryParseHeader(chunk);
// Check again: tryParseHeader may have just set headerError.
if (this.headerError) {
throw new Error(this.headerError);
}
} else {
this.addRawData(chunk);
}
this.updateStreamCompleteFlag();
const results: DecodedChunkResult[] = [];
// Drain all currently-decodable segments. Without this loop, a single
// processChunk call returns at most one segment; the trailing tail
// unlocked once streamComplete flips true would never be flushed.
while (true) {
const segment = await this.tryDecodeNextSegment();
if (!segment) break;
results.push(segment);
}
return results;
}
/**
* Accumulate bytes into the header-search buffer and retry parseHeader.
* Once a header is recognised, anything past headerSize becomes audio data.
*/
private async tryParseHeader(chunk: Uint8Array): Promise<void> {
this.headerSearchChunks.push(chunk);
this.headerBytesReceived += chunk.length;
// Guard against unbounded accumulation from a corrupt or non-WAV stream.
if (this.headerBytesReceived > StreamDecoder.MAX_HEADER_SEARCH_BYTES) {
this.headerError = `WAV header not found after ${this.headerBytesReceived} bytes — stream may be corrupt or not a WAV file`;
console.error(this.headerError);
// Drop the search buffer so subsequent chunks are not accumulated either.
this.headerSearchChunks = [];
this.headerBytesReceived = 0;
return;
}
const header = WavUtils.parseHeader(this.headerSearchChunks, this.headerBytesReceived);
if (!header) {
// Not enough bytes yet — wait for the next chunk. If the stream ends
// without ever producing a valid header, the final processChunk will
// mark streamComplete and the player will report no audio decoded;
// that is the correct failure mode, since there is no audio to play.
console.log(`Header not yet parsable: ${this.headerBytesReceived} bytes accumulated`);
return;
}
this.wavHeader = header;
console.log(`WAV format: ${header.bitsPerSample}-bit, ${header.channels}ch, ${header.sampleRate}Hz`);
console.log(`Header size: ${header.headerSize}, byteRate: ${header.byteRate}`);
// Recreate AudioContext with correct sample rate if needed
if (this.contextManager.sampleRate !== header.sampleRate) {
await this.contextManager.recreateWithSampleRate(header.sampleRate);
}
// Concatenate all header-search chunks and push the audio-data tail
// (everything past headerSize) into the raw audio buffer.
const concatenated = new Uint8Array(this.headerBytesReceived);
let offset = 0;
for (const c of this.headerSearchChunks) {
concatenated.set(c, offset);
offset += c.length;
}
const audioData = concatenated.subarray(header.headerSize);
if (audioData.length > 0) {
this.addRawData(audioData);
}
console.log(`Extracted ${audioData.length} bytes of audio data from header buffer`);
// Header-search buffer no longer needed.
this.headerSearchChunks = [];
this.headerBytesReceived = 0;
}
/**
* Mark the stream complete once we've received all expected bytes. The
* computation must account for whichever stage of header parsing we're in:
* if a header has been parsed, raw audio bytes are tracked separately;
* otherwise pre-header bytes count toward the total.
*/
private updateStreamCompleteFlag(): void {
if (this.totalStreamLength <= 0) return;
const totalReceived = this.wavHeader
? this.totalRawBytes + this.wavHeader.headerSize
: this.headerBytesReceived;
if (totalReceived >= this.totalStreamLength) {
this.streamComplete = true;
}
}
/**
* Add raw audio data to buffer
*/
private addRawData(data: Uint8Array): void {
this.rawChunks.push(data);
this.totalRawBytes += data.length;
}
/**
* Try to decode the next segment of audio.
*
* Failure modes:
* - Decode timeout: retry once, then surface as DecodeTimeoutError (typed).
* - Other decode error (corrupt data, format mismatch): surface as DecodeError.
* Both are thrown rather than silently swallowed — callers (processChunk /
* markStreamComplete) decide whether to abort the stream or skip the segment.
* processedBytes is only advanced on success so a thrown failure does not
* silently consume the failed segment.
*/
private async tryDecodeNextSegment(): Promise<DecodedChunkResult | null> {
if (!this.wavHeader) return null;
const segmentSize = 64 * 1024; // 64KB segments
const availableBytes = this.totalRawBytes - this.processedBytes;
// Passing streamComplete lets the aligner relax the min-frame guard
// for the final tail; otherwise residual <512-byte tails get dropped.
const alignedSize = WavUtils.getSampleAlignedChunkSize(
this.wavHeader,
segmentSize,
availableBytes,
this.streamComplete
);
if (alignedSize <= 0) return null;
const segmentOffset = this.processedBytes;
console.log(`\n--- Decoding segment ---`);
console.log(`Available: ${availableBytes} bytes, aligned size: ${alignedSize} bytes`);
const rawSegment = this.extractAlignedData(alignedSize);
const wavFile = this.createWavFile(rawSegment);
try {
const buffer = await this.decodeWithRetry(wavFile, segmentOffset, alignedSize);
// Advance only after a successful decode so a thrown timeout/decode
// failure does not silently drop the segment.
this.processedBytes += alignedSize;
console.log(`✓ Decoded: ${buffer.duration.toFixed(3)}s, ${buffer.numberOfChannels}ch`);
return { buffer, duration: buffer.duration };
} catch (error) {
// Re-throw typed errors so the outer drain loop in processChunk /
// markStreamComplete sees the real failure instead of an empty array.
// The previous silent return hid timeouts entirely.
if (error instanceof DecodeTimeoutError || error instanceof DecodeError) {
throw error;
}
// Unknown synchronous failure during decode — wrap and surface.
throw new DecodeError(
`Decode failed at offset ${segmentOffset} (${alignedSize} bytes): ${(error as Error).message}`,
segmentOffset,
alignedSize,
error as Error);
}
}
/**
* Decode with a single retry on timeout. Web Audio's decodeAudioData is
* occasionally flaky under tab throttling; a retry costs little and recovers
* the common transient case without dropping the segment.
*/
private async decodeWithRetry(
wavData: Uint8Array,
segmentOffset: number,
alignedSize: number): Promise<AudioBuffer> {
try {
return await this.decodeWithTimeout(wavData);
} catch (error) {
if (!(error instanceof DecodeTimeoutError)) {
throw new DecodeError(
`Decode failed at offset ${segmentOffset} (${alignedSize} bytes): ${(error as Error).message}`,
segmentOffset,
alignedSize,
error as Error);
}
console.warn(
`Decode timeout at offset ${segmentOffset} (${alignedSize} bytes) — retrying once`);
try {
return await this.decodeWithTimeout(wavData);
} catch (retryError) {
if (retryError instanceof DecodeTimeoutError) {
console.error(
`Decode timeout after retry at offset ${segmentOffset} (${alignedSize} bytes)`);
throw new DecodeTimeoutError(segmentOffset, alignedSize);
}
throw new DecodeError(
`Decode failed on retry at offset ${segmentOffset} (${alignedSize} bytes): ${(retryError as Error).message}`,
segmentOffset,
alignedSize,
retryError as Error);
}
}
}
/**
* Extract aligned data from raw chunks
*/
private extractAlignedData(size: number): Uint8Array {
const extracted = new Uint8Array(size);
let extractedOffset = 0;
let remaining = size;
let streamPosition = this.processedBytes;
let currentPos = 0;
for (const chunk of this.rawChunks) {
if (remaining <= 0) break;
if (currentPos + chunk.length <= streamPosition) {
currentPos += chunk.length;
continue;
}
const chunkStartOffset = Math.max(0, streamPosition - currentPos);
const availableInChunk = chunk.length - chunkStartOffset;
const toCopy = Math.min(availableInChunk, remaining);
if (toCopy > 0) {
extracted.set(chunk.subarray(chunkStartOffset, chunkStartOffset + toCopy), extractedOffset);
extractedOffset += toCopy;
remaining -= toCopy;
}
currentPos += chunk.length;
}
return extracted;
}
/**
* Create a complete WAV file from raw audio data
*/
private createWavFile(rawData: Uint8Array): Uint8Array {
const header = WavUtils.createHeader(this.wavHeader!, rawData.length);
const wavFile = new Uint8Array(header.length + rawData.length);
wavFile.set(header, 0);
wavFile.set(rawData, header.length);
return wavFile;
}
/**
* Decode with timeout to prevent hanging. Throws DecodeTimeoutError if the
* deadline expires so callers can distinguish timeout from corrupt-data
* failures (decodeAudioData throws DOMException for the latter).
*/
private async decodeWithTimeout(wavData: Uint8Array, timeoutMs: number = 5000): Promise<AudioBuffer> {
const buffer = new ArrayBuffer(wavData.length);
new Uint8Array(buffer).set(wavData);
const decodePromise = this.contextManager.decodeAudioData(buffer);
let timer: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new DecodeTimeoutError(-1, wavData.length)), timeoutMs);
});
try {
return await Promise.race([decodePromise, timeoutPromise]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}
/**
* Get calculated duration from WAV header
*/
getEstimatedDuration(): number | null {
if (!this.wavHeader || this.wavHeader.byteRate <= 0) return null;
const audioDataSize = this.wavHeader.dataSize > 0
? this.wavHeader.dataSize
: (this.totalStreamLength - this.wavHeader.headerSize);
return audioDataSize / this.wavHeader.byteRate;
}
/**
* Check if WAV header has been parsed
*/
get headerParsed(): boolean {
return this.wavHeader !== null;
}
/**
* Check if all stream data has been received
*/
get isComplete(): boolean {
return this.streamComplete;
}
/**
* Get the WAV header info for byte offset calculation
*/
getWavHeader(): WavHeader | null {
return this.wavHeader;
}
/**
* Calculate byte offset from a time position (in seconds)
* Returns block-aligned byte offset for clean audio
*/
calculateByteOffset(positionSeconds: number): number {
if (!this.wavHeader || this.wavHeader.byteRate <= 0) return 0;
const rawOffset = Math.floor(positionSeconds * this.wavHeader.byteRate);
// Align to block boundary for clean audio
return Math.floor(rawOffset / this.wavHeader.blockAlign) * this.wavHeader.blockAlign;
}
/**
* Explicitly mark the stream as complete.
*
* Called by the C# streaming loop after ReadAsync returns 0 (no more data).
* This ensures streamComplete is set even when the server omits Content-Length,
* which prevents updateStreamCompleteFlag from ever firing via byte counting.
* Returns all remaining decoded segments (the tail drain pass).
*
* If streamComplete was already true (set by updateStreamCompleteFlag during the
* final processChunk call), the tail was already drained inside that call's
* while(true) loop — return immediately to avoid a second drain pass that would
* set streamingCompleted = true even if the first drain had a partial failure.
*/
async markStreamComplete(): Promise<DecodedChunkResult[]> {
if (this.streamComplete) {
return [];
}
this.streamComplete = true;
const results: DecodedChunkResult[] = [];
while (true) {
const segment = await this.tryDecodeNextSegment();
if (!segment) break;
results.push(segment);
}
return results;
}
/**
* Reset decoder state
*/
reset(): void {
this.wavHeader = null;
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.totalStreamLength = 0;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
}
/**
* Reinitialize for offset streaming - preserves header format knowledge
* Called when seeking beyond buffer to prepare for new stream from server
*/
reinitializeForOffset(totalStreamLength: number): void {
// Reset data state but we'll get a fresh header from the offset stream
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
// wavHeader will be reparsed from the new stream (server sends fresh header)
this.wavHeader = null;
console.log(`StreamDecoder reinitialized for offset: expecting ${totalStreamLength} bytes`);
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* Audio Interop - Exposes AudioPlayer to Blazor via window.DeepDrftAudio
*/
import { AudioPlayer, AudioResult, StreamingResult, AudioState } from './AudioPlayer.js';
// Player instances by ID
const audioPlayers = new Map<string, AudioPlayer>();
// .NET interop type
interface DotNetObjectReference {
invokeMethodAsync(methodName: string, ...args: unknown[]): Promise<unknown>;
}
// Global API exposed to Blazor
const DeepDrftAudio = {
createPlayer: async (playerId: string): Promise<AudioResult> => {
try {
const player = new AudioPlayer();
const result = await player.initialize();
if (result.success) {
audioPlayers.set(playerId, player);
}
return result;
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
initializeStreaming: (playerId: string, totalStreamLength: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.initializeStreaming(totalStreamLength);
},
processStreamingChunk: async (playerId: string, chunk: Uint8Array): Promise<StreamingResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.processStreamingChunk(chunk);
},
startStreamingPlayback: async (playerId: string): Promise<AudioResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.startStreamingPlayback();
},
markStreamComplete: async (playerId: string): Promise<StreamingResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.markStreamComplete();
},
ensureAudioContextReady: async (playerId: string): Promise<AudioResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.ensureAudioContextReady();
},
play: async (playerId: string): Promise<AudioResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.play();
},
pause: (playerId: string): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.pause();
},
stop: (playerId: string): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.stop();
},
unload: (playerId: string): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.unload();
},
seek: (playerId: string, position: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.seek(position);
},
// New methods for seek-beyond-buffer support
getBufferedDuration: (playerId: string): number => {
const player = audioPlayers.get(playerId);
return player?.getBufferedDuration() ?? 0;
},
calculateByteOffset: (playerId: string, positionSeconds: number): number => {
const player = audioPlayers.get(playerId);
return player?.calculateByteOffset(positionSeconds) ?? 0;
},
reinitializeFromOffset: (playerId: string, totalStreamLength: number, seekPosition: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.reinitializeFromOffset(totalStreamLength, seekPosition);
},
setVolume: (playerId: string, volume: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.setVolume(volume);
},
getCurrentTime: (playerId: string): number => {
const player = audioPlayers.get(playerId);
return player?.getCurrentTime() ?? 0;
},
getState: (playerId: string): AudioState | null => {
const player = audioPlayers.get(playerId);
return player?.getState() ?? null;
},
setOnProgressCallback: (
playerId: string,
dotNetRef: DotNetObjectReference,
methodName: string
): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
player.setOnProgressCallback((currentTime: number) => {
dotNetRef.invokeMethodAsync(methodName, currentTime);
});
return { success: true };
},
setOnEndCallback: (
playerId: string,
dotNetRef: DotNetObjectReference,
methodName: string
): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
player.setOnEndCallback(() => {
dotNetRef.invokeMethodAsync(methodName);
});
return { success: true };
},
// Spectrum analyzer methods
getSpectrumData: (playerId: string): number[] | null => {
const player = audioPlayers.get(playerId);
return player?.getSpectrumData() ?? null;
},
setSpectrumHighPass: (playerId: string, freq: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.setSpectrumHighPass(freq);
},
setSpectrumLowPass: (playerId: string, freq: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.setSpectrumLowPass(freq);
},
setSpectrumSlope: (playerId: string, dbPerDecade: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.setSpectrumSlope(dbPerDecade);
},
startSpectrumAnimation: (
playerId: string,
callbackId: string,
dotNetRef: DotNetObjectReference,
methodName: string
): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
player.startSpectrumAnimation(callbackId, (data: number[]) => {
dotNetRef.invokeMethodAsync(methodName, data);
});
return { success: true };
},
stopSpectrumAnimation: (playerId: string, callbackId: string): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
player.stopSpectrumAnimation(callbackId);
return { success: true };
},
disposePlayer: (playerId: string): AudioResult => {
const player = audioPlayers.get(playerId);
if (player) {
player.dispose();
audioPlayers.delete(playerId);
return { success: true };
}
return { success: false, error: 'Player not found' };
},
// Legacy compatibility - these may not be needed but kept for safety
initializeBufferedPlayer: (_playerId: string): AudioResult => {
return { success: true }; // No-op for streaming mode
},
appendAudioBlock: (_playerId: string, _audioBlock: Uint8Array): AudioResult => {
return { success: true }; // No-op - use processStreamingChunk instead
},
finalizeAudioBuffer: async (_playerId: string): Promise<AudioResult & { duration?: number }> => {
return { success: true }; // No-op for streaming mode
}
};
// Expose to window
declare global {
interface Window {
DeepDrftAudio: typeof DeepDrftAudio;
}
}
window.DeepDrftAudio = DeepDrftAudio;
export { DeepDrftAudio };
@@ -0,0 +1,250 @@
/**
* AudioBufferManager - Encapsulates all audio buffer storage and scheduling logic.
*
* Responsibilities:
* - Store decoded AudioBuffers (retained for pause/resume/seek)
* - Track playback position
* - Schedule buffers for playback from any position
* - Handle pause/resume without losing audio data
*/
export interface ScheduledBuffer {
source: AudioBufferSourceNode;
startTime: number; // AudioContext time when this buffer starts
duration: number; // Duration of this buffer
bufferIndex: number; // Index in decodedBuffers array
}
export class AudioBufferManager {
private decodedBuffers: AudioBuffer[] = [];
private scheduledSources: ScheduledBuffer[] = [];
private audioContext: AudioContext;
private gainNode: GainNode;
// Playback state
private playbackStartTime: number = 0; // AudioContext.currentTime when playback started
private playbackStartPosition: number = 0; // Position in audio (seconds) where playback started
private nextScheduleIndex: number = 0; // Next buffer index to schedule during streaming
private nextScheduleTime: number = 0; // AudioContext time for next buffer
// Callbacks
public onBufferEnded: (() => void) | null = null;
public onAllBuffersPlayed: (() => void) | null = null;
constructor(audioContext: AudioContext, gainNode: GainNode) {
this.audioContext = audioContext;
this.gainNode = gainNode;
}
/**
* Add a newly decoded buffer to storage
*/
addBuffer(buffer: AudioBuffer): void {
this.decodedBuffers.push(buffer);
console.log(`📦 Buffer added: index=${this.decodedBuffers.length - 1}, duration=${buffer.duration.toFixed(3)}s, total=${this.getTotalDuration().toFixed(3)}s`);
}
/**
* Get total duration of all stored buffers
*/
getTotalDuration(): number {
return this.decodedBuffers.reduce((sum, b) => sum + b.duration, 0);
}
/**
* Get number of stored buffers
*/
getBufferCount(): number {
return this.decodedBuffers.length;
}
/**
* Get current playback position in seconds
*/
getCurrentPosition(): number {
if (this.playbackStartTime === 0) {
return this.playbackStartPosition;
}
const elapsed = this.audioContext.currentTime - this.playbackStartTime;
return this.playbackStartPosition + elapsed;
}
/**
* Schedule playback from a specific position (used for play, resume, seek)
*/
scheduleFromPosition(position: number): void {
// Stop any currently scheduled sources
this.stopAllScheduled();
// Find which buffer contains this position
let accumulatedTime = 0;
let startBufferIndex = 0;
let offsetInBuffer = 0;
for (let i = 0; i < this.decodedBuffers.length; i++) {
const bufferDuration = this.decodedBuffers[i].duration;
if (accumulatedTime + bufferDuration > position) {
startBufferIndex = i;
offsetInBuffer = position - accumulatedTime;
break;
}
accumulatedTime += bufferDuration;
startBufferIndex = i + 1;
}
console.log(`🎯 Scheduling from position ${position.toFixed(3)}s: buffer[${startBufferIndex}] offset=${offsetInBuffer.toFixed(3)}s`);
// Record playback start reference
this.playbackStartPosition = position;
this.playbackStartTime = this.audioContext.currentTime;
this.nextScheduleTime = this.audioContext.currentTime + 0.01; // Small lookahead
// Schedule buffers starting from the found position
this.scheduleBuffersFrom(startBufferIndex, offsetInBuffer);
}
/**
* Schedule pending buffers during live streaming (called when new buffers arrive)
*/
schedulePendingBuffers(): void {
if (this.nextScheduleIndex >= this.decodedBuffers.length) {
return; // No new buffers to schedule
}
// If this is the first scheduling, initialize timing
if (this.nextScheduleTime === 0) {
this.nextScheduleTime = this.audioContext.currentTime + 0.01;
}
this.scheduleBuffersFrom(this.nextScheduleIndex, 0);
}
/**
* Internal: Schedule buffers starting from a specific index
*/
private scheduleBuffersFrom(startIndex: number, offsetInFirstBuffer: number): void {
const lookaheadTarget = 0.5; // Schedule up to 500ms ahead
for (let i = startIndex; i < this.decodedBuffers.length; i++) {
const buffer = this.decodedBuffers[i];
const isFirstBuffer = (i === startIndex && offsetInFirstBuffer > 0);
const offset = isFirstBuffer ? offsetInFirstBuffer : 0;
const duration = buffer.duration - offset;
// Create and configure source
const source = this.audioContext.createBufferSource();
source.buffer = buffer;
source.connect(this.gainNode);
// Set up ended callback
const bufferIndex = i;
source.onended = () => this.handleBufferEnded(bufferIndex);
// Schedule the source
source.start(this.nextScheduleTime, offset);
// Track the scheduled source
this.scheduledSources.push({
source,
startTime: this.nextScheduleTime,
duration,
bufferIndex: i
});
console.log(`🎵 Scheduled buffer[${i}]: start=${this.nextScheduleTime.toFixed(3)}s, offset=${offset.toFixed(3)}s, duration=${duration.toFixed(3)}s`);
// Update timing for next buffer
this.nextScheduleTime += duration;
this.nextScheduleIndex = i + 1;
// Check if we have enough lookahead
const lookahead = this.nextScheduleTime - this.audioContext.currentTime;
if (lookahead > lookaheadTarget) {
console.log(`📋 Sufficient lookahead: ${(lookahead * 1000).toFixed(0)}ms`);
break;
}
}
}
/**
* Handle a buffer finishing playback
*/
private handleBufferEnded(bufferIndex: number): void {
// Remove from scheduled list
this.scheduledSources = this.scheduledSources.filter(s => s.bufferIndex !== bufferIndex);
this.onBufferEnded?.();
// Check if all buffers have finished
if (this.scheduledSources.length === 0 && this.nextScheduleIndex >= this.decodedBuffers.length) {
console.log(`✓ All buffers played`);
this.onAllBuffersPlayed?.();
}
}
/**
* Stop all scheduled sources (for pause/stop)
*/
stopAllScheduled(): void {
for (const scheduled of this.scheduledSources) {
try {
scheduled.source.stop();
} catch (e) {
// Source may already be stopped
}
}
this.scheduledSources = [];
console.log(`⏹️ Stopped all scheduled sources`);
}
/**
* Pause playback - saves position and stops sources
*/
pause(): number {
const position = this.getCurrentPosition();
this.stopAllScheduled();
this.playbackStartPosition = position;
this.playbackStartTime = 0;
console.log(`⏸️ Paused at ${position.toFixed(3)}s`);
return position;
}
/**
* Reset to beginning (for stop)
*/
resetToStart(): void {
this.stopAllScheduled();
this.playbackStartPosition = 0;
this.playbackStartTime = 0;
this.nextScheduleIndex = 0;
this.nextScheduleTime = 0;
console.log(`⏮️ Reset to start`);
}
/**
* Full reset - clears all buffers (for unload/new track)
*/
clear(): void {
this.stopAllScheduled();
this.decodedBuffers = [];
this.playbackStartPosition = 0;
this.playbackStartTime = 0;
this.nextScheduleIndex = 0;
this.nextScheduleTime = 0;
console.log(`🗑️ Buffer manager cleared`);
}
/**
* Check if we have any buffers
*/
hasBuffers(): boolean {
return this.decodedBuffers.length > 0;
}
/**
* Check if we have enough buffers to start playback
*/
hasMinimumBuffers(minCount: number): boolean {
return this.decodedBuffers.length >= minCount;
}
}
+210
View File
@@ -0,0 +1,210 @@
interface WavHeader {
sampleRate: number;
channels: number;
bitsPerSample: number;
byteRate: number;
blockAlign: number;
dataSize: number;
headerSize: number;
}
class WavUtils {
static parseHeader(chunks: Uint8Array[], totalSize: number): WavHeader | null {
if (totalSize < 44) return null;
const concatenated = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
concatenated.set(chunk, offset);
offset += chunk.length;
}
// Need a DataView that spans the entire buffer for chunk searching
const view = new DataView(concatenated.buffer);
// Allocate TextDecoder once for the entire parse pass. Constructing it
// inside the chunk-walk loop would create a new instance per iteration,
// which is non-trivial and unnecessary — a single instance is reusable.
const decoder = new TextDecoder();
// Check RIFF header
const riff = decoder.decode(concatenated.slice(0, 4));
if (riff !== 'RIFF') return null;
const wave = decoder.decode(concatenated.slice(8, 12));
if (wave !== 'WAVE') return null;
// Variables to store parsed header info
let sampleRate = 0;
let channels = 0;
let bitsPerSample = 0;
let byteRate = 0;
let blockAlign = 0;
let dataSize = 0;
let headerSize = 0;
let foundFmt = false;
let foundData = false;
// Find fmt and data chunks
let chunkOffset = 12;
while (chunkOffset < totalSize - 8) {
const chunkId = decoder.decode(concatenated.slice(chunkOffset, chunkOffset + 4));
const chunkSize = view.getUint32(chunkOffset + 4, true);
if (chunkId === 'fmt ') {
// Validate minimum fmt chunk size
if (chunkSize < 16) return null;
const audioFormat = view.getUint16(chunkOffset + 8, true);
// PCM only. The server's WavOffsetService synthesises PCM-shaped headers,
// and AudioProcessor rejects non-PCM at upload — accepting Float here would
// hand the decoder a header/payload mismatch that surfaces as garbled audio.
if (audioFormat !== 1) {
console.warn(`Unsupported audio format: ${audioFormat} (only PCM=1 supported)`);
return null;
}
channels = view.getUint16(chunkOffset + 10, true);
sampleRate = view.getUint32(chunkOffset + 12, true);
byteRate = view.getUint32(chunkOffset + 16, true);
blockAlign = view.getUint16(chunkOffset + 20, true);
bitsPerSample = view.getUint16(chunkOffset + 22, true);
// Basic validation
if (channels < 1 || channels > 8) return null;
if (blockAlign !== channels * (bitsPerSample / 8)) return null;
foundFmt = true;
console.log(`Found fmt chunk: ${bitsPerSample}-bit, ${channels}ch, ${sampleRate}Hz, format=${audioFormat}`);
}
else if (chunkId === 'data') {
dataSize = chunkSize;
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
foundData = true;
console.log(`Found data chunk at offset ${chunkOffset}, headerSize=${headerSize}, dataSize=${dataSize}`);
}
// Move to next chunk with proper alignment (chunks are word-aligned)
chunkOffset += 8 + ((chunkSize + 1) & ~1);
// If we found both chunks, we're done
if (foundFmt && foundData) break;
}
// Must have found both fmt and data chunks
if (!foundFmt || !foundData) {
console.warn(`WAV parsing incomplete: foundFmt=${foundFmt}, foundData=${foundData}`);
return null;
}
return {
sampleRate,
channels,
bitsPerSample,
byteRate,
blockAlign,
dataSize,
headerSize
};
}
static createHeader(wavHeader: WavHeader, dataSize: number): Uint8Array {
const header = new ArrayBuffer(44);
const view = new DataView(header);
// RIFF header
view.setUint8(0, 0x52); view.setUint8(1, 0x49); view.setUint8(2, 0x46); view.setUint8(3, 0x46); // "RIFF"
view.setUint32(4, 36 + dataSize, true); // File size
view.setUint8(8, 0x57); view.setUint8(9, 0x41); view.setUint8(10, 0x56); view.setUint8(11, 0x45); // "WAVE"
// fmt chunk
view.setUint8(12, 0x66); view.setUint8(13, 0x6d); view.setUint8(14, 0x74); view.setUint8(15, 0x20); // "fmt "
view.setUint32(16, 16, true); // fmt chunk size
view.setUint16(20, 1, true); // Audio format (PCM)
view.setUint16(22, wavHeader.channels, true);
view.setUint32(24, wavHeader.sampleRate, true);
view.setUint32(28, wavHeader.byteRate, true);
view.setUint16(32, wavHeader.blockAlign, true);
view.setUint16(34, wavHeader.bitsPerSample, true);
// data chunk header
view.setUint8(36, 0x64); view.setUint8(37, 0x61); view.setUint8(38, 0x74); view.setUint8(39, 0x61); // "data"
view.setUint32(40, dataSize, true);
return new Uint8Array(header);
}
static copyAudioDataDirect(chunks: Uint8Array[], targetBuffer: Uint8Array, targetOffset: number, headerSize: number, audioDataSize: number): number {
// Clear audio data area completely to prevent contamination - KEY FIX
for (let i = targetOffset; i < targetOffset + audioDataSize; i++) {
targetBuffer[i] = 0;
}
// Direct copy of audio data to target buffer, skipping WAV header in first chunk only
let targetPos = targetOffset;
let remainingSize = audioDataSize;
let chunkIndex = 0;
let chunkOffset = headerSize; // Skip WAV header in first chunk
while (remainingSize > 0 && chunkIndex < chunks.length) {
const chunk = chunks[chunkIndex];
const availableInChunk = chunk.length - chunkOffset;
const toCopy = Math.min(availableInChunk, remainingSize);
if (toCopy > 0) {
targetBuffer.set(chunk.subarray(chunkOffset, chunkOffset + toCopy), targetPos);
targetPos += toCopy;
remainingSize -= toCopy;
chunkOffset += toCopy;
}
if (chunkOffset >= chunk.length) {
chunkIndex++;
chunkOffset = 0; // No header to skip in subsequent chunks
}
}
return targetPos - targetOffset; // Return actual bytes copied
}
static patchHeaderSizes(buffer: Uint8Array, audioDataSize: number): void {
// Patch file size (offset 4) and data chunk size (offset 40) - little endian, 4 bytes each
const fileSize = 36 + audioDataSize;
buffer[4] = fileSize & 0xFF;
buffer[5] = (fileSize >> 8) & 0xFF;
buffer[6] = (fileSize >> 16) & 0xFF;
buffer[7] = (fileSize >> 24) & 0xFF;
buffer[40] = audioDataSize & 0xFF;
buffer[41] = (audioDataSize >> 8) & 0xFF;
buffer[42] = (audioDataSize >> 16) & 0xFF;
buffer[43] = (audioDataSize >> 24) & 0xFF;
}
static getSampleAlignedChunkSize(header: WavHeader, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number {
const frameSize = header.blockAlign;
// Much smaller minimum for streaming - just enough for Web Audio API.
// The minimum exists to avoid decoding partial-frame artifacts on
// mid-stream chunks while the rest is still in flight. Once the stream
// is fully received, we must drain whatever remains regardless of size,
// otherwise the trailing tail (often <512 bytes) is silently lost.
const minAudioBytes = Math.max(512, frameSize * 10); // At least 512 bytes or 10 frames
// Mid-stream guard: wait for more data if below minimum.
if (!streamComplete && availableDataSize < minAudioBytes) {
return 0;
}
// Even when complete we still need at least one full frame to decode.
if (availableDataSize < frameSize) {
return 0;
}
// Calculate frames for the available data
const requestedSize = Math.min(maxChunkSize, availableDataSize);
const frames = Math.floor(requestedSize / frameSize);
return frames * frameSize;
}
}
export { WavHeader, WavUtils };
+14
View File
@@ -0,0 +1,14 @@
/**
* webaudio.ts - Legacy entry point for Blazor Audio Interop
*
* This file now delegates to the SOLID audio architecture in ./audio/
* All functionality is provided by the new modular classes:
* - AudioContextManager: Web Audio API context and routing
* - StreamDecoder: WAV parsing and decoding
* - PlaybackScheduler: Buffer storage and playback scheduling
* - AudioPlayer: Main orchestrator
*/
// Re-export from the new SOLID architecture
export { DeepDrftAudio } from './audio/index.js';
export { AudioPlayer, AudioResult, StreamingResult, AudioState } from './audio/AudioPlayer.js';
+120
View File
@@ -0,0 +1,120 @@
using DeepDrftPublic;
using MudBlazor.Services;
using DeepDrftPublic.Components;
using Microsoft.AspNetCore.HttpOverrides;
using NetBlocks.Utilities.Environment;
var builder = WebApplication.CreateBuilder(args);
// Add MudBlazor services
builder.Services.AddMudServices();
// Required credential files — must exist before the app will start.
// In dev: create the files under DeepDrftPublic/environment/ (gitignored).
// In prod: systemd CREDENTIALS_DIRECTORY points to encrypted credential blobs.
// - environment/connections.json: { "ConnectionStrings": { "DefaultConnection": "..." } }
// AuthBlocks and the DeepDrftContent API key now live on DeepDrftManager;
// the public host has no auth surface and no CMS upload proxy.
var connectionsPath = CredentialTools.ResolvePathOrThrow("connections", "environment/connections.json");
builder.Configuration.AddJsonFile(connectionsPath, optional: false, reloadOnChange: false);
var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"] ?? throw new Exception("Content API URL is not configured");
DeepDrftPublic.Client.Startup.ConfigureApiHttpClient(builder.Services, builder.GetKestrelUrl());
DeepDrftPublic.Client.Startup.ConfigureDomainServices(builder.Services);
DeepDrftPublic.Client.Startup.ConfigureContentServices(builder.Services, contentApiUrl);
Startup.ConfigureDomainServices(builder);
builder.Services.AddControllers();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
// Configure SignalR for better circuit cleanup
builder.Services.AddSignalR(options =>
{
if (builder.Environment.IsDevelopment())
{
options.EnableDetailedErrors = true;
options.KeepAliveInterval = TimeSpan.FromSeconds(10);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
}
});
// Configure forwarded headers for reverse proxy support
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
// Trust any proxy (nginx) - in production, specify known proxy networks
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
var app = builder.Build();
// Configure the HTTP request pipeline.
// Use forwarded headers before other middleware
app.UseForwardedHeaders();
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
// Only use HTTPS redirection if not behind a reverse proxy
var disableHttpsRedirection = app.Configuration.GetValue<bool>("ForwardedHeaders:DisableHttpsRedirection");
if (!disableHttpsRedirection)
{
app.UseHttpsRedirection();
}
}
// Antiforgery is required by Blazor form handling. Authentication / authorization
// middleware is intentionally absent — this host is fully anonymous.
app.UseAntiforgery();
// Configure cache headers for Blazor WebAssembly assets
if (app.Environment.IsDevelopment())
{
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/_framework") ||
context.Request.Path.StartsWithSegments("/_content"))
{
context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
context.Response.Headers.Pragma = "no-cache";
context.Response.Headers.Expires = "0";
}
await next();
});
}
app.MapStaticAssets();
// Serve TypeScript source files for debugging in development
if (app.Environment.IsDevelopment())
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(
Path.Combine(app.Environment.ContentRootPath, "Interop")),
RequestPath = "/Interop"
});
}
app.MapControllers();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(DeepDrftPublic.Client._Imports).Assembly);
app.Run();
@@ -0,0 +1,18 @@
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Services;
public class DarkModeService(DarkModeSettings darkModeSettings, IHttpContextAccessor httpAccessor) : DarkModeServiceBase
{
public void CheckDarkMode()
{
bool isDarkMode = false; // Default to light mode
var context = httpAccessor.HttpContext;
if (context?.Request.Cookies.TryGetValue(COOKIE_NAME, out var dark) == true)
{
isDarkMode = dark == "true";
}
darkModeSettings.IsDarkMode = isDarkMode;
}
}
+59
View File
@@ -0,0 +1,59 @@
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftPublic.Services; // DarkModeService namespace (within this host project)
using Microsoft.EntityFrameworkCore;
namespace DeepDrftPublic;
public static class Startup
{
public static void ConfigureDomainServices(WebApplicationBuilder builder)
{
// Add Entity Framework services
builder.Services.AddDbContext<DeepDrftContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// Add Server Prerendering Theming Support
// DarkModeSettings is registered in DeepDrftPublic.Client.Startup.ConfigureDomainServices
builder.Services
.AddHttpContextAccessor()
.AddScoped<DarkModeService>();
// Add Track services. TrackManager implements ITrackService for backward compatibility
// with pages that inject the interface; resolving ITrackService returns the same scoped
// TrackManager instance so the manager surface (DTO-space) and the service surface
// (entity-space) share state.
builder.Services
.AddScoped<TrackRepository>()
.AddScoped<TrackManager>()
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
}
public static string GetKestrelUrl(this WebApplicationBuilder builder)
{
// Check all the places Kestrel URL can be configured
var urls = builder.Configuration["ASPNETCORE_URLS"]
?? builder.Configuration["urls"];
if (!string.IsNullOrEmpty(urls))
{
return urls.Split(';')[0].Trim();
}
// Check Kestrel endpoints configuration
var kestrelSection = builder.Configuration.GetSection("Kestrel:Endpoints");
var firstEndpoint = kestrelSection.GetChildren().FirstOrDefault();
var endpointUrl = firstEndpoint?["Url"];
if (!string.IsNullOrEmpty(endpointUrl))
{
return endpointUrl;
}
// ASP.NET Core defaults
return builder.Environment.IsDevelopment()
? "https://localhost:5001"
: "http://localhost:5000";
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ApiUrls": {
"ContentApi": "http://localhost:12777/"
},
"ForwardedHeaders": {
"DisableHttpsRedirection": "true"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5433;Database=postgres;Username=postgres;Password=your-password-here",
"Auth": "Host=localhost;Port=5433;Database=postgres;Username=postgres;Password=your-password-here"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"outDir": "wwwroot/js",
"sourceRoot": "/Interop",
"mapRoot": "/js"
},
"include": [
"Interop/**/*.ts"
],
"exclude": [
"node_modules",
"bin/**/*",
"obj/**/*",
"publish/**/*"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,328 @@
/* DeepDrft Global Styles - Simplified & Maintainable
Note: the palette / token layer (--deepdrft-*, --theme-*, --gradient-*, and the
.deepdrft-theme-dark override block) lives in DeepDrftShared.Client and is
served at _content/DeepDrftShared.Client/styles/deepdrft-tokens.css. Link that
file BEFORE this one in App.razor every rule below depends on those tokens. */
/* =============================================================================
1. PAGE BASELINE
============================================================================= */
/* Base page colours use MudBlazor's theme-injected variables so they
switch automatically when IsDarkMode toggles. The --mud-palette-background
and --mud-palette-text-primary variables are injected by MudThemeProvider
and update in both light and dark modes. */
html, body {
background-color: var(--mud-palette-background);
color: var(--mud-palette-text-primary);
}
/* Ensure the theme wrapper fills the full viewport so no background gap shows. */
.deepdrft-theme-dark,
.deepdrft-theme-light {
min-height: 100vh;
}
/* =============================================================================
2. GRADIENTS
============================================================================= */
.deepdrft-gradient-primary,
.deepdrft-gradient-hero {
background: linear-gradient(135deg,
var(--gradient-base) 0%,
color-mix(in srgb, var(--gradient-base) 90%, var(--gradient-accent) 10%) 50%,
color-mix(in srgb, var(--gradient-base) 80%, var(--gradient-accent) 20%) 100%);
}
.deepdrft-gradient-soft-primary {
background: linear-gradient(45deg,
color-mix(in srgb, var(--gradient-accent) 4%, transparent) 0%,
color-mix(in srgb, var(--gradient-warm) 6%, transparent) 100%);
}
.deepdrft-gradient-soft-secondary {
background: linear-gradient(45deg,
color-mix(in srgb, var(--gradient-light) 6%, transparent) 0%,
color-mix(in srgb, var(--gradient-accent) 4%, transparent) 100%);
}
.deepdrft-gradient-soft-accent {
background: linear-gradient(135deg,
color-mix(in srgb, var(--gradient-accent) 3%, transparent) 0%,
color-mix(in srgb, var(--gradient-light) 5%, transparent) 100%);
}
.deepdrft-gradient-soft-tertiary {
background: linear-gradient(135deg,
color-mix(in srgb, var(--gradient-warm) 5%, transparent) 0%,
color-mix(in srgb, var(--gradient-accent) 3%, transparent) 100%);
}
.deepdrft-gradient-features {
background: linear-gradient(to right,
color-mix(in srgb, var(--gradient-accent) 2%, transparent) 0%,
color-mix(in srgb, var(--gradient-warm) 3%, transparent) 100%);
}
/* =============================================================================
3. TYPOGRAPHY
============================================================================= */
/* Hero text */
h1, .deepdrft-text-hero {
font-family: var(--deepdrft-font-display);
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}
/* Headers */
h2, h3, h4, h5, h6,
.deepdrft-text-subtitle {
font-family: var(--deepdrft-font-display);
}
.deepdrft-text-subtitle {
font-weight: 300;
text-shadow: 1px 1px 2px rgba(0,0,0,0.3);
}
/* Body */
body, p, span, div,
.deepdrft-text-description,
.deepdrft-text-readable {
font-family: var(--deepdrft-font-body);
}
.deepdrft-text-description {
font-weight: 400;
opacity: 0.9;
}
.deepdrft-text-bold { font-weight: bold; }
.deepdrft-text-readable { line-height: 1.6; }
/* MudBlazor font overrides */
.mud-typography-h1,
.mud-typography-h2, .mud-typography-h3, .mud-typography-h4,
.mud-typography-h5, .mud-typography-h6,
.mud-navlink-text, .mud-appbar-content {
font-family: var(--deepdrft-font-display) !important;
}
.mud-button-text,
.mud-typography-caption, .mud-typography-overline {
font-family: var(--deepdrft-font-mono) !important;
}
.mud-typography-body1, .mud-typography-body2,
.mud-input-text, .mud-select-text, .mud-form-label {
font-family: var(--deepdrft-font-body) !important;
}
/* =============================================================================
4. HERO SECTION
============================================================================= */
.deepdrft-hero-container {
min-height: 60vh;
display: flex;
flex-direction: column;
justify-content: center;
}
.deepdrft-hero-text-container {
max-width: 600px;
}
/* Light mode hero text */
.deepdrft-theme-light .deepdrft-hero-title { color: var(--deepdrft-primary); }
.deepdrft-theme-light .deepdrft-hero-subtitle { color: var(--deepdrft-secondary); }
.deepdrft-theme-light .deepdrft-hero-description { color: var(--theme-surface-soft); }
/* Dark mode hero text */
.deepdrft-theme-dark .deepdrft-hero-title { color: var(--theme-surface); }
.deepdrft-theme-dark .deepdrft-hero-subtitle { color: var(--deepdrft-tertiary); }
.deepdrft-theme-dark .deepdrft-hero-description { color: var(--theme-surface-soft); }
/* Hero buttons - Light */
.deepdrft-theme-light .deepdrft-hero-button-filled.mud-button-filled {
background-color: var(--deepdrft-primary);
color: var(--gradient-base);
}
.deepdrft-theme-light .deepdrft-hero-button-outlined.mud-button-outlined {
border-color: var(--deepdrft-primary);
color: var(--deepdrft-primary);
}
/* Hero buttons - Dark */
.deepdrft-theme-dark .deepdrft-hero-button-filled.mud-button-filled {
background-color: var(--deepdrft-primary);
color: var(--gradient-base);
}
.deepdrft-theme-dark .deepdrft-hero-button-outlined.mud-button-outlined {
border-color: var(--theme-surface);
color: var(--theme-surface);
}
/* =============================================================================
5. APPBAR
============================================================================= */
.deepdrft-theme-light .mud-appbar,
.deepdrft-theme-light .mud-appbar *,
.deepdrft-theme-light .mud-appbar .mud-icon-button {
color: var(--gradient-base);
}
.deepdrft-theme-dark .mud-appbar,
.deepdrft-theme-dark .mud-appbar *,
.deepdrft-theme-dark .mud-appbar .mud-icon-button {
color: var(--theme-surface);
}
/* =============================================================================
6. BORDERS (Only used variants)
============================================================================= */
.deepdrft-border-left-secondary { border-left: 4px solid var(--theme-secondary); }
.deepdrft-border-left-tertiary { border-left: 4px solid var(--theme-tertiary); }
.deepdrft-border-top-quaternary { border-top: 4px solid var(--theme-quaternary); }
.deepdrft-border-top-senary { border-top: 4px solid var(--theme-senary); }
/* =============================================================================
7. CARDS & TINTS (Only used variants)
============================================================================= */
.deepdrft-feature-card,
.deepdrft-about-card { height: 100%; }
.deepdrft-feature-icon-container { text-align: center; }
/* Card tints - using theme variables */
.deepdrft-card-purple-tint { background: color-mix(in srgb, var(--deepdrft-secondary) 10%, transparent); }
.deepdrft-card-pink-tint { background: color-mix(in srgb, var(--gradient-warm) 10%, transparent); }
.deepdrft-card-indigo-tint { background: color-mix(in srgb, var(--gradient-accent) 8%, transparent); }
.deepdrft-card-lavender-tint { background: color-mix(in srgb, var(--theme-quinary) 10%, transparent); }
/* =============================================================================
8. TRACK CARDS
============================================================================= */
.deepdrft-track-card-container {
width: 250px;
height: 250px;
min-width: 250px;
position: relative;
overflow: hidden;
}
.deepdrft-track-card-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: brightness(0.7);
}
.deepdrft-track-card-content {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px;
}
.deepdrft-track-card-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.deepdrft-track-info-middle { margin: 8px 0; }
.deepdrft-track-info-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.deepdrft-track-gallery-item-center {
display: flex;
justify-content: center;
}
/* =============================================================================
9. CHIPS & BUTTONS
============================================================================= */
.deepdrft-chip-spacing { margin: 2px; }
.deepdrft-genre-chip { opacity: 0.9; margin-top: 4px; }
.deepdrft-button-spaced { margin: 8px; }
/* Extended palette chips */
.mud-chip.deepdrft-chip-quaternary {
background-color: var(--theme-quaternary);
color: white;
}
.mud-chip.deepdrft-chip-quinary {
background-color: var(--theme-quinary);
color: white;
}
.mud-chip.deepdrft-chip-senary {
background-color: var(--theme-senary);
color: white;
}
/* =============================================================================
10. EXTENDED PALETTE TEXT COLORS (Only used variants)
============================================================================= */
.deepdrft-text-quaternary { color: var(--theme-quaternary); }
.deepdrft-text-quinary { color: var(--theme-quinary); }
.deepdrft-text-senary { color: var(--theme-senary); }
/* =============================================================================
11. CTA SECTION
============================================================================= */
.deepdrft-cta-container {
border-radius: 16px;
text-align: center;
}
.deepdrft-cta-buttons { margin-bottom: 16px; }
/* =============================================================================
12. ICONS & UTILITIES
============================================================================= */
.deepdrft-icon-large { font-size: 3rem; }
/* =============================================================================
13. RESPONSIVE
============================================================================= */
@media (max-width: 768px) {
.deepdrft-hero-text {
font-size: clamp(1.5rem, 6vw, 3rem) !important;
}
.deepdrft-cta-buttons .mud-button {
margin: 4px !important;
width: 100%;
}
}
@media (max-width: 480px) {
.deepdrft-track-card-container {
min-width: 200px;
width: 200px;
height: 200px;
}
}