refactor(split): rename DeepDrftWeb -> DeepDrftPublic and DeepDrftWeb.Client -> DeepDrftPublic.Client (Phase 4)
This commit is contained in:
@@ -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`.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ApiUrls": {
|
||||
"ContentApi": "http://localhost:12777/"
|
||||
},
|
||||
"ForwardedHeaders": {
|
||||
"DisableHttpsRedirection": "true"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user