-
Notifications
You must be signed in to change notification settings - Fork 59
mount waveform #354
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
mount waveform #354
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { NotFoundException } from '@nestjs/common'; | ||
| import { Test, TestingModule } from '@nestjs/testing'; | ||
| import { getRepositoryToken } from '@nestjs/typeorm'; | ||
| import { WaveformService } from '../waveform/waveform.service'; | ||
| import { TrackEntity } from './track.entity'; | ||
| import { TracksService } from './tracks.service'; | ||
|
|
||
| // ─── Mocks ──────────────────────────────────────────────────────────────────── | ||
|
|
||
| const trackRepoMock = { | ||
| create: jest.fn(), | ||
| save: jest.fn(), | ||
| findOne: jest.fn(), | ||
| find: jest.fn(), | ||
| }; | ||
|
|
||
| const waveformServiceMock = { | ||
| enqueueForTrack: jest.fn(), | ||
| }; | ||
|
|
||
| const makeTrack = (overrides: Partial<TrackEntity> = {}): TrackEntity => | ||
| Object.assign(new TrackEntity(), { | ||
| id: 'track-uuid', | ||
| title: 'Test Track', | ||
| audioFilePath: '/uploads/test.mp3', | ||
| createdAt: new Date(), | ||
| updatedAt: new Date(), | ||
| ...overrides, | ||
| }); | ||
|
|
||
| // ─── Tests ──────────────────────────────────────────────────────────────────── | ||
|
|
||
| describe('TracksService', () => { | ||
| let service: TracksService; | ||
|
|
||
| beforeEach(async () => { | ||
| jest.clearAllMocks(); | ||
|
|
||
| const module: TestingModule = await Test.createTestingModule({ | ||
| providers: [ | ||
| TracksService, | ||
| { provide: getRepositoryToken(TrackEntity), useValue: trackRepoMock }, | ||
| { provide: WaveformService, useValue: waveformServiceMock }, | ||
| ], | ||
| }).compile(); | ||
|
|
||
| service = module.get(TracksService); | ||
| }); | ||
|
|
||
| // ── createTrack ───────────────────────────────────────────────────────────── | ||
|
|
||
| describe('createTrack', () => { | ||
| it('persists the track and enqueues waveform generation', async () => { | ||
| const track = makeTrack(); | ||
| trackRepoMock.create.mockReturnValue(track); | ||
| trackRepoMock.save.mockResolvedValue(track); | ||
| waveformServiceMock.enqueueForTrack.mockResolvedValue('job-1'); | ||
|
|
||
| const result = await service.createTrack({ | ||
| title: 'Test Track', | ||
| audioFilePath: '/uploads/test.mp3', | ||
| }); | ||
|
|
||
| expect(trackRepoMock.save).toHaveBeenCalledWith(track); | ||
| expect(waveformServiceMock.enqueueForTrack).toHaveBeenCalledWith( | ||
| 'track-uuid', | ||
| '/uploads/test.mp3', | ||
| ); | ||
| expect(result).toEqual(track); | ||
| }); | ||
|
|
||
| it('does NOT use setTimeout (no timer mocking needed)', async () => { | ||
| const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); | ||
| const track = makeTrack(); | ||
| trackRepoMock.create.mockReturnValue(track); | ||
| trackRepoMock.save.mockResolvedValue(track); | ||
| waveformServiceMock.enqueueForTrack.mockResolvedValue('job-1'); | ||
|
|
||
| await service.createTrack({ title: 'T', audioFilePath: '/a.mp3' }); | ||
|
|
||
| // Durable queue – no in-process timers | ||
| expect(setTimeoutSpy).not.toHaveBeenCalled(); | ||
| setTimeoutSpy.mockRestore(); | ||
| }); | ||
| }); | ||
|
|
||
| // ── findOne ───────────────────────────────────────────────────────────────── | ||
|
|
||
| describe('findOne', () => { | ||
| it('returns the track when found', async () => { | ||
| const track = makeTrack(); | ||
| trackRepoMock.findOne.mockResolvedValue(track); | ||
|
|
||
| const result = await service.findOne('track-uuid'); | ||
| expect(result).toEqual(track); | ||
| }); | ||
|
|
||
| it('throws NotFoundException when track is missing', async () => { | ||
| trackRepoMock.findOne.mockResolvedValue(null); | ||
| await expect(service.findOne('ghost')).rejects.toThrow(NotFoundException); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { | ||
| Injectable, | ||
| Logger, | ||
| NotFoundException, | ||
| } from '@nestjs/common'; | ||
| import { InjectRepository } from '@nestjs/typeorm'; | ||
| import { Repository } from 'typeorm'; | ||
|
|
||
| import { WaveformService } from '../waveform/waveform.service'; | ||
| import { CreateTrackDto } from './dto/create-track.dto'; | ||
| import { TrackEntity } from './track.entity'; | ||
|
Comment on lines
+9
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Import paths likely incorrect – same issues as spec file. The import 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * TracksService owns the track lifecycle and delegates waveform generation | ||
| * to WaveformService – no direct queue interaction here. | ||
| * | ||
| * Key change: `createTrack` now calls `waveformService.enqueueForTrack` | ||
| * after persisting the track, replacing any fire-and-forget setTimeout that | ||
| * may have existed. | ||
| */ | ||
| @Injectable() | ||
| export class TracksService { | ||
| private readonly logger = new Logger(TracksService.name); | ||
|
|
||
| constructor( | ||
| @InjectRepository(TrackEntity) | ||
| private readonly trackRepo: Repository<TrackEntity>, | ||
|
|
||
| // Injected from WaveformModule (exported). | ||
| private readonly waveformService: WaveformService, | ||
| ) {} | ||
|
|
||
| async createTrack(dto: CreateTrackDto): Promise<TrackEntity> { | ||
| const track = this.trackRepo.create(dto); | ||
| await this.trackRepo.save(track); | ||
|
|
||
| this.logger.log(`Track ${track.id} created – enqueuing waveform generation`); | ||
|
|
||
| // Durable – survives process restarts. No setTimeout. | ||
| await this.waveformService.enqueueForTrack(track.id, track.audioFilePath); | ||
|
|
||
| return track; | ||
| } | ||
|
Comment on lines
+33
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify TrackEntity has audioFilePath field
echo "=== Searching for TrackEntity definition ==="
ast-grep --pattern $'class TrackEntity {
$$$
}'
echo "=== Searching for audioFilePath in track entities ==="
rg -n 'audioFilePath' --type tsRepository: OlufunbiIK/tip-tune Length of output: 2035 🏁 Script executed: #!/bin/bash
# Find track entity and DTO files
echo "=== Finding track-related entity/DTO files ==="
fd -e ts -i track | grep -E "(entity|dto)" | head -20
echo "=== Searching for CreateTrackDto definition ==="
rg -n 'class CreateTrackDto|interface CreateTrackDto' -A 10 --type ts
echo "=== Searching for TrackEntity definition ==="
rg -n 'class TrackEntity|interface TrackEntity|export class TrackEntity' -A 15 --type tsRepository: OlufunbiIK/tip-tune Length of output: 1474 🏁 Script executed: #!/bin/bash
# Read the actual entity and DTO files
echo "=== TrackEntity (backend/src/tracks/entities/track.entity.ts) ==="
cat backend/src/tracks/entities/track.entity.ts
echo ""
echo "=== CreateTrackDto (backend/src/tracks/dto/create-track.dto.ts) ==="
cat backend/src/tracks/dto/create-track.dto.tsRepository: OlufunbiIK/tip-tune Length of output: 4861 Fix field mismatch: The 🤖 Prompt for AI Agents |
||
|
|
||
| async findOne(id: string): Promise<TrackEntity> { | ||
| const track = await this.trackRepo.findOne({ where: { id } }); | ||
| if (!track) throw new NotFoundException(`Track ${id} not found`); | ||
| return track; | ||
| } | ||
|
|
||
| async findAll(): Promise<TrackEntity[]> { | ||
| return this.trackRepo.find(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { Test, TestingModule } from '@nestjs/testing'; | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import * as os from 'os'; | ||
| import { WaveformGeneratorService } from './waveform-generator.service'; | ||
|
|
||
| describe('WaveformGeneratorService', () => { | ||
| let service: WaveformGeneratorService; | ||
| let tmpFile: string; | ||
|
|
||
| beforeEach(async () => { | ||
| const module: TestingModule = await Test.createTestingModule({ | ||
| providers: [WaveformGeneratorService], | ||
| }).compile(); | ||
|
|
||
| service = module.get(WaveformGeneratorService); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| if (tmpFile && fs.existsSync(tmpFile)) { | ||
| fs.unlinkSync(tmpFile); | ||
| } | ||
| }); | ||
|
|
||
| it('should be defined', () => { | ||
| expect(service).toBeDefined(); | ||
| }); | ||
|
|
||
| describe('generateFromFile', () => { | ||
| it('returns peaks array and durationSeconds for a valid file', async () => { | ||
| tmpFile = path.join(os.tmpdir(), `test-${Date.now()}.mp3`); | ||
| // Write 10 KB of fake audio data so peakCount > 100 | ||
| fs.writeFileSync(tmpFile, Buffer.alloc(10 * 1024, 0xaa)); | ||
|
|
||
| const result = await service.generateFromFile(tmpFile); | ||
|
|
||
| expect(result.peaks).toBeInstanceOf(Array); | ||
| expect(result.peaks.length).toBeGreaterThanOrEqual(100); | ||
| expect(result.durationSeconds).toBeGreaterThan(0); | ||
| result.peaks.forEach((p) => { | ||
| expect(p).toBeGreaterThanOrEqual(-1); | ||
| expect(p).toBeLessThanOrEqual(1); | ||
| }); | ||
| }); | ||
|
|
||
| it('produces deterministic peaks for the same file path', async () => { | ||
| tmpFile = path.join(os.tmpdir(), `det-test.mp3`); | ||
| fs.writeFileSync(tmpFile, Buffer.alloc(5 * 1024, 0xbb)); | ||
|
|
||
| const a = await service.generateFromFile(tmpFile); | ||
| const b = await service.generateFromFile(tmpFile); | ||
|
|
||
| expect(a.peaks).toEqual(b.peaks); | ||
| }); | ||
|
|
||
| it('throws when the file does not exist', async () => { | ||
| await expect( | ||
| service.generateFromFile('/nonexistent/path/audio.mp3'), | ||
| ).rejects.toThrow('Audio file not found'); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { Injectable, Logger } from '@nestjs/common'; | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
|
|
||
| export interface GeneratedWaveform { | ||
| peaks: number[]; | ||
| /** Duration in seconds derived from the audio file. */ | ||
| durationSeconds: number; | ||
| } | ||
|
|
||
| /** | ||
| * Responsible solely for reading an audio file and producing normalised | ||
| * peak-amplitude data. All retry / persistence logic lives elsewhere. | ||
| * | ||
| * Production swap-in: replace `generateFromFile` with a call to ffprobe / | ||
| * audiowaveform CLI or a cloud-based audio analysis service. | ||
| */ | ||
| @Injectable() | ||
| export class WaveformGeneratorService { | ||
| private readonly logger = new Logger(WaveformGeneratorService.name); | ||
|
|
||
| /** | ||
| * Generates waveform peaks from a local file path. | ||
| * | ||
| * @throws {Error} when the file cannot be read or parsed. | ||
| */ | ||
| async generateFromFile(filePath: string): Promise<GeneratedWaveform> { | ||
| this.logger.log(`Generating waveform for: ${filePath}`); | ||
|
|
||
| if (!fs.existsSync(filePath)) { | ||
| throw new Error(`Audio file not found: ${filePath}`); | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------ | ||
| // Real implementation would shell out to `audiowaveform` or `ffprobe`. | ||
| // The stub below produces deterministic fake data so the rest of the | ||
| // stack (queue, persistence, API) can be exercised in tests. | ||
| // ------------------------------------------------------------------ | ||
| const stats = fs.statSync(filePath); | ||
| const peakCount = Math.max(100, Math.floor(stats.size / 1024)); | ||
| const peaks = this.buildFakePeaks(peakCount, filePath); | ||
| const durationSeconds = peakCount * 0.1; // 100 ms per sample – rough stub | ||
|
|
||
| return { peaks, durationSeconds }; | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------------- | ||
| // Private helpers | ||
| // ------------------------------------------------------------------------- | ||
|
|
||
| private buildFakePeaks(count: number, seed: string): number[] { | ||
| // Simple seeded PRNG so tests get deterministic output. | ||
| let s = seed.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0); | ||
| const rand = () => { | ||
| s = (s * 1664525 + 1013904223) & 0xffffffff; | ||
| return (s >>> 0) / 0xffffffff; | ||
| }; | ||
|
|
||
| return Array.from({ length: count }, () => | ||
| parseFloat((rand() * 2 - 1).toFixed(4)), | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| export const WAVEFORM_QUEUE = 'waveform'; | ||
|
|
||
| export const WAVEFORM_JOBS = { | ||
| GENERATE: 'generate', | ||
| } as const; | ||
|
|
||
| export const WAVEFORM_JOB_DEFAULTS = { | ||
| /** Max BullMQ attempts before moving to failed */ | ||
| ATTEMPTS: 5, | ||
| /** Exponential back-off: 30 s, 60 s, 120 s, 240 s, 480 s */ | ||
| BACKOFF_DELAY_MS: 30_000, | ||
| } as const; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { NotFoundException } from '@nestjs/common'; | ||
| import { Test, TestingModule } from '@nestjs/testing'; | ||
| import { WaveformStatus } from './dto/waveform.dto'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This spec cannot compile until the DTO module is wired up. Backend CI is already failing with 🧰 Tools🪛 GitHub Actions: Backend CI[error] 3-3: TS2307: Cannot find module './dto/waveform.dto' or its corresponding type declarations. 🤖 Prompt for AI Agents |
||
| import { WaveformController } from './waveform.controller'; | ||
| import { WaveformService } from './waveform.service'; | ||
|
|
||
| const waveformServiceMock = { | ||
| getStatus: jest.fn(), | ||
| regenerate: jest.fn(), | ||
| }; | ||
|
|
||
| describe('WaveformController', () => { | ||
| let controller: WaveformController; | ||
|
|
||
| beforeEach(async () => { | ||
| jest.clearAllMocks(); | ||
|
|
||
| const module: TestingModule = await Test.createTestingModule({ | ||
| controllers: [WaveformController], | ||
| providers: [{ provide: WaveformService, useValue: waveformServiceMock }], | ||
| }).compile(); | ||
|
|
||
| controller = module.get(WaveformController); | ||
| }); | ||
|
|
||
| it('should be defined', () => expect(controller).toBeDefined()); | ||
|
|
||
| // ── GET /tracks/:trackId/waveform ───────────────────────────────────────── | ||
|
|
||
| describe('getStatus', () => { | ||
| it('delegates to waveformService.getStatus', async () => { | ||
| const dto = { | ||
| status: WaveformStatus.DONE, | ||
| peaks: [0.1], | ||
| attempts: 1, | ||
| updatedAt: new Date().toISOString(), | ||
| }; | ||
| waveformServiceMock.getStatus.mockResolvedValue(dto); | ||
|
|
||
| const result = await controller.getStatus('track-uuid'); | ||
|
|
||
| expect(waveformServiceMock.getStatus).toHaveBeenCalledWith('track-uuid'); | ||
| expect(result).toEqual(dto); | ||
| }); | ||
|
|
||
| it('propagates NotFoundException', async () => { | ||
| waveformServiceMock.getStatus.mockRejectedValue( | ||
| new NotFoundException('not found'), | ||
| ); | ||
| await expect(controller.getStatus('ghost')).rejects.toThrow( | ||
| NotFoundException, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // ── POST /tracks/:trackId/waveform/regenerate ───────────────────────────── | ||
|
|
||
| describe('regenerate', () => { | ||
| it('returns queued result', async () => { | ||
| waveformServiceMock.regenerate.mockResolvedValue({ | ||
| result: 'queued', | ||
| jobId: 'j-42', | ||
| }); | ||
|
|
||
| const result = await controller.regenerate('track-uuid'); | ||
|
|
||
| expect(waveformServiceMock.regenerate).toHaveBeenCalledWith('track-uuid'); | ||
| expect(result.result).toBe('queued'); | ||
| }); | ||
|
|
||
| it('returns already_processing when job is in-flight', async () => { | ||
| waveformServiceMock.regenerate.mockResolvedValue({ | ||
| result: 'already_processing', | ||
| jobId: 'in-flight', | ||
| }); | ||
|
|
||
| const result = await controller.regenerate('track-uuid'); | ||
| expect(result.result).toBe('already_processing'); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: OlufunbiIK/tip-tune
Length of output: 723
🏁 Script executed:
cat -n backend/src/mount-waveform/tracks.service.spec.ts | head -20Repository: OlufunbiIK/tip-tune
Length of output: 780
Fix import path for
TrackEntity– module not found.Line 5 imports
TrackEntityfrom'./track.entity', but the file does not exist in the mount-waveform directory. The actual location isbackend/src/tracks/entities/track.entity.ts. Change the import to:This is causing the pipeline failure
TS2307: Cannot find module './track.entity'.🧰 Tools
🪛 GitHub Actions: Backend CI
[error] 5-5: TS2307: Cannot find module './track.entity' or its corresponding type declarations.
🤖 Prompt for AI Agents