Skip to content

Commit

Permalink
Basic sound channel support! (no (un)pause yet)
Browse files Browse the repository at this point in the history
  • Loading branch information
curiousdannii committed Dec 7, 2024
1 parent 08c2840 commit dfb70d1
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ jobs:
test:
strategy:
matrix:
node: [18, 20, 22]
node: [18, 20, 22, 23]
name: test (Node v${{ matrix.node }})
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 1 addition & 1 deletion src/blorb/blorb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export class Blorb {
return null
}

get_chunk(usage: string, num: number): BlorbChunk | null {
get_chunk(usage: BlorbChunk['usage'], num: number): BlorbChunk | null {
return this.chunks[`${usage}:${num}`] || null
}

Expand Down
4 changes: 1 addition & 3 deletions src/common/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,9 +464,7 @@ export interface PlayOperation {
/** Number of repeats (default: 1) */
repeats?: number,
/** Sound resource ID (from a Blorb) */
snd?: number,
/** Sound URL */
url?: string,
snd: number,
}

export interface SetVolumeOperation {
Expand Down
7 changes: 6 additions & 1 deletion src/glkote/common/glkote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export abstract class GlkOteBase implements GlkOte {

protected accept_func: (event: protocol.Event) => void = () => {}
protected autorestoring = false
protected Blorb?: Blorb
Blorb?: Blorb
current_metrics = Object.assign({}, Constants.DEFAULT_METRICS)
protected Dialog?: Dialog
disabled = false
Expand Down Expand Up @@ -207,6 +207,9 @@ export abstract class GlkOteBase implements GlkOte {
if (data.input) {
this.update_inputs(data.input)
}
if (data.schannels && this.Blorb) {
this.update_schannels(data.schannels)
}

if (data.timer !== undefined) {
if (this.timer) {
Expand Down Expand Up @@ -330,6 +333,8 @@ export abstract class GlkOteBase implements GlkOte {

protected set_page_bg(colour: string) {}

protected update_schannels(windows: protocol.SoundChannelUpdate[]) {}

// Functions to be implemented in a subclass
protected abstract cancel_inputs(windows: protocol.InputUpdate[]): void
protected abstract disable(disable: boolean): void
Expand Down
162 changes: 162 additions & 0 deletions src/glkote/web/schannels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
Web GlkOte Sound Channels
=========================
Copyright (c) 2024 Dannii Willis
MIT licenced
https://github.com/curiousdannii/asyncglk
*/

import * as protocol from '../../common/protocol.js'
import WebGlkOte from './web.js'

export class SoundChannelManager extends Map<number, SoundChannel> {
private context: AudioContext
private glkote: WebGlkOte

constructor(glkote: WebGlkOte) {
super()
this.glkote = glkote
this.context = new AudioContext()
}

update(schannels: protocol.SoundChannelUpdate[]) {
const wanted_schannels = []
for (const schannel of schannels) {
const {id, ops} = schannel
wanted_schannels.push(id)

// Add new channels
if (!this.has(id)) {
this.set(id, new SoundChannel(this.glkote, this.context))
}

// Do operations
if (ops) {
this.get(id)!.do_ops(ops)
}
}

// Remove unwanted channels
for (const [id, schannel] of this) {
if (!wanted_schannels.includes(id)) {
schannel.delete()
this.delete(id)
}
}
}
}

export class SoundChannel {
private context: AudioContext
private gain: GainNode
private glkote: WebGlkOte
private notify = 0
private source: AudioBufferSourceNode | null = null

constructor(glkote: WebGlkOte, context: AudioContext) {
this.context = context
this.glkote = glkote
this.gain = context.createGain()
this.gain.connect(context.destination)
}

delete() {
this.gain.disconnect()
}

async do_ops(ops: protocol.SoundChannelOperation[]) {
for (const op of ops) {
switch (op.op) {
case 'pause':
break

case 'play': {
this.stop()

// Get the data from Blorb
const chunk = this.glkote.Blorb!.get_chunk('sound', op.snd)
if (!chunk) {
continue
}
// Decode
const buffer = await this.context.decodeAudioData(chunk.content!.slice().buffer)
const source = this.context.createBufferSource()
source.buffer = buffer

if (op.repeats && op.repeats !== 1) {
source.loop = true
if (op.repeats > 0) {
source.stop(this.context.currentTime + buffer.duration * op.repeats)
}
}

if (op.notify) {
this.notify = op.notify
source.addEventListener('ended', this.on_stop)
}

// Play!
source.connect(this.gain)
source.start()
this.source = source

break
}

case 'stop':
this.stop()
break

case 'unpause':
break

case 'volume':{
const gain = this.gain.gain
const notify = () => {
this.glkote.send_event({
type: 'volume',
notify: op.notify,
})
}

if (op.dur) {
const currentTime = this.context.currentTime
gain.setValueAtTime(gain.value || 0.0001, currentTime)
gain.exponentialRampToValueAtTime(op.vol || 0.0001, currentTime + (op.dur) / 1000)
if (op.notify) {
setTimeout(notify, op.dur)
}
}
else {
gain.value = op.vol
if (op.notify) {
notify()
}
}
break
}
}
}
}

// Only for sound finished events, not volume
private on_stop = () => {
this.glkote.send_event({
type: 'sound',
notify: this.notify,
})
}

private stop() {
const source = this.source
if (source) {
source.removeEventListener('ended', this.on_stop)
source.stop()
source.disconnect()
this.source = null
}
}
}
8 changes: 8 additions & 0 deletions src/glkote/web/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as GlkOte from '../common/glkote.js'
import * as protocol from '../../common/protocol.js'

import Metrics from './metrics.js'
import {SoundChannelManager} from './schannels.js'
import {DOM, is_iOS} from './shared.js'
import TranscriptRecorder from './transcript-recorder.js'
import Windows, {GraphicsWindow} from './windows.js'
Expand Down Expand Up @@ -46,6 +47,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
windowport_id: 'windowport',
})
metrics_calculator: Metrics
private schannels: SoundChannelManager
private showing_error = false
private showing_loading = true
private transcript_recorder?: TranscriptRecorder
Expand All @@ -55,6 +57,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
super()

this.metrics_calculator = new Metrics(this)
this.schannels = new SoundChannelManager(this)
this.windows = new Windows(this)
}

Expand Down Expand Up @@ -180,6 +183,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
'graphics',
'graphicswin',
'hyperlinks',
'sounds',
'timer',
]
}
Expand Down Expand Up @@ -358,6 +362,10 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
this.windows.update_inputs(windows)
}

protected update_schannels(schannels: protocol.SoundChannelUpdate[]) {
this.schannels.update(schannels)
}

protected update_windows(windows: protocol.WindowUpdate[]) {
this.windows.update(windows)
}
Expand Down

0 comments on commit dfb70d1

Please sign in to comment.